mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-22 22:20:29 +01:00
Compare commits
25 Commits
issue_5601
...
feat/gcp-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f3fe7ec6e | ||
|
|
4f45b3a691 | ||
|
|
dc4b4c3d03 | ||
|
|
c5f550d88f | ||
|
|
c2c2552cc6 | ||
|
|
88bd737758 | ||
|
|
8c40d8c2ca | ||
|
|
7be340ce69 | ||
|
|
852ca779c9 | ||
|
|
05b35dd4ee | ||
|
|
bcea24f449 | ||
|
|
22e55340d7 | ||
|
|
dd8cbe0844 | ||
|
|
6b065aa54c | ||
|
|
ac5b5947eb | ||
|
|
c563d79ee0 | ||
|
|
b9b92f951b | ||
|
|
6bcc2d66d9 | ||
|
|
5ee1ca7d05 | ||
|
|
98c0c53a2b | ||
|
|
6d3861d60f | ||
|
|
20e13f66e0 | ||
|
|
07fe353b94 | ||
|
|
9fb89fafc8 | ||
|
|
0655328fa9 |
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -53,8 +53,8 @@ jobs:
|
||||
- queriermetrics
|
||||
- querierscalar
|
||||
- queriercommon
|
||||
- querierai
|
||||
- rawexportdata
|
||||
- querierauthz
|
||||
- role
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
|
||||
@@ -2596,19 +2596,6 @@ components:
|
||||
repeatVariable:
|
||||
type: string
|
||||
type: object
|
||||
DashboardLink:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
renderVariables:
|
||||
type: boolean
|
||||
targetBlank:
|
||||
type: boolean
|
||||
tooltip:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesAxes:
|
||||
properties:
|
||||
isLogScale:
|
||||
@@ -2747,6 +2734,7 @@ components:
|
||||
datasources:
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/DashboardtypesDatasourceSpec'
|
||||
nullable: true
|
||||
type: object
|
||||
display:
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
@@ -2758,7 +2746,8 @@ components:
|
||||
type: array
|
||||
links:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardLink'
|
||||
$ref: '#/components/schemas/DashboardtypesLink'
|
||||
nullable: true
|
||||
type: array
|
||||
panels:
|
||||
additionalProperties:
|
||||
@@ -3029,6 +3018,19 @@ components:
|
||||
- solid
|
||||
- dashed
|
||||
type: string
|
||||
DashboardtypesLink:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
renderVariables:
|
||||
type: boolean
|
||||
targetBlank:
|
||||
type: boolean
|
||||
tooltip:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesListOrder:
|
||||
enum:
|
||||
- asc
|
||||
@@ -3039,6 +3041,7 @@ components:
|
||||
selectFields:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
DashboardtypesListSort:
|
||||
@@ -3388,7 +3391,8 @@ components:
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
links:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardLink'
|
||||
$ref: '#/components/schemas/DashboardtypesLink'
|
||||
nullable: true
|
||||
type: array
|
||||
plugin:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelPlugin'
|
||||
@@ -6333,6 +6337,7 @@ components:
|
||||
- delta
|
||||
- cumulative
|
||||
- unspecified
|
||||
- ""
|
||||
type: string
|
||||
MetrictypesTimeAggregation:
|
||||
enum:
|
||||
@@ -6345,6 +6350,7 @@ components:
|
||||
- count_distinct
|
||||
- rate
|
||||
- increase
|
||||
- ""
|
||||
type: string
|
||||
MetrictypesType:
|
||||
enum:
|
||||
@@ -6552,6 +6558,7 @@ components:
|
||||
args:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5FunctionArg'
|
||||
nullable: true
|
||||
type: array
|
||||
name:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5FunctionName'
|
||||
@@ -6722,6 +6729,7 @@ components:
|
||||
functions:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Function'
|
||||
nullable: true
|
||||
type: array
|
||||
having:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Having'
|
||||
@@ -6734,6 +6742,7 @@ components:
|
||||
order:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregation:
|
||||
@@ -6741,6 +6750,7 @@ components:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LogAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
cursor:
|
||||
type: string
|
||||
@@ -6751,10 +6761,12 @@ components:
|
||||
functions:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Function'
|
||||
nullable: true
|
||||
type: array
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
having:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Having'
|
||||
@@ -6771,14 +6783,17 @@ components:
|
||||
order:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
nullable: true
|
||||
type: array
|
||||
secondaryAggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5SecondaryAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
selectFields:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
signal:
|
||||
enum:
|
||||
@@ -6796,6 +6811,7 @@ components:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5MetricAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
cursor:
|
||||
type: string
|
||||
@@ -6806,10 +6822,12 @@ components:
|
||||
functions:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Function'
|
||||
nullable: true
|
||||
type: array
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
having:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Having'
|
||||
@@ -6826,14 +6844,17 @@ components:
|
||||
order:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
nullable: true
|
||||
type: array
|
||||
secondaryAggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5SecondaryAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
selectFields:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
signal:
|
||||
enum:
|
||||
@@ -6851,6 +6872,7 @@ components:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
cursor:
|
||||
type: string
|
||||
@@ -6861,10 +6883,12 @@ components:
|
||||
functions:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Function'
|
||||
nullable: true
|
||||
type: array
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
having:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Having'
|
||||
@@ -6881,14 +6905,17 @@ components:
|
||||
order:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
nullable: true
|
||||
type: array
|
||||
secondaryAggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5SecondaryAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
selectFields:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
signal:
|
||||
enum:
|
||||
@@ -6906,6 +6933,7 @@ components:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
cursor:
|
||||
type: string
|
||||
@@ -6918,10 +6946,12 @@ components:
|
||||
functions:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Function'
|
||||
nullable: true
|
||||
type: array
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
having:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Having'
|
||||
@@ -6936,12 +6966,14 @@ components:
|
||||
order:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
nullable: true
|
||||
type: array
|
||||
returnSpansFrom:
|
||||
type: string
|
||||
selectFields:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
stepInterval:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Step'
|
||||
@@ -7181,6 +7213,7 @@ components:
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
type: integer
|
||||
@@ -7189,6 +7222,7 @@ components:
|
||||
order:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
nullable: true
|
||||
type: array
|
||||
stepInterval:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Step'
|
||||
@@ -7571,6 +7605,7 @@ components:
|
||||
groupBy:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
newGroupEvalDelay:
|
||||
type: string
|
||||
@@ -7642,6 +7677,7 @@ components:
|
||||
alertStates:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
nullable: true
|
||||
type: array
|
||||
enabled:
|
||||
type: boolean
|
||||
@@ -8565,7 +8601,7 @@ components:
|
||||
TelemetrytypesSource:
|
||||
enum:
|
||||
- meter
|
||||
- ai
|
||||
- ""
|
||||
type: string
|
||||
TelemetrytypesTelemetryFieldKey:
|
||||
properties:
|
||||
@@ -24465,9 +24501,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
|
||||
@@ -24534,9 +24578,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"
|
||||
}
|
||||
@@ -3300,29 +3300,6 @@ export interface DashboardGridLayoutSpecDTO {
|
||||
repeatVariable?: string;
|
||||
}
|
||||
|
||||
export interface DashboardLinkDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
renderVariables?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
targetBlank?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
tooltip?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAxesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -3489,9 +3466,9 @@ export enum Querybuildertypesv5FunctionNameDTO {
|
||||
}
|
||||
export interface Querybuildertypesv5FunctionDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
args?: Querybuildertypesv5FunctionArgDTO[];
|
||||
args?: Querybuildertypesv5FunctionArgDTO[] | null;
|
||||
name?: Querybuildertypesv5FunctionNameDTO;
|
||||
}
|
||||
|
||||
@@ -3593,18 +3570,18 @@ export interface Querybuildertypesv5SecondaryAggregationDTO {
|
||||
*/
|
||||
expression?: string;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[];
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
limit?: number;
|
||||
limitBy?: Querybuildertypesv5LimitByDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
order?: Querybuildertypesv5OrderByDTO[];
|
||||
order?: Querybuildertypesv5OrderByDTO[] | null;
|
||||
stepInterval?: Querybuildertypesv5StepDTO;
|
||||
}
|
||||
|
||||
@@ -3631,13 +3608,13 @@ export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQue
|
||||
}
|
||||
export enum TelemetrytypesSourceDTO {
|
||||
meter = 'meter',
|
||||
ai = 'ai',
|
||||
'' = '',
|
||||
}
|
||||
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5LogAggregationDTO[];
|
||||
aggregations?: Querybuildertypesv5LogAggregationDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3648,13 +3625,13 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
disabled?: boolean;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
functions?: Querybuildertypesv5FunctionDTO[];
|
||||
functions?: Querybuildertypesv5FunctionDTO[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[];
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
having?: Querybuildertypesv5HavingDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -3674,17 +3651,17 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
order?: Querybuildertypesv5OrderByDTO[];
|
||||
order?: Querybuildertypesv5OrderByDTO[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[];
|
||||
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
|
||||
/**
|
||||
* @enum logs
|
||||
* @type string
|
||||
@@ -3731,6 +3708,7 @@ export enum MetrictypesTemporalityDTO {
|
||||
delta = 'delta',
|
||||
cumulative = 'cumulative',
|
||||
unspecified = 'unspecified',
|
||||
'' = '',
|
||||
}
|
||||
export enum MetrictypesTimeAggregationDTO {
|
||||
latest = 'latest',
|
||||
@@ -3742,6 +3720,7 @@ export enum MetrictypesTimeAggregationDTO {
|
||||
count_distinct = 'count_distinct',
|
||||
rate = 'rate',
|
||||
increase = 'increase',
|
||||
'' = '',
|
||||
}
|
||||
export interface Querybuildertypesv5MetricAggregationDTO {
|
||||
comparisonSpaceAggregationParam?: MetrictypesComparisonSpaceAggregationParamDTO;
|
||||
@@ -3760,9 +3739,9 @@ export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQue
|
||||
}
|
||||
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5MetricAggregationDTO[];
|
||||
aggregations?: Querybuildertypesv5MetricAggregationDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3773,13 +3752,13 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
disabled?: boolean;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
functions?: Querybuildertypesv5FunctionDTO[];
|
||||
functions?: Querybuildertypesv5FunctionDTO[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[];
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
having?: Querybuildertypesv5HavingDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -3799,17 +3778,17 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
order?: Querybuildertypesv5OrderByDTO[];
|
||||
order?: Querybuildertypesv5OrderByDTO[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[];
|
||||
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
|
||||
/**
|
||||
* @enum metrics
|
||||
* @type string
|
||||
@@ -3835,9 +3814,9 @@ export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQue
|
||||
}
|
||||
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5TraceAggregationDTO[];
|
||||
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3848,13 +3827,13 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
disabled?: boolean;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
functions?: Querybuildertypesv5FunctionDTO[];
|
||||
functions?: Querybuildertypesv5FunctionDTO[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[];
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
having?: Querybuildertypesv5HavingDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -3874,17 +3853,17 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
order?: Querybuildertypesv5OrderByDTO[];
|
||||
order?: Querybuildertypesv5OrderByDTO[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[];
|
||||
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
|
||||
/**
|
||||
* @enum traces
|
||||
* @type string
|
||||
@@ -4035,10 +4014,16 @@ export interface DashboardtypesDatasourceSpecDTO {
|
||||
plugin?: DashboardtypesDatasourcePluginDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesDashboardSpecDTODatasources = {
|
||||
export type DashboardtypesDashboardSpecDTODatasourcesAnyOf = {
|
||||
[key: string]: DashboardtypesDatasourceSpecDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesDashboardSpecDTODatasources =
|
||||
DashboardtypesDashboardSpecDTODatasourcesAnyOf | null;
|
||||
|
||||
export enum DashboardtypesPanelKindDTO {
|
||||
Panel = 'Panel',
|
||||
}
|
||||
@@ -4053,6 +4038,29 @@ export interface DashboardtypesDisplayDTO {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesLinkDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
renderVariables?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
targetBlank?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
tooltip?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTOKind {
|
||||
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
|
||||
}
|
||||
@@ -4273,9 +4281,9 @@ export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboa
|
||||
}
|
||||
export interface DashboardtypesListPanelSpecDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO {
|
||||
@@ -4345,9 +4353,9 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
*/
|
||||
expression?: string;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
functions?: Querybuildertypesv5FunctionDTO[];
|
||||
functions?: Querybuildertypesv5FunctionDTO[] | null;
|
||||
having?: Querybuildertypesv5HavingDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -4362,9 +4370,9 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
order?: Querybuildertypesv5OrderByDTO[];
|
||||
order?: Querybuildertypesv5OrderByDTO[] | null;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5QueryEnvelopeFormulaDTOType {
|
||||
@@ -4381,9 +4389,9 @@ export interface Querybuildertypesv5QueryEnvelopeFormulaDTO {
|
||||
|
||||
export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5TraceAggregationDTO[];
|
||||
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4398,13 +4406,13 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
|
||||
expression?: string;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
functions?: Querybuildertypesv5FunctionDTO[];
|
||||
functions?: Querybuildertypesv5FunctionDTO[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[];
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
having?: Querybuildertypesv5HavingDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -4423,17 +4431,17 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
order?: Querybuildertypesv5OrderByDTO[];
|
||||
order?: Querybuildertypesv5OrderByDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
returnSpansFrom?: string;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
|
||||
stepInterval?: Querybuildertypesv5StepDTO;
|
||||
}
|
||||
|
||||
@@ -4614,9 +4622,9 @@ export interface DashboardtypesQueryDTO {
|
||||
export interface DashboardtypesPanelSpecDTO {
|
||||
display: DashboardtypesDisplayDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
links?: DashboardLinkDTO[];
|
||||
links?: DashboardtypesLinkDTO[] | null;
|
||||
plugin: DashboardtypesPanelPluginDTO;
|
||||
/**
|
||||
* @type array
|
||||
@@ -4790,7 +4798,7 @@ export type DashboardtypesVariableDTO =
|
||||
|
||||
export interface DashboardtypesDashboardSpecDTO {
|
||||
/**
|
||||
* @type object
|
||||
* @type object,null
|
||||
*/
|
||||
datasources?: DashboardtypesDashboardSpecDTODatasources;
|
||||
display: DashboardtypesDisplayDTO;
|
||||
@@ -4803,9 +4811,9 @@ export interface DashboardtypesDashboardSpecDTO {
|
||||
*/
|
||||
layouts: DashboardtypesLayoutDTO[];
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
links?: DashboardLinkDTO[];
|
||||
links?: DashboardtypesLinkDTO[] | null;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
@@ -8676,9 +8684,9 @@ export interface RuletypesGettableTestRuleDTO {
|
||||
|
||||
export interface RuletypesRenotifyDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
alertStates?: RuletypesAlertStateDTO[];
|
||||
alertStates?: RuletypesAlertStateDTO[] | null;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -8691,9 +8699,9 @@ export interface RuletypesRenotifyDTO {
|
||||
|
||||
export interface RuletypesNotificationSettingsDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
groupBy?: string[];
|
||||
groupBy?: string[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
1
frontend/src/assets/Logos/gcp.svg
Normal file
1
frontend/src/assets/Logos/gcp.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 128 128"><path fill="#ea4535" d="M80.6 40.3h.4l-.2-.2 14-14v-.3c-11.8-10.4-28.1-14-43.2-9.5C36.5 20.8 24.9 32.8 20.7 48c.2-.1.5-.2.8-.2 5.2-3.4 11.4-5.4 17.9-5.4 2.2 0 4.3.2 6.4.6.1-.1.2-.1.3-.1 9-9.9 24.2-11.1 34.6-2.6h-.1z"/><path fill="#557ebf" d="M108.1 47.8c-2.3-8.5-7.1-16.2-13.8-22.1L80 39.9c6 4.9 9.5 12.3 9.3 20v2.5c16.9 0 16.9 25.2 0 25.2H63.9v20h-.1l.1.2h25.4c14.6.1 27.5-9.3 31.8-23.1 4.3-13.8-1-28.8-13-36.9z"/><path fill="#36a852" d="M39 107.9h26.3V87.7H39c-1.9 0-3.7-.4-5.4-1.1l-15.2 14.6v.2c6 4.3 13.2 6.6 20.7 6.6z"/><path fill="#f9bc15" d="M40.2 41.9c-14.9.1-28.1 9.3-32.9 22.8-4.8 13.6 0 28.5 11.8 37.3l15.6-14.9c-8.6-3.7-10.6-14.5-4-20.8 6.6-6.4 17.8-4.4 21.7 3.8L68 55.2C61.4 46.9 51.1 42 40.2 42.1z"/></svg>
|
||||
|
After Width: | Height: | Size: 805 B |
@@ -1,4 +1,4 @@
|
||||
.timeseries-export-popover {
|
||||
.export-menu-popover {
|
||||
width: 240px;
|
||||
padding: 0 12px 12px 12px;
|
||||
|
||||
@@ -4,42 +4,43 @@ 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 { useClientExport } from 'hooks/useExportData/useClientExport';
|
||||
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 { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import './TimeseriesExportMenu.styles.scss';
|
||||
import './ExportMenu.styles.scss';
|
||||
|
||||
interface TimeseriesExportMenuProps {
|
||||
interface ExportMenuProps {
|
||||
dataSource: DataSource;
|
||||
queryResponse: QueryRangeResponseV5;
|
||||
// The queryRange response object the view holds — the hook picks the
|
||||
// serializer (timeseries / table) from what it carries.
|
||||
data: ClientExportData;
|
||||
query?: Query;
|
||||
yAxisUnit?: string;
|
||||
legendMap?: Record<string, string>;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
// Download menu for in-memory timeseries data (client-side serialization).
|
||||
// Download menu for in-memory query results (client-side serialization).
|
||||
// The raw/list backend export keeps its own menu in DownloadOptionsMenu.
|
||||
export default function TimeseriesExportMenu({
|
||||
export default function ExportMenu({
|
||||
dataSource,
|
||||
queryResponse,
|
||||
data,
|
||||
query,
|
||||
yAxisUnit,
|
||||
legendMap,
|
||||
fileName,
|
||||
}: TimeseriesExportMenuProps): JSX.Element {
|
||||
}: ExportMenuProps): JSX.Element {
|
||||
const [exportFormat, setExportFormat] = useState<string>(ExportFormat.Csv);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const { isExporting, handleExport: handleClientExport } = useClientExport({
|
||||
response: queryResponse,
|
||||
data,
|
||||
query,
|
||||
yAxisUnit,
|
||||
legendMap,
|
||||
fileName,
|
||||
});
|
||||
|
||||
@@ -57,7 +58,7 @@ export default function TimeseriesExportMenu({
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Download"
|
||||
data-testid={`timeseries-export-${dataSource}`}
|
||||
data-testid={`export-menu-${dataSource}`}
|
||||
disabled={isExporting}
|
||||
loading={isExporting}
|
||||
>
|
||||
@@ -65,7 +66,7 @@ export default function TimeseriesExportMenu({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipSimple>
|
||||
<PopoverContent align="end" className="timeseries-export-popover">
|
||||
<PopoverContent align="end" className="export-menu-popover">
|
||||
<div className="export-format">
|
||||
<Typography.Text className="title">FORMAT</Typography.Text>
|
||||
<RadioGroup value={exportFormat} onChange={setExportFormat}>
|
||||
@@ -1,8 +1,8 @@
|
||||
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import TimeseriesExportMenu from '../TimeseriesExportMenu';
|
||||
import ExportMenu from '../ExportMenu';
|
||||
|
||||
const mockHandleExport = jest.fn();
|
||||
let mockIsExporting = false;
|
||||
@@ -14,25 +14,26 @@ jest.mock('hooks/useExportData/useClientExport', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const response = {
|
||||
type: 'time_series',
|
||||
data: { results: [] },
|
||||
meta: {},
|
||||
} as unknown as QueryRangeResponseV5;
|
||||
const data = {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: { data: { result: [], resultType: 'time_series' } },
|
||||
} as unknown as MetricQueryRangeSuccessResponse;
|
||||
|
||||
const TEST_ID = `timeseries-export-${DataSource.LOGS}`;
|
||||
const TEST_ID = `export-menu-${DataSource.LOGS}`;
|
||||
|
||||
function renderMenu(): void {
|
||||
render(
|
||||
<TimeseriesExportMenu
|
||||
<ExportMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
queryResponse={response}
|
||||
data={data}
|
||||
fileName="logs-timeseries"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('TimeseriesExportMenu', () => {
|
||||
describe('ExportMenu', () => {
|
||||
beforeEach(() => {
|
||||
mockHandleExport.mockReset();
|
||||
mockIsExporting = false;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -238,67 +238,12 @@ 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,
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
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';
|
||||
@@ -23,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';
|
||||
@@ -47,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);
|
||||
@@ -55,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;
|
||||
@@ -210,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>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,10 @@ import * as Sentry from '@sentry/react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import {
|
||||
QuickFilterCheckboxUseFieldApis,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -36,7 +39,9 @@ import {
|
||||
GetPodsQuickFiltersConfig,
|
||||
GetStatefulsetsQuickFiltersConfig,
|
||||
GetVolumesQuickFiltersConfig,
|
||||
InfraMonitoringEntity,
|
||||
K8sCategories,
|
||||
METRIC_NAMESPACE_BY_ENTITY,
|
||||
} from './constants';
|
||||
import K8sDaemonSetsList from './DaemonSets/K8sDaemonSetsList';
|
||||
import K8sDeploymentsList from './Deployments/K8sDeploymentsList';
|
||||
@@ -56,6 +61,7 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
|
||||
import styles from './InfraMonitoringK8s.module.scss';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
|
||||
|
||||
export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
@@ -69,6 +75,31 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const { currentQuery, 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]);
|
||||
|
||||
const getUseFieldApis = useCallback(
|
||||
(entity: InfraMonitoringEntity): QuickFilterCheckboxUseFieldApis => ({
|
||||
metricNamespace: METRIC_NAMESPACE_BY_ENTITY[entity],
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
}),
|
||||
[startUnixMilli, endUnixMilli],
|
||||
);
|
||||
|
||||
const selectedCategoryUseFieldApis = useMemo(
|
||||
() => getUseFieldApis(selectedCategory as InfraMonitoringEntity),
|
||||
[getUseFieldApis, selectedCategory],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized.current) {
|
||||
return;
|
||||
@@ -269,6 +300,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={selectedCategoryConfig}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={selectedCategoryUseFieldApis}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,8 @@ import { QueryParams } from 'constants/query';
|
||||
import { initialQueryMeterWithType } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { INITIAL_ALERT_THRESHOLD_STATE } from 'container/CreateAlertV2/context/constants';
|
||||
import { EvaluationWindowPreset } from 'container/CreateAlertV2/context/resolveUrlAlertPrefill';
|
||||
import { AlertThresholdMatchType } from 'container/CreateAlertV2/context/types';
|
||||
import dayjs from 'dayjs';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useGetGlobalConfig } from 'api/generated/services/global';
|
||||
@@ -907,6 +909,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
? `[ingestion][${signal.signal}] ${keyName} has exceeded daily ingestion limit`
|
||||
: `[ingestion][${signal.signal}] ${signal.signal} has exceeded daily ingestion limit`;
|
||||
|
||||
// Declare the metering prefill explicitly: "in total" + the meter window.
|
||||
const URL = `${ROUTES.ALERTS_NEW}?${
|
||||
QueryParams.compositeQuery
|
||||
}=${encodeURIComponent(stringifiedQuery)}&${
|
||||
@@ -915,7 +918,9 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
QueryParams.ruleName
|
||||
}=${encodeURIComponent(ruleName)}&${
|
||||
QueryParams.yAxisUnit
|
||||
}=${encodeURIComponent(yAxisUnit)}`;
|
||||
}=${encodeURIComponent(yAxisUnit)}&${QueryParams.matchType}=${
|
||||
AlertThresholdMatchType.IN_TOTAL
|
||||
}&${QueryParams.evaluationWindowPreset}=${EvaluationWindowPreset.METER}`;
|
||||
|
||||
history.push(URL);
|
||||
};
|
||||
|
||||
@@ -17,9 +17,12 @@ import { ChevronDown, Dot, PencilLine, Plug, Plus } from '@signozhq/icons';
|
||||
|
||||
import AzureCloudAccountSetupModal from '../../AzureCloudServices/AddNewAccount/CloudAccountSetupModal';
|
||||
import AzureAccountSettingsModal from '../../AzureCloudServices/EditAccount/AccountSettingsModal';
|
||||
import GcpCloudAccountSetupDrawer from '../../GoogleCloudPlatform/AddNewAccount/CloudAccountSetupDrawer';
|
||||
import GcpAccountSettingsDrawer from '../../GoogleCloudPlatform/EditAccount/AccountSettingsDrawer';
|
||||
import {
|
||||
mapAccountDtoToAwsCloudAccount,
|
||||
mapAccountDtoToAzureCloudAccount,
|
||||
mapAccountDtoToGcpCloudAccount,
|
||||
} from '../../mapCloudAccountFromDto';
|
||||
import AwsCloudAccountSetupModal from '../AddNewAccount/CloudAccountSetupModal';
|
||||
import AwsAccountSettingsModal from '../EditAccount/AccountSettingsModal';
|
||||
@@ -156,6 +159,18 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
|
||||
});
|
||||
}
|
||||
|
||||
if (type === IntegrationType.GCP_SERVICES) {
|
||||
raw.forEach((account) => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
const mapped = mapAccountDtoToGcpCloudAccount(account);
|
||||
if (mapped) {
|
||||
mappedAccounts.push(mapped);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return mappedAccounts;
|
||||
}, [listAccountsResponse, type]);
|
||||
|
||||
@@ -207,13 +222,23 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
|
||||
// log telemetry event when an account is viewed.
|
||||
useEffect(() => {
|
||||
if (activeAccount) {
|
||||
const { config } = activeAccount;
|
||||
let enabledRegions: string[];
|
||||
if ('regions' in config) {
|
||||
// AWS
|
||||
enabledRegions = config.regions;
|
||||
} else if ('resource_groups' in config) {
|
||||
// Azure
|
||||
enabledRegions = config.resource_groups;
|
||||
} else {
|
||||
// GCP
|
||||
enabledRegions = config.project_ids;
|
||||
}
|
||||
|
||||
logEvent(`${type} Integration: Account viewed`, {
|
||||
cloudAccountId: activeAccount?.cloud_account_id,
|
||||
status: activeAccount?.status,
|
||||
enabledRegions:
|
||||
'regions' in activeAccount.config
|
||||
? activeAccount.config.regions
|
||||
: activeAccount.config.resource_groups,
|
||||
enabledRegions,
|
||||
});
|
||||
}
|
||||
}, [activeAccount, type]);
|
||||
@@ -260,6 +285,11 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
|
||||
onClose={(): void => setIsIntegrationModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{type === IntegrationType.GCP_SERVICES && (
|
||||
<GcpCloudAccountSetupDrawer
|
||||
onClose={(): void => setIsIntegrationModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -281,6 +311,13 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
|
||||
setActiveAccount={setActiveAccount}
|
||||
/>
|
||||
)}
|
||||
{type === IntegrationType.GCP_SERVICES && (
|
||||
<GcpAccountSettingsDrawer
|
||||
onClose={(): void => setIsAccountSettingsModalOpen(false)}
|
||||
account={activeAccount}
|
||||
setActiveAccount={setActiveAccount}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,14 +46,31 @@ const EMPTY_FORM_VALUES: ServiceConfigFormValues = {
|
||||
s3BucketsByRegion: {},
|
||||
};
|
||||
|
||||
function getIntegrationServiceConfig(
|
||||
type: IntegrationType,
|
||||
serviceDetailsData?: ServiceDetailsData,
|
||||
):
|
||||
| { logs?: { enabled?: boolean }; metrics?: { enabled?: boolean } }
|
||||
| undefined {
|
||||
const config = serviceDetailsData?.cloudIntegrationService?.config;
|
||||
|
||||
if (type === IntegrationType.AWS_SERVICES) {
|
||||
return config?.aws;
|
||||
}
|
||||
if (type === IntegrationType.GCP_SERVICES) {
|
||||
return config?.gcp;
|
||||
}
|
||||
return config?.azure;
|
||||
}
|
||||
|
||||
function getInitialFormValues(
|
||||
type: IntegrationType,
|
||||
serviceDetailsData?: ServiceDetailsData,
|
||||
): ServiceConfigFormValues {
|
||||
const integrationConfig =
|
||||
type === IntegrationType.AWS_SERVICES
|
||||
? serviceDetailsData?.cloudIntegrationService?.config?.aws
|
||||
: serviceDetailsData?.cloudIntegrationService?.config?.azure;
|
||||
const integrationConfig = getIntegrationServiceConfig(
|
||||
type,
|
||||
serviceDetailsData,
|
||||
);
|
||||
|
||||
return {
|
||||
logsEnabled: integrationConfig?.logs?.enabled || false,
|
||||
@@ -98,16 +115,21 @@ function getServiceConfigPayload({
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
azure: {
|
||||
logs: {
|
||||
enabled: isLogsSupported ? logsEnabled : false,
|
||||
},
|
||||
metrics: {
|
||||
enabled: isMetricsSupported ? metricsEnabled : false,
|
||||
},
|
||||
// Azure and GCP share the same simple logs/metrics enable-flag shape.
|
||||
const signalConfig = {
|
||||
logs: {
|
||||
enabled: isLogsSupported ? logsEnabled : false,
|
||||
},
|
||||
metrics: {
|
||||
enabled: isMetricsSupported ? metricsEnabled : false,
|
||||
},
|
||||
};
|
||||
|
||||
if (type === IntegrationType.GCP_SERVICES) {
|
||||
return { gcp: signalConfig };
|
||||
}
|
||||
|
||||
return { azure: signalConfig };
|
||||
}
|
||||
|
||||
function ServiceDetails({
|
||||
@@ -163,10 +185,10 @@ function ServiceDetails({
|
||||
? isAccountServiceLoading
|
||||
: isReadOnlyServiceLoading;
|
||||
|
||||
const integrationConfig =
|
||||
type === IntegrationType.AWS_SERVICES
|
||||
? serviceDetailsData?.cloudIntegrationService?.config?.aws
|
||||
: serviceDetailsData?.cloudIntegrationService?.config?.azure;
|
||||
const integrationConfig = getIntegrationServiceConfig(
|
||||
type,
|
||||
serviceDetailsData,
|
||||
);
|
||||
const isServiceEnabledInPersistedConfig =
|
||||
Boolean(integrationConfig?.logs?.enabled) ||
|
||||
Boolean(integrationConfig?.metrics?.enabled);
|
||||
|
||||
@@ -36,8 +36,12 @@ function AccountSettingsModal({
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// `account.config` is the shared per-provider union (Azure | AWS | GCP).
|
||||
// Narrow to Azure by `resource_groups` (Azure-only) rather than
|
||||
// `deployment_region`, which GCP also has — so it no longer identifies
|
||||
// Azure uniquely.
|
||||
const azureConfig = useMemo(
|
||||
() => ('deployment_region' in account.config ? account.config : null),
|
||||
() => ('resource_groups' in account.config ? account.config : null),
|
||||
[account.config],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
.setupDrawer {
|
||||
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
|
||||
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
|
||||
|
||||
// Fill every field with --l2-background (Input/ComboboxSimple default to transparent).
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--combobox-trigger-background-color: var(--l2-background);
|
||||
|
||||
// Text uses the brighter --l1-foreground, placeholders the duller --l3-foreground.
|
||||
--input-foreground: var(--l1-foreground);
|
||||
--input-placeholder-color: var(--l3-foreground);
|
||||
--combobox-trigger-placeholder-color: var(--l3-foreground);
|
||||
|
||||
// Shared resting border (--l2-border) and text size; focus borders stay per-component.
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--l2-border);
|
||||
--combobox-trigger-border-color: var(--l2-border);
|
||||
--input-font-size: var(--periscope-font-size-base);
|
||||
--combobox-trigger-font-size: var(--periscope-font-size-base);
|
||||
--combobox-trigger-value-font-size: var(--periscope-font-size-base);
|
||||
|
||||
// Bounded flex column so the header/footer stay put and only the body
|
||||
// scrolls when content overflows.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
[data-slot='drawer-header'],
|
||||
[data-slot='drawer-footer'] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// The drawer body renders inside [data-slot='drawer-description'] — this is
|
||||
// the only region allowed to scroll.
|
||||
[data-slot='drawer-description'] {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-height: 0;
|
||||
padding: var(--spacing-10) var(--spacing-12);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
[data-slot='select-content'] {
|
||||
width: var(--radix-select-trigger-width);
|
||||
}
|
||||
|
||||
[data-slot='combobox-content'] {
|
||||
z-index: 5;
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
// Selected region value: bright, like every other field's text.
|
||||
[data-slot='combobox-value'] {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
// Empty trigger (.regionEmpty, set from the RHF value): dull the placeholder text.
|
||||
.regionEmpty [data-slot='combobox-value'] {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-medium);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
}
|
||||
|
||||
.footerContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.drawerSection {
|
||||
composes: drawerSection from './shared.module.scss';
|
||||
}
|
||||
|
||||
.mono {
|
||||
composes: mono from './shared.module.scss';
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
composes: fieldError from './shared.module.scss';
|
||||
}
|
||||
|
||||
.projectIdsSelect {
|
||||
:global(.ant-select-selector) {
|
||||
min-height: 36px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
}
|
||||
|
||||
&:hover :global(.ant-select-selector),
|
||||
&:global(.ant-select-focused) :global(.ant-select-selector) {
|
||||
border-color: var(--l2-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
// antd defaults to 14px; pin to 13px to line up with the Input/Combobox fields.
|
||||
:global(.ant-select-selection-placeholder),
|
||||
:global(.ant-select-selection-search-input),
|
||||
:global(.ant-select-selection-item) {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-placeholder) {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-search-input) {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-item-remove) {
|
||||
color: var(--l3-foreground);
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { ComboboxSimple } from '@signozhq/ui/combobox';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Select } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { GCP_REGIONS } from 'container/Integrations/constants';
|
||||
import { IntegrationModalProps } from 'container/Integrations/HeroSection/types';
|
||||
import { useCloudAccountSetupDrawer } from 'hooks/integration/gcp/useCloudAccountSetupDrawer';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import ConnectionSecretsFields from './ConnectionSecretsFields';
|
||||
import FieldLabel from './FieldLabel';
|
||||
import FlowSelector from './FlowSelector';
|
||||
import { GcpSetupFormValues, SetupFlow } from './types';
|
||||
|
||||
import styles from './CloudAccountSetupDrawer.module.scss';
|
||||
|
||||
const REGION_ITEMS = GCP_REGIONS.map((region) => ({
|
||||
value: region.value,
|
||||
label: `${region.label} (${region.value})`,
|
||||
}));
|
||||
|
||||
const DEFAULT_VALUES: GcpSetupFormValues = {
|
||||
accountName: '',
|
||||
deploymentProjectId: '',
|
||||
deploymentRegion: '',
|
||||
projectIds: [],
|
||||
sigNozApiUrl: '',
|
||||
sigNozApiKey: '',
|
||||
ingestionUrl: '',
|
||||
ingestionKey: '',
|
||||
};
|
||||
|
||||
function CloudAccountSetupDrawer({
|
||||
onClose,
|
||||
}: IntegrationModalProps): JSX.Element {
|
||||
const {
|
||||
isLoading,
|
||||
connectAccount,
|
||||
handleClose,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
} = useCloudAccountSetupDrawer({ onClose });
|
||||
|
||||
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
});
|
||||
|
||||
const [flow, setFlow] = useState<SetupFlow>('manual');
|
||||
|
||||
// Pre-fill the deployment/ingestion fields with the fetched credentials.
|
||||
useEffect(() => {
|
||||
if (!connectionParams) {
|
||||
return;
|
||||
}
|
||||
setValue('sigNozApiUrl', connectionParams.sigNozApiUrl);
|
||||
setValue('sigNozApiKey', connectionParams.sigNozApiKey);
|
||||
setValue('ingestionUrl', connectionParams.ingestionUrl);
|
||||
setValue('ingestionKey', connectionParams.ingestionKey);
|
||||
}, [connectionParams, setValue]);
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footerContainer}>
|
||||
{submitError && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
action="dismissible"
|
||||
onClick={clearSubmitError}
|
||||
title="Failed to connect GCP account"
|
||||
testId="gcp-connect-error"
|
||||
>
|
||||
{submitError}
|
||||
</Callout>
|
||||
)}
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleClose}
|
||||
testId="gcp-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit(connectAccount)}
|
||||
loading={isLoading}
|
||||
disabled={isConnectionParamsLoading}
|
||||
testId="gcp-connect-account-btn"
|
||||
>
|
||||
Connect Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={true}
|
||||
className={styles.setupDrawer}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
showCloseButton
|
||||
title="Connect Google Cloud Platform"
|
||||
width="base"
|
||||
footer={footer}
|
||||
drawerHeaderProps={{ className: styles.title }}
|
||||
>
|
||||
<FlowSelector value={flow} onChange={setFlow} />
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-account-name-input"
|
||||
label="Account Name"
|
||||
tooltip="A label to identify this group of GCP projects (org ID, billing email, or any descriptive name)"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="accountName"
|
||||
control={control}
|
||||
rules={{ required: 'Please enter an account name' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
id="gcp-account-name-input"
|
||||
className={styles.fullWidth}
|
||||
placeholder="e.g. my-org or billing@company.com"
|
||||
value={field.value}
|
||||
onChange={(e): void => field.onChange(e.target.value)}
|
||||
testId="gcp-account-name-input"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-deployment-project-id-input"
|
||||
label="Deployment Project ID"
|
||||
tooltip="The GCP project that hosts your OTel Collector deployment — often separate from the projects you actually monitor"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="deploymentProjectId"
|
||||
control={control}
|
||||
rules={{ required: 'Please enter the deployment project ID' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
id="gcp-deployment-project-id-input"
|
||||
className={cx(styles.fullWidth, styles.mono)}
|
||||
placeholder="e.g. my-deployment-project-123"
|
||||
value={field.value}
|
||||
onChange={(e): void => field.onChange(e.target.value)}
|
||||
testId="gcp-deployment-project-id-input"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-deployment-region-select"
|
||||
label="Deployment Region"
|
||||
tooltip="The GCP region where your OTel Collector will be deployed"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="deploymentRegion"
|
||||
control={control}
|
||||
rules={{ required: 'Please select a region' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<ComboboxSimple
|
||||
id="gcp-deployment-region-select"
|
||||
className={cx(styles.fullWidth, {
|
||||
[styles.regionEmpty]: !field.value,
|
||||
})}
|
||||
items={REGION_ITEMS}
|
||||
value={field.value}
|
||||
onChange={(value): void => field.onChange(value as string)}
|
||||
placeholder="Select a region..."
|
||||
inputPlaceholder="Search regions…"
|
||||
withPortal={false}
|
||||
testId="gcp-deployment-region-select"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-project-ids-select"
|
||||
label="Projects to Monitor"
|
||||
tooltip="Enter each GCP project ID then press Enter"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="projectIds"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): true | string =>
|
||||
value.length > 0 || 'Please add at least one project ID',
|
||||
}}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Select
|
||||
id="gcp-project-ids-select"
|
||||
className={cx(styles.fullWidth, styles.projectIdsSelect)}
|
||||
mode="tags"
|
||||
value={field.value}
|
||||
onChange={(value): void => field.onChange(value)}
|
||||
placeholder="Add project IDs…"
|
||||
tokenSeparators={[',', ' ']}
|
||||
notFoundContent={null}
|
||||
suffixIcon={null}
|
||||
getPopupContainer={popupContainer}
|
||||
data-testid="gcp-project-ids-select"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<ConnectionSecretsFields
|
||||
control={control}
|
||||
isLoading={isConnectionParamsLoading}
|
||||
connectionParams={connectionParams}
|
||||
/>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default CloudAccountSetupDrawer;
|
||||
@@ -0,0 +1,71 @@
|
||||
.drawerSurface {
|
||||
composes: drawerSurface from './shared.module.scss';
|
||||
}
|
||||
|
||||
.drawerSurfaceHead {
|
||||
composes: drawerSurfaceHead from './shared.module.scss';
|
||||
}
|
||||
|
||||
.drawerSection {
|
||||
composes: drawerSection from './shared.module.scss';
|
||||
}
|
||||
|
||||
.headLabel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.mono {
|
||||
composes: mono from './shared.module.scss';
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
composes: fieldError from './shared.module.scss';
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.secretsBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.skeletonLabel :global(.ant-skeleton-input) {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.skeletonInput :global(.ant-skeleton-input) {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.readonlyField {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 var(--spacing-2) 0 var(--spacing-4);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.readonlyValue {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
// Match the other fields' value text: 13px and the brighter --l1-foreground.
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l2-foreground);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Lock } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Skeleton } from 'antd';
|
||||
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { Control, Controller } from 'react-hook-form';
|
||||
|
||||
import FieldLabel from './FieldLabel';
|
||||
import { GcpSetupFormValues } from './types';
|
||||
import { SecretFieldType, validateSecretValue } from './validators';
|
||||
import styles from './ConnectionSecretsFields.module.scss';
|
||||
|
||||
type CredentialField = keyof CloudintegrationtypesCredentialsDTO;
|
||||
|
||||
interface FieldConfig {
|
||||
name: CredentialField;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
placeholder: string;
|
||||
testId: string;
|
||||
type: SecretFieldType;
|
||||
}
|
||||
|
||||
const FIELDS: FieldConfig[] = [
|
||||
{
|
||||
name: 'sigNozApiUrl',
|
||||
label: 'SigNoz API URL',
|
||||
tooltip: 'Base URL of your SigNoz instance the collector reports to',
|
||||
placeholder: 'https://<tenant>.signoz.cloud',
|
||||
testId: 'gcp-signoz-api-url-input',
|
||||
type: 'url',
|
||||
},
|
||||
{
|
||||
name: 'sigNozApiKey',
|
||||
label: 'SigNoz API Key',
|
||||
tooltip: 'API key used to authenticate with your SigNoz instance',
|
||||
placeholder: 'Enter SigNoz API key',
|
||||
testId: 'gcp-signoz-api-key-input',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'ingestionUrl',
|
||||
label: 'Ingestion URL',
|
||||
tooltip: 'OTLP ingestion endpoint your OTel Collector sends telemetry to',
|
||||
placeholder: 'https://ingest.<region>.signoz.cloud',
|
||||
testId: 'gcp-ingestion-url-input',
|
||||
type: 'url',
|
||||
},
|
||||
{
|
||||
name: 'ingestionKey',
|
||||
label: 'Ingestion Key',
|
||||
tooltip: 'Ingestion key that authorizes telemetry sent to SigNoz',
|
||||
placeholder: 'Enter ingestion key',
|
||||
testId: 'gcp-ingestion-key-input',
|
||||
type: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
interface ConnectionSecretsFieldsProps {
|
||||
control: Control<GcpSetupFormValues>;
|
||||
isLoading: boolean;
|
||||
connectionParams?: CloudintegrationtypesCredentialsDTO;
|
||||
}
|
||||
|
||||
function ConnectionSecretsFields({
|
||||
control,
|
||||
isLoading,
|
||||
connectionParams,
|
||||
}: ConnectionSecretsFieldsProps): JSX.Element {
|
||||
const hasMissingValue = FIELDS.some(
|
||||
(field) => !connectionParams?.[field.name],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.drawerSurface}>
|
||||
<div className={styles.drawerSurfaceHead}>
|
||||
<Typography.Text weight="bold" size="base">
|
||||
Deployment details & ingestion secrets
|
||||
</Typography.Text>
|
||||
{!hasMissingValue && (
|
||||
<div className={styles.headLabel}>
|
||||
<Lock size={12} />
|
||||
<Typography.Text as="span" size="small" className={styles.headLabel}>
|
||||
Auto-filled by SigNoz
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className={styles.secretsBody} data-testid="gcp-secrets-skeleton">
|
||||
{FIELDS.map((field) => (
|
||||
<div key={field.name} className={styles.drawerSection}>
|
||||
<Skeleton.Input active size="small" className={styles.skeletonLabel} />
|
||||
<Skeleton.Input active block className={styles.skeletonInput} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.secretsBody}>
|
||||
{FIELDS.map((field) => {
|
||||
// Backend-provided values are read-only — the user can't edit them, so
|
||||
// show a truncated value with a copy button. Missing values (enterprise)
|
||||
// stay editable inputs with no copy button.
|
||||
const providedValue = connectionParams?.[field.name];
|
||||
if (providedValue) {
|
||||
return (
|
||||
<div key={field.name} className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor={field.testId}
|
||||
label={field.label}
|
||||
tooltip={field.tooltip}
|
||||
/>
|
||||
<div className={styles.readonlyField}>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
id={field.testId}
|
||||
className={cx(styles.readonlyValue, styles.mono)}
|
||||
title={providedValue}
|
||||
testId={field.testId}
|
||||
>
|
||||
{providedValue}
|
||||
</Typography.Text>
|
||||
<CopyButton
|
||||
value={providedValue}
|
||||
size={12}
|
||||
ariaLabel={`Copy ${field.label}`}
|
||||
testId={`${field.testId}-copy`}
|
||||
onCopy={(): void => {
|
||||
toast.success(`${field.label} copied to clipboard`, {
|
||||
position: 'bottom-right',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.name} className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor={field.testId}
|
||||
label={field.label}
|
||||
tooltip={field.tooltip}
|
||||
/>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): true | string =>
|
||||
validateSecretValue(field.label, field.type, value),
|
||||
}}
|
||||
render={({ field: rhfField, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
id={field.testId}
|
||||
className={cx(styles.fullWidth, styles.mono)}
|
||||
placeholder={field.placeholder}
|
||||
value={rhfField.value}
|
||||
onChange={(e): void => rhfField.onChange(e.target.value)}
|
||||
testId={field.testId}
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ConnectionSecretsFields.defaultProps = {
|
||||
connectionParams: undefined,
|
||||
};
|
||||
|
||||
export default ConnectionSecretsFields;
|
||||
@@ -0,0 +1,22 @@
|
||||
.fieldLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.required {
|
||||
composes: required from './shared.module.scss';
|
||||
}
|
||||
|
||||
.infoTrigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--l3-foreground);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
max-width: 240px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './FieldLabel.module.scss';
|
||||
|
||||
interface FieldLabelProps {
|
||||
htmlFor: string;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
htmlFor,
|
||||
label,
|
||||
tooltip,
|
||||
required,
|
||||
}: FieldLabelProps): JSX.Element {
|
||||
return (
|
||||
<label className={styles.fieldLabel} htmlFor={htmlFor}>
|
||||
{label}
|
||||
|
||||
<TooltipSimple
|
||||
title={tooltip}
|
||||
side="top"
|
||||
tooltipContentProps={{ className: styles.tooltipContent }}
|
||||
>
|
||||
<span
|
||||
className={styles.infoTrigger}
|
||||
aria-label={`${label} help`}
|
||||
data-testid={`${htmlFor}-tooltip`}
|
||||
>
|
||||
<Info size={12} />
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
{required && (
|
||||
<span className={styles.required} aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
FieldLabel.defaultProps = {
|
||||
required: false,
|
||||
};
|
||||
|
||||
export default FieldLabel;
|
||||
@@ -0,0 +1,84 @@
|
||||
.drawerSection {
|
||||
composes: drawerSection from './shared.module.scss';
|
||||
}
|
||||
|
||||
.drawerSurface {
|
||||
composes: drawerSurface from './shared.module.scss';
|
||||
}
|
||||
|
||||
.drawerSurfaceHead {
|
||||
composes: drawerSurfaceHead from './shared.module.scss';
|
||||
}
|
||||
|
||||
.flowRadioGroup {
|
||||
--radio-group-item-border-color: var(--l2-border);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
|
||||
.flowRadio {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing-5);
|
||||
width: 100%;
|
||||
padding: var(--spacing-5) var(--spacing-6);
|
||||
margin: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-2);
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.12s ease,
|
||||
border-color 0.12s ease;
|
||||
|
||||
> button[role='radio'] {
|
||||
flex: 0 0 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
> label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&.flowRadioManual:has(button[data-state='checked']) {
|
||||
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background-hover);
|
||||
}
|
||||
|
||||
&:has(button[disabled]) {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flowRadioTitle {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flowRadioDesc {
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { SetupFlow } from './types';
|
||||
import styles from './FlowSelector.module.scss';
|
||||
|
||||
interface FlowSelectorProps {
|
||||
value: SetupFlow;
|
||||
onChange: (flow: SetupFlow) => void;
|
||||
}
|
||||
|
||||
function FlowSelector({ value, onChange }: FlowSelectorProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
|
||||
<div className={styles.drawerSurfaceHead}>
|
||||
<Typography.Text weight="bold" size="base">
|
||||
Connection method
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<RadioGroup
|
||||
value={value}
|
||||
onChange={(next): void => onChange(next as SetupFlow)}
|
||||
className={styles.flowRadioGroup}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="manual"
|
||||
containerClassName={cx(styles.flowRadio, styles.flowRadioManual)}
|
||||
testId="gcp-flow-manual"
|
||||
>
|
||||
<div className={styles.flowRadioTitle}>
|
||||
<Typography.Text weight="semibold" size="base">
|
||||
Connect Manually
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text
|
||||
as="p"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.flowRadioDesc}
|
||||
>
|
||||
Deploy your own OTel Collector and configure log sinks.
|
||||
</Typography.Text>
|
||||
</RadioGroupItem>
|
||||
|
||||
<RadioGroupItem
|
||||
value="agent"
|
||||
containerClassName={styles.flowRadio}
|
||||
testId="gcp-flow-agent"
|
||||
disabled
|
||||
>
|
||||
<div className={styles.flowRadioTitle}>
|
||||
<Typography.Text weight="semibold" size="base">
|
||||
Connect via Agent
|
||||
</Typography.Text>
|
||||
<Badge color="robin" variant="default">
|
||||
Soon
|
||||
</Badge>
|
||||
</div>
|
||||
<Typography.Text
|
||||
as="p"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.flowRadioDesc}
|
||||
>
|
||||
SigNoz deploys and manages the collector for you.
|
||||
</Typography.Text>
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowSelector;
|
||||
@@ -0,0 +1,36 @@
|
||||
.drawerSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.drawerSection > label {
|
||||
font-size: var(--periscope-font-size-normal);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--accent-cherry);
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
font-size: var(--periscope-font-size-small);
|
||||
color: var(--accent-cherry);
|
||||
}
|
||||
|
||||
.drawerSurface {
|
||||
padding: var(--spacing-7);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.drawerSurfaceHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-5);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export type SetupFlow = 'manual' | 'agent';
|
||||
|
||||
export interface GcpSetupFormValues {
|
||||
accountName: string;
|
||||
deploymentProjectId: string;
|
||||
deploymentRegion: string;
|
||||
projectIds: string[];
|
||||
sigNozApiUrl: string;
|
||||
sigNozApiKey: string;
|
||||
ingestionUrl: string;
|
||||
ingestionKey: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type SecretFieldType = 'url' | 'text';
|
||||
|
||||
export function isValidUrl(value: string): boolean {
|
||||
try {
|
||||
return Boolean(new URL(value));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSecretValue(
|
||||
label: string,
|
||||
type: SecretFieldType,
|
||||
value: string | undefined,
|
||||
): true | string {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return `Please enter the ${label}`;
|
||||
}
|
||||
if (type === 'url' && !isValidUrl(trimmed)) {
|
||||
return `Please enter a valid URL for ${label}`;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Dispatch, SetStateAction, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Save } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Form, Select } from 'antd';
|
||||
import { invalidateListAccounts } from 'api/generated/services/cloudintegration';
|
||||
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
|
||||
import { CloudAccount } from 'container/Integrations/types';
|
||||
import { useAccountSettingsDrawer } from 'hooks/integration/gcp/useAccountSettingsDrawer';
|
||||
|
||||
import RemoveIntegrationAccount from '../../RemoveAccount/RemoveIntegrationAccount';
|
||||
|
||||
import '../../AmazonWebServices/EditAccount/AccountSettingsModal.style.scss';
|
||||
|
||||
interface AccountSettingsDrawerProps {
|
||||
onClose: () => void;
|
||||
account: CloudAccount;
|
||||
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
|
||||
}
|
||||
|
||||
function AccountSettingsDrawer({
|
||||
onClose,
|
||||
account,
|
||||
setActiveAccount,
|
||||
}: AccountSettingsDrawerProps): JSX.Element {
|
||||
const {
|
||||
form,
|
||||
isLoading,
|
||||
projectIds,
|
||||
isSaveDisabled,
|
||||
setProjectIds,
|
||||
handleSubmit,
|
||||
handleClose,
|
||||
} = useAccountSettingsDrawer({ onClose, account, setActiveAccount });
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const gcpConfig = useMemo(
|
||||
() => ('project_ids' in account.config ? account.config : null),
|
||||
[account.config],
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={true}
|
||||
className="account-settings-modal"
|
||||
title="Account Settings"
|
||||
direction="right"
|
||||
showCloseButton
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
width="wide"
|
||||
footer={
|
||||
<div className="account-settings-modal__footer">
|
||||
<RemoveIntegrationAccount
|
||||
accountId={account?.id}
|
||||
onRemoveIntegrationAccountSuccess={(): void => {
|
||||
void invalidateListAccounts(queryClient, {
|
||||
cloudProvider: INTEGRATION_TYPES.GCP,
|
||||
});
|
||||
setActiveAccount(null);
|
||||
handleClose();
|
||||
}}
|
||||
cloudProvider={INTEGRATION_TYPES.GCP}
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
disabled={isSaveDisabled}
|
||||
onClick={handleSubmit}
|
||||
loading={isLoading}
|
||||
prefix={<Save size={14} />}
|
||||
data-testid="gcp-update-account-btn"
|
||||
>
|
||||
Update Changes
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
projectIds: gcpConfig?.project_ids || [],
|
||||
}}
|
||||
>
|
||||
<div className="account-settings-modal__body">
|
||||
<div className="account-settings-modal__body-account-info">
|
||||
<div className="account-settings-modal__body-account-info-connected-account-details">
|
||||
<div className="account-settings-modal__body-account-info-connected-account-details-title">
|
||||
Connected Account details
|
||||
</div>
|
||||
<div className="account-settings-modal__body-account-info-connected-account-details-account-id">
|
||||
Account Name:{' '}
|
||||
<span className="account-settings-modal__body-account-info-connected-account-details-account-id-account-id">
|
||||
{account?.providerAccountId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gcpConfig?.deployment_project_id && (
|
||||
<div className="account-settings-modal__body-region-selector">
|
||||
<div className="account-settings-modal__body-region-selector-title">
|
||||
Deployment project ID
|
||||
</div>
|
||||
<div className="account-settings-modal__body-region-selector-description">
|
||||
{gcpConfig.deployment_project_id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gcpConfig?.deployment_region && (
|
||||
<div className="account-settings-modal__body-region-selector">
|
||||
<div className="account-settings-modal__body-region-selector-title">
|
||||
Deployment region
|
||||
</div>
|
||||
<div className="account-settings-modal__body-region-selector-description">
|
||||
{gcpConfig.deployment_region}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="account-settings-modal__body-region-selector">
|
||||
<div className="account-settings-modal__body-region-selector-title">
|
||||
Projects to monitor
|
||||
</div>
|
||||
<div className="account-settings-modal__body-region-selector-description">
|
||||
Update the GCP project IDs that should be monitored.
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="projectIds"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
type: 'array',
|
||||
min: 1,
|
||||
message: 'Please add at least one project ID',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
value={projectIds}
|
||||
tokenSeparators={[',']}
|
||||
onChange={(values): void => {
|
||||
setProjectIds(values);
|
||||
form.setFieldValue('projectIds', values);
|
||||
}}
|
||||
data-testid="gcp-edit-project-ids-select"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountSettingsDrawer;
|
||||
@@ -60,6 +60,44 @@ function RemoveIntegrationAccount({
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
let modalDescription: JSX.Element;
|
||||
if (cloudProvider === INTEGRATION_TYPES.AWS) {
|
||||
modalDescription = (
|
||||
<>
|
||||
Removing this account will remove all components created for sending
|
||||
telemetry to SigNoz in your AWS account within the next ~15 minutes
|
||||
(cloudformation stacks named signoz-integration-telemetry-collection in
|
||||
enabled regions). <br />
|
||||
<br />
|
||||
After that, you can delete the cloudformation stack that was created
|
||||
manually when connecting this account.
|
||||
</>
|
||||
);
|
||||
} else if (cloudProvider === INTEGRATION_TYPES.GCP) {
|
||||
modalDescription = (
|
||||
<>
|
||||
Removing this account will stop SigNoz from monitoring it. <br />
|
||||
<br />
|
||||
Since you manage the GCP resources yourself, remember to manually tear down
|
||||
the OTel collector and Pub/Sub resources you created for this integration if
|
||||
you no longer need them.
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
modalDescription = (
|
||||
<>
|
||||
Removing this account will remove all components created for sending
|
||||
telemetry to SigNoz in your Azure subscription within the next ~15 minutes
|
||||
(deployment stack named signoz-integration-telemetry will be deleted
|
||||
automatically). <br />
|
||||
<br />
|
||||
After that, you have to manually delete 'signoz-integration'
|
||||
deployment stack that was created while connecting this account (Takes ~20
|
||||
minutes to delete).
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="remove-integration-account-container">
|
||||
<Button
|
||||
@@ -84,28 +122,7 @@ function RemoveIntegrationAccount({
|
||||
loading: isRemoveIntegrationLoading,
|
||||
}}
|
||||
>
|
||||
{cloudProvider === INTEGRATION_TYPES.AWS ? (
|
||||
<>
|
||||
Removing this account will remove all components created for sending
|
||||
telemetry to SigNoz in your AWS account within the next ~15 minutes
|
||||
(cloudformation stacks named signoz-integration-telemetry-collection in
|
||||
enabled regions). <br />
|
||||
<br />
|
||||
After that, you can delete the cloudformation stack that was created
|
||||
manually when connecting this account.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Removing this account will remove all components created for sending
|
||||
telemetry to SigNoz in your Azure subscription within the next ~15 minutes
|
||||
(deployment stack named signoz-integration-telemetry will be deleted
|
||||
automatically). <br />
|
||||
<br />
|
||||
After that, you have to manually delete 'signoz-integration'
|
||||
deployment stack that was created while connecting this account (Takes ~20
|
||||
minutes to delete).
|
||||
</>
|
||||
)}
|
||||
{modalDescription}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -47,3 +47,27 @@ export function mapAccountDtoToAzureCloudAccount(
|
||||
providerAccountId: account.providerAccountId,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAccountDtoToGcpCloudAccount(
|
||||
account: CloudintegrationtypesAccountDTO,
|
||||
): IntegrationCloudAccount | null {
|
||||
if (!account.providerAccountId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
cloud_account_id: account.id,
|
||||
config: {
|
||||
deployment_region: account.config?.gcp?.deploymentRegion ?? '',
|
||||
deployment_project_id: account.config?.gcp?.deploymentProjectId ?? '',
|
||||
project_ids: account.config?.gcp?.projectIds ?? [],
|
||||
},
|
||||
status: {
|
||||
integration: {
|
||||
last_heartbeat_ts_ms: account.agentReport?.timestampMillis ?? 0,
|
||||
},
|
||||
},
|
||||
providerAccountId: account.providerAccountId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import { ArrowLeft, MoveUpRight, RotateCw } from '@signozhq/icons';
|
||||
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
|
||||
|
||||
import CloudIntegration from '../CloudIntegration/CloudIntegration';
|
||||
import { INTEGRATION_TYPES } from '../constants';
|
||||
import { IntegrationType } from '../types';
|
||||
import { INTEGRATION_TYPES } from '../constants';
|
||||
import { handleContactSupport } from '../utils';
|
||||
import IntegrationDetailContent from './IntegrationDetailContent';
|
||||
import IntegrationDetailHeader from './IntegrationDetailHeader';
|
||||
@@ -24,6 +24,12 @@ import { getConnectionStatesFromConnectionStatus } from './utils';
|
||||
|
||||
import './IntegrationDetailPage.styles.scss';
|
||||
|
||||
const cloudIntegrationTypeById: Record<string, IntegrationType> = {
|
||||
[INTEGRATION_TYPES.AWS]: IntegrationType.AWS_SERVICES,
|
||||
[INTEGRATION_TYPES.AZURE]: IntegrationType.AZURE_SERVICES,
|
||||
[INTEGRATION_TYPES.GCP]: IntegrationType.GCP_SERVICES,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function IntegrationDetailPage(): JSX.Element {
|
||||
const history = useHistory();
|
||||
@@ -55,19 +61,8 @@ function IntegrationDetailPage(): JSX.Element {
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
integrationId === INTEGRATION_TYPES.AWS ||
|
||||
integrationId === INTEGRATION_TYPES.AZURE
|
||||
) {
|
||||
return (
|
||||
<CloudIntegration
|
||||
type={
|
||||
integrationId === INTEGRATION_TYPES.AWS
|
||||
? IntegrationType.AWS_SERVICES
|
||||
: IntegrationType.AZURE_SERVICES
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (integrationId && cloudIntegrationTypeById[integrationId]) {
|
||||
return <CloudIntegration type={cloudIntegrationTypeById[integrationId]} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import awsDarkLogo from '@/assets/Logos/aws-dark.svg';
|
||||
import azureOpenaiLogo from '@/assets/Logos/azure-openai.svg';
|
||||
import gcpLogo from '@/assets/Logos/gcp.svg';
|
||||
|
||||
import { AzureRegion } from './types';
|
||||
import { AzureRegion, GCPRegion } from './types';
|
||||
|
||||
export const INTEGRATION_TELEMETRY_EVENTS = {
|
||||
INTEGRATIONS_LIST_VISITED: 'Integrations Page: Visited the list page',
|
||||
@@ -21,6 +22,7 @@ export const INTEGRATION_TELEMETRY_EVENTS = {
|
||||
export const INTEGRATION_TYPES = {
|
||||
AWS: 'aws',
|
||||
AZURE: 'azure',
|
||||
GCP: 'gcp',
|
||||
};
|
||||
|
||||
export const AWS_INTEGRATION = {
|
||||
@@ -53,7 +55,26 @@ export const AZURE_INTEGRATION = {
|
||||
is_new: true,
|
||||
};
|
||||
|
||||
export const ONE_CLICK_INTEGRATIONS = [AWS_INTEGRATION, AZURE_INTEGRATION];
|
||||
export const GCP_INTEGRATION = {
|
||||
id: INTEGRATION_TYPES.GCP,
|
||||
title: 'Google Cloud Platform',
|
||||
description: 'Setup for GCP monitoring with SigNoz',
|
||||
author: {
|
||||
name: 'SigNoz',
|
||||
email: 'integrations@signoz.io',
|
||||
homepage: 'https://signoz.io',
|
||||
},
|
||||
icon: gcpLogo,
|
||||
icon_alt: 'gcp-logo',
|
||||
is_installed: false,
|
||||
is_new: true,
|
||||
};
|
||||
|
||||
export const ONE_CLICK_INTEGRATIONS = [
|
||||
AWS_INTEGRATION,
|
||||
AZURE_INTEGRATION,
|
||||
GCP_INTEGRATION,
|
||||
];
|
||||
|
||||
export const AZURE_REGIONS: AzureRegion[] = [
|
||||
{
|
||||
@@ -165,3 +186,66 @@ export const AZURE_REGIONS: AzureRegion[] = [
|
||||
{ label: 'West US 2', value: 'westus2', geography: 'United States' },
|
||||
{ label: 'West US 3', value: 'westus3', geography: 'United States' },
|
||||
];
|
||||
|
||||
// Source of truth: pkg/types/cloudintegrationtypes/regions.go (GCP regions).
|
||||
export const GCP_REGIONS: GCPRegion[] = [
|
||||
{ label: 'Johannesburg', value: 'africa-south1', geography: 'Africa' },
|
||||
{ label: 'Changhua County', value: 'asia-east1', geography: 'APAC' },
|
||||
{ label: 'Hong Kong', value: 'asia-east2', geography: 'APAC' },
|
||||
{ label: 'Tokyo', value: 'asia-northeast1', geography: 'APAC' },
|
||||
{ label: 'Osaka', value: 'asia-northeast2', geography: 'APAC' },
|
||||
{ label: 'Seoul', value: 'asia-northeast3', geography: 'APAC' },
|
||||
{ label: 'Mumbai', value: 'asia-south1', geography: 'APAC' },
|
||||
{ label: 'Delhi', value: 'asia-south2', geography: 'APAC' },
|
||||
{ label: 'Singapore', value: 'asia-southeast1', geography: 'APAC' },
|
||||
{ label: 'Jakarta', value: 'asia-southeast2', geography: 'APAC' },
|
||||
{ label: 'Bangkok', value: 'asia-southeast3', geography: 'APAC' },
|
||||
{ label: 'Sydney', value: 'australia-southeast1', geography: 'APAC' },
|
||||
{ label: 'Melbourne', value: 'australia-southeast2', geography: 'APAC' },
|
||||
{ label: 'Warsaw', value: 'europe-central2', geography: 'Europe' },
|
||||
{ label: 'Hamina', value: 'europe-north1', geography: 'Europe' },
|
||||
{ label: 'Stockholm', value: 'europe-north2', geography: 'Europe' },
|
||||
{ label: 'Madrid', value: 'europe-southwest1', geography: 'Europe' },
|
||||
{ label: 'St. Ghislain', value: 'europe-west1', geography: 'Europe' },
|
||||
{ label: 'London', value: 'europe-west2', geography: 'Europe' },
|
||||
{ label: 'Frankfurt', value: 'europe-west3', geography: 'Europe' },
|
||||
{ label: 'Eemshaven', value: 'europe-west4', geography: 'Europe' },
|
||||
{ label: 'Zurich', value: 'europe-west6', geography: 'Europe' },
|
||||
{ label: 'Milan', value: 'europe-west8', geography: 'Europe' },
|
||||
{ label: 'Paris', value: 'europe-west9', geography: 'Europe' },
|
||||
{ label: 'Berlin', value: 'europe-west10', geography: 'Europe' },
|
||||
{ label: 'Turin', value: 'europe-west12', geography: 'Europe' },
|
||||
{ label: 'Doha', value: 'me-central1', geography: 'Middle East' },
|
||||
{ label: 'Dammam', value: 'me-central2', geography: 'Middle East' },
|
||||
{ label: 'Tel Aviv', value: 'me-west1', geography: 'Middle East' },
|
||||
{
|
||||
label: 'Montréal',
|
||||
value: 'northamerica-northeast1',
|
||||
geography: 'North America',
|
||||
},
|
||||
{
|
||||
label: 'Toronto',
|
||||
value: 'northamerica-northeast2',
|
||||
geography: 'North America',
|
||||
},
|
||||
{
|
||||
label: 'Querétaro',
|
||||
value: 'northamerica-south1',
|
||||
geography: 'North America',
|
||||
},
|
||||
{
|
||||
label: 'São Paulo',
|
||||
value: 'southamerica-east1',
|
||||
geography: 'South America',
|
||||
},
|
||||
{ label: 'Santiago', value: 'southamerica-west1', geography: 'South America' },
|
||||
{ label: 'Council Bluffs', value: 'us-central1', geography: 'North America' },
|
||||
{ label: 'Moncks Corner', value: 'us-east1', geography: 'North America' },
|
||||
{ label: 'Ashburn', value: 'us-east4', geography: 'North America' },
|
||||
{ label: 'Columbus', value: 'us-east5', geography: 'North America' },
|
||||
{ label: 'Dallas', value: 'us-south1', geography: 'North America' },
|
||||
{ label: 'The Dalles', value: 'us-west1', geography: 'North America' },
|
||||
{ label: 'Los Angeles', value: 'us-west2', geography: 'North America' },
|
||||
{ label: 'Salt Lake City', value: 'us-west3', geography: 'North America' },
|
||||
{ label: 'Las Vegas', value: 'us-west4', geography: 'North America' },
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
export enum IntegrationType {
|
||||
AWS_SERVICES = 'aws',
|
||||
AZURE_SERVICES = 'azure',
|
||||
GCP_SERVICES = 'gcp',
|
||||
}
|
||||
|
||||
interface LogField {
|
||||
@@ -87,7 +88,10 @@ export interface ServiceData {
|
||||
export interface CloudAccount {
|
||||
id: string;
|
||||
cloud_account_id: string;
|
||||
config: AzureCloudAccountConfig | AWSCloudAccountConfig;
|
||||
config:
|
||||
| AzureCloudAccountConfig
|
||||
| AWSCloudAccountConfig
|
||||
| GCPCloudAccountConfig;
|
||||
status: AccountStatus | IServiceStatus;
|
||||
providerAccountId: string;
|
||||
}
|
||||
@@ -97,6 +101,12 @@ export interface AzureCloudAccountConfig {
|
||||
resource_groups: string[];
|
||||
}
|
||||
|
||||
export interface GCPCloudAccountConfig {
|
||||
deployment_region: string;
|
||||
deployment_project_id: string;
|
||||
project_ids: string[];
|
||||
}
|
||||
|
||||
export interface AccountStatus {
|
||||
integration: IntegrationStatus;
|
||||
}
|
||||
@@ -111,6 +121,12 @@ export interface AzureRegion {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface GCPRegion {
|
||||
label: string;
|
||||
geography: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface UpdateServiceConfigPayload {
|
||||
cloud_account_id: string;
|
||||
config: AzureServicesConfig;
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
import { DraftGroup } from '../types';
|
||||
import { AttributeMappingEditor } from '../hooks/useAttributeMappingEditor';
|
||||
import styles from './AttributeMappingsTab.module.scss';
|
||||
import MappingsTable from './components/MappingsTable/MappingsTable';
|
||||
import { useAttributeMappingStore } from './hooks/useAttributeMappingStore';
|
||||
|
||||
function AttributeMappingsTab(): JSX.Element {
|
||||
const store = useAttributeMappingStore();
|
||||
interface AttributeMappingsTabProps {
|
||||
editor: AttributeMappingEditor;
|
||||
onEditGroup: (group: DraftGroup) => void;
|
||||
onAddGroup: () => void;
|
||||
}
|
||||
|
||||
// "Attribute mappings" tab: the mapping-groups listing and its error state.
|
||||
// The editor is owned by the container (the header's save/discard share it),
|
||||
// so it's passed in rather than created here.
|
||||
function AttributeMappingsTab({
|
||||
editor,
|
||||
onEditGroup,
|
||||
onAddGroup,
|
||||
}: AttributeMappingsTabProps): JSX.Element {
|
||||
return (
|
||||
<div data-testid="attribute-mappings-tab">
|
||||
{store.isError ? (
|
||||
{editor.isError ? (
|
||||
<div className={styles.pageError} role="alert">
|
||||
Failed to load mapping groups. Please try again.
|
||||
</div>
|
||||
) : (
|
||||
<MappingsTable store={store} />
|
||||
<MappingsTable
|
||||
editor={editor}
|
||||
onEditGroup={onEditGroup}
|
||||
onAddGroup={onAddGroup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,19 @@ import {
|
||||
SpantypesFieldContextDTO as FieldContext,
|
||||
SpantypesSpanMapperOperationDTO as MapperOperation,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
GROUPS_ENDPOINT,
|
||||
makeGroupsResponse,
|
||||
@@ -14,7 +24,9 @@ import {
|
||||
mockGroups,
|
||||
mockMappers,
|
||||
} from 'container/LLMObservability/AttributeMapping/__tests__/fixtures';
|
||||
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import AttributeMappingsTab from '../AttributeMappingsTab';
|
||||
import { useAttributeMappingEditor } from '../../hooks/useAttributeMappingEditor';
|
||||
|
||||
function setupGroups(groups = mockGroups): void {
|
||||
server.use(
|
||||
@@ -39,6 +51,53 @@ async function expandGroup(
|
||||
await user.click(screen.getByTestId(`group-expand-${groupId}`));
|
||||
}
|
||||
|
||||
async function openGroupActionsMenu(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
groupId: string,
|
||||
): Promise<void> {
|
||||
await user.click(screen.getByTestId(`group-actions-${groupId}`));
|
||||
}
|
||||
|
||||
interface AttributeMappingsTabWithStoreProps {
|
||||
onEditGroup?: (group: DraftGroup) => void;
|
||||
onAddGroup?: () => void;
|
||||
}
|
||||
function AttributeMappingsTabWithStore({
|
||||
onEditGroup,
|
||||
onAddGroup,
|
||||
}: AttributeMappingsTabWithStoreProps): JSX.Element {
|
||||
const editor = useAttributeMappingEditor();
|
||||
return (
|
||||
<AttributeMappingsTab
|
||||
editor={editor}
|
||||
onEditGroup={onEditGroup ?? jest.fn()}
|
||||
onAddGroup={onAddGroup ?? jest.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveableHarness(): JSX.Element {
|
||||
const editor = useAttributeMappingEditor();
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="save-button"
|
||||
onClick={(): void => {
|
||||
void editor.save();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<AttributeMappingsTab
|
||||
editor={editor}
|
||||
onEditGroup={jest.fn()}
|
||||
onAddGroup={jest.fn()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AttributeMappingsTab (integration)', () => {
|
||||
beforeEach(() => {
|
||||
// Reset URL state between tests — jsdom shares window.location across a file.
|
||||
@@ -51,7 +110,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
|
||||
it('renders no error banner on a successful load', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTab />);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
|
||||
@@ -63,7 +122,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
render(<AttributeMappingsTab />);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await expect(screen.findByRole('alert')).resolves.toHaveTextContent(
|
||||
'Failed to load mapping groups. Please try again.',
|
||||
@@ -72,7 +131,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
|
||||
it('shows the empty state when there are no groups', async () => {
|
||||
setupGroups([]);
|
||||
render(<AttributeMappingsTab />);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('mapper-groups-empty'),
|
||||
@@ -81,58 +140,96 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
|
||||
it('renders each group header row with its name, condition count and status', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTab />);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
// Condition filters are no longer shown inline as clauses — the header
|
||||
// carries a count instead (the keys surface in the group drawer, later PR).
|
||||
// Group headers are antd Collapse panels, so rows scope to the panel item.
|
||||
// group-1: enabled, with attribute + resource condition keys.
|
||||
const enabledRow = (await screen.findByTestId('group-name-group-1')).closest(
|
||||
'.ant-collapse-item',
|
||||
) as HTMLElement;
|
||||
expect(
|
||||
within(enabledRow).getByTestId('group-name-group-1'),
|
||||
).toHaveTextContent('demo');
|
||||
expect(
|
||||
within(enabledRow).getByTestId('group-condition-count-group-1'),
|
||||
).toHaveTextContent('2 conditions');
|
||||
expect(within(enabledRow).getByTestId('group-enabled-group-1')).toBeChecked();
|
||||
// carries a count instead (the keys surface in the group drawer).
|
||||
// Every field carries a group-scoped testId, so assert on them directly.
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
|
||||
// group-2: disabled, with no condition keys.
|
||||
const disabledRow = screen
|
||||
.getByTestId('group-name-group-2')
|
||||
.closest('.ant-collapse-item') as HTMLElement;
|
||||
expect(within(disabledRow).getByText('Tool')).toBeInTheDocument();
|
||||
expect(
|
||||
within(disabledRow).getByTestId('group-condition-count-group-2'),
|
||||
).toHaveTextContent('0 conditions');
|
||||
expect(
|
||||
within(disabledRow).getByTestId('group-enabled-group-2'),
|
||||
).not.toBeChecked();
|
||||
// group-1: enabled, with attribute + resource condition keys.
|
||||
expect(screen.getByTestId('group-name-group-1')).toHaveTextContent('demo');
|
||||
expect(screen.getByTestId('group-condition-count-group-1')).toHaveTextContent(
|
||||
'2 conditions',
|
||||
);
|
||||
expect(screen.getByTestId('group-enabled-group-1')).toBeChecked();
|
||||
|
||||
// group-2: disabled.
|
||||
expect(screen.getByTestId('group-name-group-2')).toHaveTextContent('Tool');
|
||||
expect(screen.getByTestId('group-condition-count-group-2')).toHaveTextContent(
|
||||
'0 conditions',
|
||||
);
|
||||
expect(screen.getByTestId('group-enabled-group-2')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('renders the group enable state as a read-only switch', async () => {
|
||||
it("stages a toggle of the group's enabled state via the header switch", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTab />);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
// The status switch reflects enabled state but is non-interactive in this
|
||||
// read-only listing — editing lands in a later PR.
|
||||
const toggle = await screen.findByTestId('group-enabled-group-1');
|
||||
expect(toggle).toBeChecked();
|
||||
expect(toggle).toBeDisabled();
|
||||
|
||||
await user.click(toggle);
|
||||
expect(screen.getByTestId('group-enabled-group-1')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('invokes onAddGroup from the toolbar button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onAddGroup = jest.fn();
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTabWithStore onAddGroup={onAddGroup} />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await user.click(screen.getByTestId('add-group-row'));
|
||||
|
||||
expect(onAddGroup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('invokes onEditGroup with the group when Edit is chosen from the actions menu', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onEditGroup = jest.fn();
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTabWithStore onEditGroup={onEditGroup} />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await openGroupActionsMenu(user, 'group-1');
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Edit' }));
|
||||
|
||||
expect(onEditGroup).toHaveBeenCalledTimes(1);
|
||||
expect(onEditGroup.mock.calls[0][0]).toMatchObject({
|
||||
localId: 'group-1',
|
||||
name: 'demo',
|
||||
});
|
||||
});
|
||||
|
||||
it('stages a group removal when Delete is chosen from the actions menu', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await openGroupActionsMenu(user, 'group-1');
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('group-name-group-1')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByTestId('group-name-group-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reveals a group's mappers on expand and hides them on collapse", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1' })]);
|
||||
render(<AttributeMappingsTab />);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
// The toggle is the antd Collapse header, which owns the expanded state.
|
||||
// The clickable Collapse header owns the expanded state; find it by the
|
||||
// aria-expanded contract rather than any framework-internal class.
|
||||
const header = screen
|
||||
.getByTestId('group-expand-group-1')
|
||||
.closest('.ant-collapse-header') as HTMLElement;
|
||||
.closest('[aria-expanded]') as HTMLElement;
|
||||
expect(header).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
await expandGroup(user);
|
||||
@@ -155,7 +252,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
setupMappers([
|
||||
makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model', enabled: true }),
|
||||
]);
|
||||
render(<AttributeMappingsTab />);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
// Mappers are not fetched until the row is expanded.
|
||||
@@ -172,29 +269,12 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
const sources = within(mapperRow).getByTestId('mapper-sources-mapper-1');
|
||||
expect(sources).toHaveTextContent('genai.model');
|
||||
expect(sources).toHaveTextContent('llm.model');
|
||||
// Writes-to field context + enabled status (an inline Switch, not text).
|
||||
expect(within(mapperRow).getByText('attribute')).toBeInTheDocument();
|
||||
expect(
|
||||
within(mapperRow).getByTestId('mapper-enabled-mapper-1'),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
it("renders a mapper's enable state as a read-only switch", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1', enabled: true })]);
|
||||
render(<AttributeMappingsTab />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
|
||||
// Like the group switch, a mapper's status switch reflects state without
|
||||
// accepting flips in this read-only listing.
|
||||
const toggle = await screen.findByTestId('mapper-enabled-mapper-1');
|
||||
expect(toggle).toBeChecked();
|
||||
expect(toggle).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows the mappers error state when the mappers request fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
@@ -203,7 +283,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
res(ctx.status(500)),
|
||||
),
|
||||
);
|
||||
render(<AttributeMappingsTab />);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
@@ -217,7 +297,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([]);
|
||||
render(<AttributeMappingsTab />);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
@@ -243,7 +323,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
},
|
||||
}),
|
||||
]);
|
||||
render(<AttributeMappingsTab />);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
@@ -255,7 +335,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1', config: { sources: [] } })]);
|
||||
render(<AttributeMappingsTab />);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
@@ -264,4 +344,167 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
expect(screen.getByTestId('mapper-sources-mapper-1')).toHaveTextContent('—'),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-fetches an open group's mappers on save instead of showing stale rows", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
|
||||
let mappers = mockMappers;
|
||||
server.use(
|
||||
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeMappersResponse(mappers))),
|
||||
),
|
||||
);
|
||||
render(<SaveableHarness />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('group-expand-group-1'));
|
||||
await expect(
|
||||
screen.findByTestId('mapper-target-mapper-1'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
mappers = [makeMapper({ id: 'mapper-2', name: 'gen_ai.response.model' })];
|
||||
|
||||
await user.click(screen.getByTestId('save-button'));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('mapper-target-mapper-2'),
|
||||
).resolves.toHaveTextContent('gen_ai.response.model');
|
||||
expect(
|
||||
screen.queryByTestId('mapper-target-mapper-1'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns and keeps the working copy when the post-save refresh fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
(toast.success as jest.Mock).mockClear();
|
||||
(toast.warning as jest.Mock).mockClear();
|
||||
|
||||
let failRefresh = false;
|
||||
server.use(
|
||||
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
|
||||
failRefresh
|
||||
? res(ctx.status(500), ctx.json({ status: 'error' }))
|
||||
: res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
|
||||
),
|
||||
rest.patch(`${GROUPS_ENDPOINT}/:groupId`, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'ok' })),
|
||||
),
|
||||
);
|
||||
render(<SaveableHarness />);
|
||||
|
||||
const toggle = await screen.findByTestId('group-enabled-group-1');
|
||||
expect(toggle).toBeChecked();
|
||||
|
||||
await user.click(toggle);
|
||||
expect(screen.getByTestId('group-enabled-group-1')).not.toBeChecked();
|
||||
|
||||
failRefresh = true;
|
||||
await user.click(screen.getByTestId('save-button'));
|
||||
|
||||
await waitFor(() => expect(toast.warning).toHaveBeenCalled());
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('mapper drawer', () => {
|
||||
async function openGroupWithMapper(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> {
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model' })]);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
await screen.findByTestId('mapper-target-mapper-1');
|
||||
}
|
||||
|
||||
it('opens the add-mapping drawer from a group\'s "Add mapping" button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await openGroupWithMapper(user);
|
||||
|
||||
expect(screen.queryByTestId('mapper-form-drawer')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByTestId('add-mapper-group-1'));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('mapper-form-drawer'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('New custom mapping')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the edit drawer prefilled and locks the target attribute', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await openGroupWithMapper(user);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Mapping actions' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Edit' }));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('mapper-form-drawer'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('Edit mapping')).toBeInTheDocument();
|
||||
const target = screen.getByTestId('mapper-form-target');
|
||||
expect(target).toHaveValue('gen_ai.request.model');
|
||||
expect(target).toBeDisabled();
|
||||
});
|
||||
|
||||
it("toggles a mapper's enabled state through the store", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await openGroupWithMapper(user);
|
||||
|
||||
const toggle = screen.getByTestId('mapper-enabled-mapper-1');
|
||||
expect(toggle).toBeChecked();
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('mapper-enabled-mapper-1')).not.toBeChecked(),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a mapper via the row action menu', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await openGroupWithMapper(user);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Mapping actions' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId('mapper-target-mapper-1'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByTestId('mappers-empty-group-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a new mapping through the drawer and appends it to the group', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await openGroupWithMapper(user);
|
||||
|
||||
await user.click(screen.getByTestId('add-mapper-group-1'));
|
||||
await screen.findByTestId('mapper-form-drawer');
|
||||
|
||||
expect(screen.getByTestId('mapper-form-save')).toBeDisabled();
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId('mapper-form-target'),
|
||||
'gen_ai.response.model',
|
||||
);
|
||||
await user.type(screen.getByTestId('mapper-form-source-0'), 'raw.model');
|
||||
|
||||
const create = screen.getByTestId('mapper-form-save');
|
||||
await waitFor(() => expect(create).toBeEnabled());
|
||||
await user.click(create);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('mapper-form-drawer')).not.toBeInTheDocument(),
|
||||
);
|
||||
await expect(
|
||||
screen.findByText('gen_ai.response.model'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisVertical, Pencil, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
|
||||
interface GroupActionsMenuProps {
|
||||
group: DraftGroup;
|
||||
onEdit: (group: DraftGroup) => void;
|
||||
onRemove: (localId: string) => void;
|
||||
}
|
||||
|
||||
function GroupActionsMenu({
|
||||
group,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: GroupActionsMenuProps): JSX.Element {
|
||||
const menuItems = useMemo<MenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
icon: <Pencil size={14} />,
|
||||
onClick: (): void => onEdit(group),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
onClick: (): void => onRemove(group.localId),
|
||||
},
|
||||
],
|
||||
[onEdit, onRemove, group],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Group actions"
|
||||
data-testid={`group-actions-${group.localId}`}
|
||||
>
|
||||
<EllipsisVertical size={16} />
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupActionsMenu;
|
||||
@@ -0,0 +1,32 @@
|
||||
.conditionsTooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
max-width: 320px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.keyList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.key {
|
||||
max-width: 100%;
|
||||
padding: 0 var(--spacing-1);
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--bg-ink-300);
|
||||
color: var(--l1-foreground);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './ConditionsTooltip.module.scss';
|
||||
|
||||
interface ConditionsTooltipProps {
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
}
|
||||
|
||||
function ConditionsTooltip({
|
||||
attributes,
|
||||
resource,
|
||||
}: ConditionsTooltipProps): JSX.Element {
|
||||
const hasConditions = attributes.length > 0 || resource.length > 0;
|
||||
|
||||
if (!hasConditions) {
|
||||
return (
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
No conditions set up
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.conditionsTooltip}
|
||||
data-testid="group-conditions-tooltip"
|
||||
>
|
||||
{attributes.length > 0 && (
|
||||
<div className={styles.section}>
|
||||
<Typography.Text as="span" size="small" color="muted">
|
||||
Runs when a span attribute key contains
|
||||
</Typography.Text>
|
||||
<div className={styles.keyList}>
|
||||
{attributes.map((key) => (
|
||||
<code key={key} className={styles.key}>
|
||||
{key}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{resource.length > 0 && (
|
||||
<div className={styles.section}>
|
||||
<Typography.Text as="span" size="sm" color="muted">
|
||||
{attributes.length > 0 ? 'or when' : 'Runs when'} a resource key contains
|
||||
</Typography.Text>
|
||||
<div className={styles.keyList}>
|
||||
{resource.map((key) => (
|
||||
<code key={key} className={styles.key}>
|
||||
{key}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConditionsTooltip;
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
.groupName {
|
||||
color: var(--l1-foreground);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -17,3 +18,15 @@
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conditionCount {
|
||||
composes: groupCount;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.conditionInfoIcon {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
|
||||
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
|
||||
import ConditionsTooltip from './ConditionsTooltip';
|
||||
import styles from './GroupHeader.module.scss';
|
||||
|
||||
interface GroupHeaderProps {
|
||||
group: MappingGroup;
|
||||
group: DraftGroup;
|
||||
}
|
||||
|
||||
function GroupHeader({ group }: GroupHeaderProps): JSX.Element {
|
||||
@@ -13,22 +17,33 @@ function GroupHeader({ group }: GroupHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.groupHeaderLabel}
|
||||
data-testid={`group-expand-${group.id}`}
|
||||
data-testid={`group-expand-${group.localId}`}
|
||||
>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
className={styles.groupName}
|
||||
testId={`group-name-${group.id}`}
|
||||
testId={`group-name-${group.localId}`}
|
||||
>
|
||||
{group.name}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
className={styles.groupCount}
|
||||
testId={`group-condition-count-${group.id}`}
|
||||
<TooltipSimple
|
||||
title={
|
||||
<ConditionsTooltip
|
||||
attributes={group.attributes}
|
||||
resource={group.resource}
|
||||
/>
|
||||
}
|
||||
side="bottom"
|
||||
align="start"
|
||||
>
|
||||
· {conditionCount} {conditionCount === 1 ? 'condition' : 'conditions'}
|
||||
</Typography.Text>
|
||||
<span
|
||||
className={styles.conditionCount}
|
||||
data-testid={`group-condition-count-${group.localId}`}
|
||||
>
|
||||
· {conditionCount} {conditionCount === 1 ? 'condition' : 'conditions'}
|
||||
<Info size={12} className={styles.conditionInfoIcon} />
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
|
||||
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import GroupActionsMenu from '../GroupActionsMenu/GroupActionsMenu';
|
||||
import styles from './GroupHeaderActions.module.scss';
|
||||
|
||||
interface GroupHeaderActionsProps {
|
||||
group: MappingGroup;
|
||||
group: DraftGroup;
|
||||
onToggle: (localId: string, enabled: boolean) => void;
|
||||
onEdit: (group: DraftGroup) => void;
|
||||
onRemove: (localId: string) => void;
|
||||
}
|
||||
|
||||
function GroupHeaderActions({ group }: GroupHeaderActionsProps): JSX.Element {
|
||||
function GroupHeaderActions({
|
||||
group,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: GroupHeaderActionsProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.actions}
|
||||
@@ -15,10 +24,10 @@ function GroupHeaderActions({ group }: GroupHeaderActionsProps): JSX.Element {
|
||||
>
|
||||
<Switch
|
||||
value={group.enabled}
|
||||
// We don't yet support toggling a group's enabled state in this read-only PR, so disable the switch. A later PR will add the toggle handler and its drawer.
|
||||
disabled
|
||||
testId={`group-enabled-${group.id}`}
|
||||
onChange={(checked): void => onToggle(group.localId, checked)}
|
||||
testId={`group-enabled-${group.localId}`}
|
||||
/>
|
||||
<GroupActionsMenu group={group} onEdit={onEdit} onRemove={onRemove} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,26 @@
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.stateContent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.addMapperRow .stateCell {
|
||||
padding-top: var(--spacing-2);
|
||||
padding-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.stateCell {
|
||||
padding: var(--spacing-4) var(--spacing-6) var(--spacing-4) var(--spacing-12);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.addMapperCell {
|
||||
padding: var(--spacing-4) var(--spacing-6) var(--spacing-4) var(--spacing-6);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ArrowLeftRight, Plus, TriangleAlert } from '@signozhq/icons';
|
||||
import { useListSpanMappers } from 'api/generated/services/spanmapper';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import {
|
||||
MappingGroup,
|
||||
Mapping,
|
||||
DraftGroup,
|
||||
DraftMapper,
|
||||
Mapper,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { buildMappingsFromListResponse } from 'container/LLMObservability/AttributeMapping/utils';
|
||||
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
|
||||
import { COLUMN_COUNT } from '../constants';
|
||||
import MapperRow, { MapperRowSkeleton } from '../MapperRow';
|
||||
import MappingsColgroup from '../MappingsColgroup';
|
||||
import MapperRow from '../MapperRow/MapperRow';
|
||||
import MapperRowSkeleton from '../MapperRow/MapperRowSkeleton';
|
||||
import MappingsColgroup from '../MappingsColgroup/MappingsColgroup';
|
||||
import styles from './GroupMappers.module.scss';
|
||||
|
||||
const MAPPER_SKELETON_ROWS = 1;
|
||||
@@ -19,72 +25,135 @@ const STATE_ROW_MOTION = {
|
||||
transition: { duration: 0.18, ease: 'easeOut' },
|
||||
} as const;
|
||||
|
||||
interface StateRowProps {
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
function ErrorRow({ groupId }: StateRowProps): JSX.Element {
|
||||
return (
|
||||
<motion.tr className={styles.mapperStateRow} {...STATE_ROW_MOTION}>
|
||||
<td
|
||||
colSpan={COLUMN_COUNT}
|
||||
className={styles.stateCell}
|
||||
data-testid={`mappers-error-${groupId}`}
|
||||
>
|
||||
Failed to load mappings. Please try again.
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyRow({ groupId }: StateRowProps): JSX.Element {
|
||||
return (
|
||||
<motion.tr className={styles.mapperStateRow} {...STATE_ROW_MOTION}>
|
||||
<td
|
||||
colSpan={COLUMN_COUNT}
|
||||
className={styles.stateCell}
|
||||
data-testid={`mappers-empty-${groupId}`}
|
||||
>
|
||||
No mappings in this group yet.
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupMappersProps {
|
||||
group: MappingGroup;
|
||||
group: DraftGroup;
|
||||
editor: AttributeMappingEditor;
|
||||
onAddMapper: (groupLocalId: string) => void;
|
||||
onEditMapper: (groupLocalId: string, mapper: DraftMapper) => void;
|
||||
}
|
||||
|
||||
function GroupMappers({ group }: GroupMappersProps): JSX.Element {
|
||||
const {
|
||||
data: mappers = [],
|
||||
isLoading,
|
||||
isError,
|
||||
} = useListSpanMappers<Mapping[]>(
|
||||
{
|
||||
groupId: group.id,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
refetchOnMount: false,
|
||||
select: buildMappingsFromListResponse,
|
||||
},
|
||||
},
|
||||
function GroupMappers({
|
||||
group,
|
||||
editor,
|
||||
onAddMapper,
|
||||
onEditMapper,
|
||||
}: GroupMappersProps): JSX.Element {
|
||||
const { hydrateGroupMappers, removeMapper, toggleMapper } = editor;
|
||||
|
||||
const hasServerId = group.serverId !== null;
|
||||
const { data, isLoading, isError } = useListSpanMappers(
|
||||
{ groupId: group.serverId ?? '' },
|
||||
// refetchOnMount: false — the Collapse destroys inactive panels, so a
|
||||
// group's mappers would otherwise refetch on every expand. Cached data is
|
||||
// reused instead; the save flow evicts these caches so post-edit expands
|
||||
// still fetch fresh.
|
||||
{ query: { enabled: hasServerId, refetchOnMount: false } },
|
||||
);
|
||||
|
||||
let rows: JSX.Element[];
|
||||
if (isError) {
|
||||
rows = [<ErrorRow key="error" groupId={group.id} />];
|
||||
} else if (isLoading) {
|
||||
rows = Array.from({ length: MAPPER_SKELETON_ROWS }).map((_, index) => (
|
||||
useEffect(() => {
|
||||
const items = data?.data?.items;
|
||||
if (group.serverId && items) {
|
||||
hydrateGroupMappers(group.serverId, items as unknown as Mapper[]);
|
||||
}
|
||||
}, [group.serverId, data, hydrateGroupMappers]);
|
||||
|
||||
const isLoadingMappers = hasServerId && isLoading;
|
||||
const isErrorMappers = hasServerId && isError;
|
||||
const mapperCount = group.mappers.length;
|
||||
|
||||
const skeletonRows = Array.from({ length: MAPPER_SKELETON_ROWS }).map(
|
||||
(_, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<MapperRowSkeleton key={`mapper-skeleton-${index}`} />
|
||||
));
|
||||
} else if (mappers.length === 0) {
|
||||
rows = [<EmptyRow key="empty" groupId={group.id} />];
|
||||
),
|
||||
);
|
||||
|
||||
const errorRow = (
|
||||
<motion.tr
|
||||
key="error"
|
||||
className={styles.mapperStateRow}
|
||||
{...STATE_ROW_MOTION}
|
||||
>
|
||||
<td
|
||||
colSpan={COLUMN_COUNT}
|
||||
className={styles.stateCell}
|
||||
data-testid={`mappers-error-${group.localId}`}
|
||||
>
|
||||
<span className={styles.stateContent}>
|
||||
<TriangleAlert size={14} color="var(--danger-background)" />
|
||||
<Typography.Text as="span" size="base">
|
||||
Failed to load mappings. Please try again.
|
||||
</Typography.Text>
|
||||
</span>
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
|
||||
const emptyRow = (
|
||||
<motion.tr
|
||||
key="empty"
|
||||
className={styles.mapperStateRow}
|
||||
{...STATE_ROW_MOTION}
|
||||
>
|
||||
<td
|
||||
colSpan={COLUMN_COUNT}
|
||||
className={styles.stateCell}
|
||||
data-testid={`mappers-empty-${group.localId}`}
|
||||
>
|
||||
<span className={styles.stateContent}>
|
||||
<ArrowLeftRight size={14} />
|
||||
<Typography.Text as="span" size="base" color="muted">
|
||||
No mappings in this group yet.
|
||||
</Typography.Text>
|
||||
</span>
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
|
||||
const addMapperRow = (
|
||||
<motion.tr
|
||||
key="add-mapper"
|
||||
className={styles.addMapperRow}
|
||||
{...STATE_ROW_MOTION}
|
||||
>
|
||||
<td colSpan={COLUMN_COUNT} className={styles.addMapperCell}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => onAddMapper(group.localId)}
|
||||
testId={`add-mapper-${group.localId}`}
|
||||
>
|
||||
Add mapping
|
||||
</Button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
|
||||
const mapperRows = group.mappers.map((mapper, index) => (
|
||||
<MapperRow
|
||||
key={mapper.localId}
|
||||
mapper={mapper}
|
||||
index={index}
|
||||
onEdit={(next): void => onEditMapper(group.localId, next)}
|
||||
onRemove={(localId): void => removeMapper(group.localId, localId)}
|
||||
onToggle={(localId, enabled): void =>
|
||||
toggleMapper(group.localId, localId, enabled)
|
||||
}
|
||||
/>
|
||||
));
|
||||
|
||||
// The add-mapping row trails every non-error state (including loading/empty).
|
||||
let rows: JSX.Element[];
|
||||
if (isErrorMappers) {
|
||||
rows = [errorRow];
|
||||
} else if (isLoadingMappers && mapperCount === 0) {
|
||||
rows = [...skeletonRows, addMapperRow];
|
||||
} else if (mapperCount === 0) {
|
||||
rows = [emptyRow, addMapperRow];
|
||||
} else {
|
||||
rows = mappers.map((mapper, index) => (
|
||||
<MapperRow key={mapper.id} mapper={mapper} index={index} />
|
||||
));
|
||||
rows = [...mapperRows, addMapperRow];
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisVertical, Pencil, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { DraftMapper } from 'container/LLMObservability/AttributeMapping/types';
|
||||
|
||||
interface MapperActionsMenuProps {
|
||||
mapper: DraftMapper;
|
||||
onEdit: (mapper: DraftMapper) => void;
|
||||
onRemove: (localId: string) => void;
|
||||
}
|
||||
|
||||
function MapperActionsMenu({
|
||||
mapper,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: MapperActionsMenuProps): JSX.Element {
|
||||
const menuItems = useMemo<MenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
icon: <Pencil size={14} />,
|
||||
onClick: (): void => onEdit(mapper),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
onClick: (): void => onRemove(mapper.localId),
|
||||
},
|
||||
],
|
||||
[onEdit, onRemove, mapper],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Mapping actions"
|
||||
testId={`mapper-actions-${mapper.localId}`}
|
||||
>
|
||||
<EllipsisVertical size={16} />
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperActionsMenu;
|
||||
@@ -11,11 +11,22 @@
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
// Indent the first cell so mapper rows read as nested under their group.
|
||||
.targetCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
padding-left: var(--spacing-12);
|
||||
}
|
||||
|
||||
.targetName {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.targetContextBadge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Shorter vertical padding so the loading state reads as a compact placeholder.
|
||||
.skeletonCell {
|
||||
composes: cell;
|
||||
@@ -29,7 +40,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.statusCell {
|
||||
.actionsCell {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -47,6 +58,10 @@
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.sourceChip {
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.sourceChipText {
|
||||
display: block;
|
||||
max-width: 220px;
|
||||
|
||||
@@ -5,7 +5,8 @@ import { SpantypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas'
|
||||
import cx from 'classnames';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import { Mapping } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { DraftMapper } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import MapperActionsMenu from '../MapperActionsMenu/MapperActionsMenu';
|
||||
import styles from './MapperRow.module.scss';
|
||||
|
||||
const MAX_VISIBLE_SOURCES = 3;
|
||||
@@ -15,11 +16,20 @@ const MAX_STAGGERED_ROWS = 6;
|
||||
const STAGGER_STEP = 0.03;
|
||||
|
||||
interface MapperRowProps {
|
||||
mapper: Mapping;
|
||||
mapper: DraftMapper;
|
||||
index: number;
|
||||
onEdit: (mapper: DraftMapper) => void;
|
||||
onRemove: (localId: string) => void;
|
||||
onToggle: (localId: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
function MapperRow({ mapper, index }: MapperRowProps): JSX.Element {
|
||||
function MapperRow({
|
||||
mapper,
|
||||
index,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onToggle,
|
||||
}: MapperRowProps): JSX.Element {
|
||||
const sources = mapper.sources ?? [];
|
||||
const visibleSources = sources.slice(0, MAX_VISIBLE_SOURCES);
|
||||
const remainingSources = sources.length - visibleSources.length;
|
||||
@@ -27,7 +37,7 @@ function MapperRow({ mapper, index }: MapperRowProps): JSX.Element {
|
||||
return (
|
||||
<motion.tr
|
||||
className={styles.mapperRow}
|
||||
data-testid={`mapper-row-${mapper.id}`}
|
||||
data-testid={`mapper-row-${mapper.localId}`}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
@@ -37,22 +47,38 @@ function MapperRow({ mapper, index }: MapperRowProps): JSX.Element {
|
||||
>
|
||||
<td className={cx(styles.cell, styles.targetCell)}>
|
||||
<Typography.Text
|
||||
weight="semibold"
|
||||
truncate={1}
|
||||
title={mapper.name}
|
||||
data-testid={`mapper-target-${mapper.id}`}
|
||||
className={styles.targetName}
|
||||
data-testid={`mapper-target-${mapper.localId}`}
|
||||
>
|
||||
{mapper.name}
|
||||
</Typography.Text>
|
||||
<Badge
|
||||
color={
|
||||
mapper.fieldContext === SpantypesFieldContextDTO.resource
|
||||
? 'amber'
|
||||
: 'robin'
|
||||
}
|
||||
variant="outline"
|
||||
className={styles.targetContextBadge}
|
||||
>
|
||||
{mapper.fieldContext}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className={styles.cell}>
|
||||
{sources.length === 0 ? (
|
||||
<span className={styles.muted} data-testid={`mapper-sources-${mapper.id}`}>
|
||||
<span
|
||||
className={styles.muted}
|
||||
data-testid={`mapper-sources-${mapper.localId}`}
|
||||
>
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
className={styles.sources}
|
||||
data-testid={`mapper-sources-${mapper.id}`}
|
||||
data-testid={`mapper-sources-${mapper.localId}`}
|
||||
>
|
||||
{visibleSources.map((source) => (
|
||||
<Badge
|
||||
@@ -74,25 +100,14 @@ function MapperRow({ mapper, index }: MapperRowProps): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.cell}>
|
||||
<Badge
|
||||
color={
|
||||
mapper.fieldContext === SpantypesFieldContextDTO.resource
|
||||
? 'amber'
|
||||
: 'robin'
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{mapper.fieldContext}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className={cx(styles.cell, styles.statusCell)}>
|
||||
<td className={cx(styles.cell, styles.actionsCell)}>
|
||||
<div className={styles.rowActions}>
|
||||
<Switch
|
||||
value={mapper.enabled}
|
||||
disabled
|
||||
testId={`mapper-enabled-${mapper.id}`}
|
||||
onChange={(checked): void => onToggle(mapper.localId, checked)}
|
||||
testId={`mapper-enabled-${mapper.localId}`}
|
||||
/>
|
||||
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
|
||||
@@ -8,6 +8,7 @@ function MapperRowSkeleton(): JSX.Element {
|
||||
<tr className={styles.mapperRow}>
|
||||
<td className={cx(styles.skeletonCell, styles.targetCell)}>
|
||||
<Skeleton.Input active size="small" style={{ width: '55%' }} />
|
||||
<Skeleton.Button active size="small" style={{ width: 72 }} />
|
||||
</td>
|
||||
<td className={styles.skeletonCell}>
|
||||
<div className={styles.sources}>
|
||||
@@ -15,10 +16,7 @@ function MapperRowSkeleton(): JSX.Element {
|
||||
<Skeleton.Button active size="small" style={{ width: 56 }} />
|
||||
</div>
|
||||
</td>
|
||||
<td className={styles.skeletonCell}>
|
||||
<Skeleton.Button active size="small" style={{ width: 72 }} />
|
||||
</td>
|
||||
<td className={cx(styles.skeletonCell, styles.statusCell)}>
|
||||
<td className={cx(styles.skeletonCell, styles.actionsCell)}>
|
||||
<div className={styles.rowActions}>
|
||||
<Skeleton.Button active size="small" shape="round" />
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
width: 32%;
|
||||
}
|
||||
|
||||
.colWritesTo {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.colStatus {
|
||||
width: 120px;
|
||||
.colActions {
|
||||
width: 176px;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ function MappingsColgroup(): JSX.Element {
|
||||
<colgroup>
|
||||
<col className={styles.colTarget} />
|
||||
<col />
|
||||
<col className={styles.colWritesTo} />
|
||||
<col className={styles.colStatus} />
|
||||
<col className={styles.colActions} />
|
||||
</colgroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
.tableWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -5,7 +18,7 @@
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.headerCell {
|
||||
@@ -73,7 +86,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tableEmpty {
|
||||
.tableState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-12) var(--spacing-6);
|
||||
text-align: center;
|
||||
color: var(--l3-foreground);
|
||||
|
||||
@@ -1,30 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight, Layers, Plus } from '@signozhq/icons';
|
||||
import { Collapse, type CollapseProps, Skeleton } from 'antd';
|
||||
|
||||
import { AttributeMappingStore } from 'container/LLMObservability/AttributeMapping/AttributeMappingsTab/hooks/useAttributeMappingStore';
|
||||
import GroupHeader from './GroupHeader';
|
||||
import GroupHeaderActions from './GroupHeaderActions';
|
||||
import GroupMappers from './GroupMappers';
|
||||
import MappingsColgroup from './MappingsColgroup';
|
||||
import MapperFormDrawer from 'container/LLMObservability/AttributeMapping/components/MapperFormDrawer/MapperFormDrawer';
|
||||
import { useMapperFormDrawer } from 'container/LLMObservability/AttributeMapping/components/MapperFormDrawer/hooks/useMapperFormDrawer';
|
||||
import {
|
||||
DraftGroup,
|
||||
DraftMapper,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
|
||||
import GroupHeader from './GroupHeader/GroupHeader';
|
||||
import GroupHeaderActions from './GroupHeaderActions/GroupHeaderActions';
|
||||
import GroupMappers from './GroupMappers/GroupMappers';
|
||||
import MappingsColgroup from './MappingsColgroup/MappingsColgroup';
|
||||
import styles from './MappingsTable.module.scss';
|
||||
|
||||
const SKELETON_ROW_COUNT = 3;
|
||||
|
||||
interface MappingsTableProps {
|
||||
store: AttributeMappingStore;
|
||||
editor: AttributeMappingEditor;
|
||||
onEditGroup: (group: DraftGroup) => void;
|
||||
onAddGroup: () => void;
|
||||
}
|
||||
|
||||
function MappingsTable({ store }: MappingsTableProps): JSX.Element {
|
||||
function MappingsTable({
|
||||
editor,
|
||||
onEditGroup,
|
||||
onAddGroup,
|
||||
}: MappingsTableProps): JSX.Element {
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||
const [targetGroupId, setTargetGroupId] = useState<string | null>(null);
|
||||
const drawer = useMapperFormDrawer();
|
||||
|
||||
const isEmpty = !store.isLoading && store.groups.length === 0;
|
||||
const { upsertMapper, removeMapper } = editor;
|
||||
|
||||
const items: CollapseProps['items'] = store.groups.map((group) => ({
|
||||
key: group.id,
|
||||
const handleAddMapper = useCallback(
|
||||
(groupLocalId: string): void => {
|
||||
setTargetGroupId(groupLocalId);
|
||||
// Keep the group open so the staged row is visible after save.
|
||||
setExpandedGroups((prev) =>
|
||||
prev.includes(groupLocalId) ? prev : [...prev, groupLocalId],
|
||||
);
|
||||
drawer.openForAdd();
|
||||
},
|
||||
[drawer],
|
||||
);
|
||||
|
||||
const handleEditMapper = useCallback(
|
||||
(groupLocalId: string, mapper: DraftMapper): void => {
|
||||
setTargetGroupId(groupLocalId);
|
||||
drawer.openForEdit(mapper);
|
||||
},
|
||||
[drawer],
|
||||
);
|
||||
|
||||
const handleSaveMapper = useCallback((): void => {
|
||||
if (targetGroupId) {
|
||||
upsertMapper(targetGroupId, drawer.draft);
|
||||
}
|
||||
drawer.close();
|
||||
}, [targetGroupId, upsertMapper, drawer]);
|
||||
|
||||
const handleDeleteMapper = useCallback((): void => {
|
||||
if (targetGroupId && drawer.draft.id) {
|
||||
removeMapper(targetGroupId, drawer.draft.id);
|
||||
}
|
||||
drawer.close();
|
||||
}, [targetGroupId, removeMapper, drawer]);
|
||||
|
||||
const isEmpty = !editor.isLoading && editor.groups.length === 0;
|
||||
|
||||
const items: CollapseProps['items'] = editor.groups.map((group) => ({
|
||||
key: group.localId,
|
||||
label: <GroupHeader group={group} />,
|
||||
extra: <GroupHeaderActions group={group} />,
|
||||
children: <GroupMappers group={group} />,
|
||||
extra: (
|
||||
<GroupHeaderActions
|
||||
group={group}
|
||||
onToggle={editor.toggleGroup}
|
||||
onEdit={onEditGroup}
|
||||
onRemove={editor.removeGroup}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<GroupMappers
|
||||
group={group}
|
||||
editor={editor}
|
||||
onAddMapper={handleAddMapper}
|
||||
onEditMapper={handleEditMapper}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
const skeletonBanners = (
|
||||
@@ -45,48 +111,79 @@ function MappingsTable({ store }: MappingsTableProps): JSX.Element {
|
||||
</div>
|
||||
<div className={styles.skeletonGroupRight}>
|
||||
<Skeleton.Button active size="small" shape="round" />
|
||||
<Skeleton.Avatar active size={16} shape="square" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className={styles.tableEmpty} data-testid="mapper-groups-empty">
|
||||
No mapping groups yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="mappings-table">
|
||||
<table className={styles.table}>
|
||||
<MappingsColgroup />
|
||||
<thead>
|
||||
<tr className={styles.headerRow}>
|
||||
<th className={styles.headerCell}>Target</th>
|
||||
<th className={styles.headerCell}>Sources</th>
|
||||
<th className={styles.headerCell}>Writes to</th>
|
||||
<th className={styles.headerCell}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
{store.isLoading ? (
|
||||
skeletonBanners
|
||||
<div className={styles.tableWrapper}>
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onAddGroup}
|
||||
testId="add-group-row"
|
||||
disabled={editor.isLoading}
|
||||
>
|
||||
Add a new group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className={styles.tableState} data-testid="mapper-groups-empty">
|
||||
<Layers size={24} />
|
||||
<Typography.Text as="span" size="base" color="muted">
|
||||
No mapping groups yet.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<Collapse
|
||||
className={styles.groupsCollapse}
|
||||
activeKey={expandedGroups}
|
||||
onChange={(keys): void =>
|
||||
setExpandedGroups(Array.isArray(keys) ? keys : [keys])
|
||||
}
|
||||
bordered={false}
|
||||
destroyInactivePanel
|
||||
expandIcon={({ isActive }): JSX.Element =>
|
||||
isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
items={items}
|
||||
<div data-testid="mappings-table">
|
||||
<table className={styles.table}>
|
||||
<MappingsColgroup />
|
||||
<thead>
|
||||
<tr className={styles.headerRow}>
|
||||
<th className={styles.headerCell}>Target</th>
|
||||
<th className={styles.headerCell}>Sources</th>
|
||||
<th className={styles.headerCell}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
{editor.isLoading ? (
|
||||
skeletonBanners
|
||||
) : (
|
||||
<Collapse
|
||||
className={styles.groupsCollapse}
|
||||
activeKey={expandedGroups}
|
||||
onChange={(keys): void =>
|
||||
setExpandedGroups(Array.isArray(keys) ? keys : [keys])
|
||||
}
|
||||
bordered={false}
|
||||
destroyInactivePanel
|
||||
expandIcon={({ isActive }): JSX.Element =>
|
||||
isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
items={items}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{drawer.isOpen && (
|
||||
<MapperFormDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
mode={drawer.mode}
|
||||
draft={drawer.draft}
|
||||
setDraft={drawer.setDraft}
|
||||
onClose={drawer.close}
|
||||
onSave={handleSaveMapper}
|
||||
onDelete={handleDeleteMapper}
|
||||
isSaving={false}
|
||||
isDeleting={false}
|
||||
saveError={null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const COLUMN_COUNT = 4;
|
||||
export const COLUMN_COUNT = 3;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { SpantypesSpanMapperGroupDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useListSpanMapperGroups } from 'api/generated/services/spanmapper';
|
||||
|
||||
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { buildMappingGroup } from 'container/LLMObservability/AttributeMapping/utils';
|
||||
|
||||
export interface AttributeMappingStore {
|
||||
groups: MappingGroup[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
// Read-only store for the listing view: loads the server groups only. Each
|
||||
// group's mappers are fetched lazily when its panel is expanded (see
|
||||
// GroupMappers), so page load is a single request instead of an N+1 fan-out
|
||||
// across every group. Editing (enabled toggles, save/discard) and its drawers
|
||||
// land in a later PR — this PR only lists.
|
||||
export function useAttributeMappingStore(): AttributeMappingStore {
|
||||
const groupsQuery = useListSpanMapperGroups();
|
||||
|
||||
const groups = useMemo<MappingGroup[]>(() => {
|
||||
const serverGroups: SpantypesSpanMapperGroupDTO[] =
|
||||
groupsQuery.data?.data?.items ?? [];
|
||||
return serverGroups.map((group) => buildMappingGroup(group));
|
||||
}, [groupsQuery.data]);
|
||||
|
||||
return {
|
||||
groups,
|
||||
isLoading: groupsQuery.isLoading,
|
||||
isError: groupsQuery.isError,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
.llmObservabilityAttributeMapping {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-0);
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--spacing-2);
|
||||
|
||||
--tab-content-padding: 0;
|
||||
--tab-text-color: var(--l1-foreground);
|
||||
--tab-active-text-color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.pageError {
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { useConfirmableAction } from 'hooks/useConfirmableAction';
|
||||
|
||||
import AttributeMappingHeader from './components/AttributeMappingHeader';
|
||||
import AttributeMappingHeader from './components/AttributeMappingHeader/AttributeMappingHeader';
|
||||
import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
|
||||
import DiscardChangesDialog from './components/DiscardChangesDialog/DiscardChangesDialog';
|
||||
import GroupFormDrawer from './components/GroupFormDrawer/GroupFormDrawer';
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
|
||||
const noop = (): void => undefined;
|
||||
import { useAttributeMappingEditor } from './hooks/useAttributeMappingEditor';
|
||||
import { useGroupFormDrawer } from './components/GroupFormDrawer/hooks/useGroupFormDrawer';
|
||||
|
||||
function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
const editor = useAttributeMappingEditor();
|
||||
const groupDrawer = useGroupFormDrawer();
|
||||
|
||||
const { discard } = editor;
|
||||
// Discarding wipes the whole working copy, so gate it behind a confirm
|
||||
// prompt rather than firing straight from the button.
|
||||
const discardConfirm = useConfirmableAction(
|
||||
useCallback(async (): Promise<void> => {
|
||||
discard();
|
||||
}, [discard]),
|
||||
);
|
||||
|
||||
const handleGroupSave = useCallback((): void => {
|
||||
editor.upsertGroup(groupDrawer.draft);
|
||||
groupDrawer.close();
|
||||
}, [editor, groupDrawer]);
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'attribute-mappings',
|
||||
label: 'Attribute mappings',
|
||||
children: <AttributeMappingsTab />,
|
||||
label: 'Attribute Mappings',
|
||||
children: (
|
||||
<AttributeMappingsTab
|
||||
editor={editor}
|
||||
onEditGroup={groupDrawer.openForEdit}
|
||||
onAddGroup={groupDrawer.openForAdd}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'test',
|
||||
@@ -28,17 +56,39 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
data-testid="llm-observability-attribute-mapping-page"
|
||||
>
|
||||
<AttributeMappingHeader
|
||||
isDirty={false}
|
||||
isSaving={false}
|
||||
onDiscard={noop}
|
||||
onSave={noop}
|
||||
isDirty={editor.isDirty}
|
||||
isSaving={editor.isSaving}
|
||||
onDiscard={discardConfirm.request}
|
||||
onSave={editor.save}
|
||||
/>
|
||||
|
||||
{editor.saveError && (
|
||||
<div className={styles.pageError} role="alert">
|
||||
{editor.saveError}
|
||||
</div>
|
||||
)}
|
||||
<Divider />
|
||||
|
||||
<Tabs
|
||||
testId="attribute-mapping-tabs"
|
||||
defaultValue="attribute-mappings"
|
||||
items={tabItems}
|
||||
/>
|
||||
{groupDrawer.isOpen && (
|
||||
<GroupFormDrawer
|
||||
isOpen={groupDrawer.isOpen}
|
||||
mode={groupDrawer.mode}
|
||||
draft={groupDrawer.draft}
|
||||
setDraft={groupDrawer.setDraft}
|
||||
onClose={groupDrawer.close}
|
||||
onSave={handleGroupSave}
|
||||
/>
|
||||
)}
|
||||
<DiscardChangesDialog
|
||||
open={discardConfirm.open}
|
||||
onConfirm={discardConfirm.confirm}
|
||||
onCancel={discardConfirm.cancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
|
||||
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
|
||||
@@ -34,7 +34,7 @@ describe('LLMObservabilityAttributeMapping', () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Attribute mappings' }),
|
||||
screen.getByRole('tab', { name: 'Attribute Mappings' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Test' })).toBeInTheDocument();
|
||||
});
|
||||
@@ -43,7 +43,7 @@ describe('LLMObservabilityAttributeMapping', () => {
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
const attributeMappingsTab = screen.getByRole('tab', {
|
||||
name: 'Attribute mappings',
|
||||
name: 'Attribute Mappings',
|
||||
});
|
||||
expect(attributeMappingsTab).toHaveAttribute('data-state', 'active');
|
||||
await expect(
|
||||
@@ -64,4 +64,44 @@ describe('LLMObservabilityAttributeMapping', () => {
|
||||
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('unsaved-changes')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prompts before discarding, then reverts staged changes on confirm', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
const toggle = await screen.findByTestId('group-enabled-group-1');
|
||||
expect(toggle).toBeChecked();
|
||||
await user.click(toggle);
|
||||
expect(screen.getByTestId('group-enabled-group-1')).not.toBeChecked();
|
||||
|
||||
await user.click(screen.getByTestId('discard-changes-btn'));
|
||||
const confirmBtn = await screen.findByTestId('discard-changes-confirm-btn');
|
||||
expect(screen.getByTestId('group-enabled-group-1')).not.toBeChecked();
|
||||
|
||||
await user.click(confirmBtn);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('group-enabled-group-1')).toBeChecked(),
|
||||
);
|
||||
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps staged changes when the discard prompt is dismissed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
const toggle = await screen.findByTestId('group-enabled-group-1');
|
||||
await user.click(toggle);
|
||||
|
||||
await user.click(screen.getByTestId('discard-changes-btn'));
|
||||
await user.click(await screen.findByTestId('discard-changes-cancel-btn'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId('discard-changes-confirm-btn'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByTestId('group-enabled-group-1')).not.toBeChecked();
|
||||
expect(screen.getByTestId('discard-changes-btn')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: var(--spacing-2);
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.pageHeaderActions {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './AttributeMappingHeader';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user