Compare commits

..

17 Commits

Author SHA1 Message Date
nityanandagohain
b9ec4a5efd fix: support for quick filters 2026-07-17 12:54:45 +05:30
nityanandagohain
247c3c4aee feat: support for trace aggregate in metadata endpoint 2026-07-17 00:41:07 +05:30
nityanandagohain
cce080e1ae fix: add back the flag in metadata 2026-07-16 11:37:57 +05:30
nityanandagohain
deca060a4d Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-16 11:00:55 +05:30
nityanandagohain
03c7e524e7 fix: fix tests 2026-07-15 20:23:33 +05:30
nityanandagohain
815dc7d88b Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-15 20:10:05 +05:30
nityanandagohain
f50d9199fe fix: address comments 2026-07-15 19:47:39 +05:30
nityanandagohain
31efe177a4 fix: address comments 2026-07-14 18:53:30 +05:30
nityanandagohain
d502d12ac3 fix: update openapi 2026-07-10 14:27:07 +05:30
nityanandagohain
bd9f15a716 fix: update integration test 2026-07-10 14:21:16 +05:30
nityanandagohain
813ef988c9 fix: edge cases and correct cost key 2026-07-10 12:06:34 +05:30
nityanandagohain
40e6799285 fix: add resource fingerprint cte 2026-07-10 00:36:25 +05:30
nityanandagohain
1caa60a3cd fix: cleanup and more tests 2026-07-09 23:54:05 +05:30
nityanandagohain
3f781f0083 fix: more cleanup 2026-07-09 12:39:01 +05:30
nityanandagohain
6aec05cf7a fix: more tests 2026-07-09 08:45:22 +05:30
nityanandagohain
683a52f35a fix: take perf into consideration 2026-07-09 08:45:22 +05:30
nityanandagohain
e924fa1e62 feat: support llm trace list and span list 2026-07-09 08:45:20 +05:30
295 changed files with 6563 additions and 18896 deletions

View File

@@ -53,8 +53,8 @@ jobs:
- queriermetrics
- querierscalar
- queriercommon
- querierai
- rawexportdata
- querierauthz
- role
- rootuser
- serviceaccount

View File

@@ -3039,7 +3039,6 @@ components:
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
nullable: true
type: array
type: object
DashboardtypesListSort:
@@ -6553,7 +6552,6 @@ components:
args:
items:
$ref: '#/components/schemas/Querybuildertypesv5FunctionArg'
nullable: true
type: array
name:
$ref: '#/components/schemas/Querybuildertypesv5FunctionName'
@@ -6724,7 +6722,6 @@ components:
functions:
items:
$ref: '#/components/schemas/Querybuildertypesv5Function'
nullable: true
type: array
having:
$ref: '#/components/schemas/Querybuildertypesv5Having'
@@ -6737,7 +6734,6 @@ components:
order:
items:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
nullable: true
type: array
type: object
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregation:
@@ -6745,7 +6741,6 @@ components:
aggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5LogAggregation'
nullable: true
type: array
cursor:
type: string
@@ -6756,12 +6751,10 @@ 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'
@@ -6778,17 +6771,14 @@ 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:
@@ -6806,7 +6796,6 @@ components:
aggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5MetricAggregation'
nullable: true
type: array
cursor:
type: string
@@ -6817,12 +6806,10 @@ 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'
@@ -6839,17 +6826,14 @@ 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:
@@ -6867,7 +6851,6 @@ components:
aggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
nullable: true
type: array
cursor:
type: string
@@ -6878,12 +6861,10 @@ 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'
@@ -6900,17 +6881,14 @@ 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:
@@ -6928,7 +6906,6 @@ components:
aggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
nullable: true
type: array
cursor:
type: string
@@ -6941,12 +6918,10 @@ 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'
@@ -6961,14 +6936,12 @@ 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'
@@ -7208,7 +7181,6 @@ components:
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
limit:
type: integer
@@ -7217,7 +7189,6 @@ components:
order:
items:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
nullable: true
type: array
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
@@ -7600,7 +7571,6 @@ components:
groupBy:
items:
type: string
nullable: true
type: array
newGroupEvalDelay:
type: string
@@ -7672,7 +7642,6 @@ components:
alertStates:
items:
$ref: '#/components/schemas/RuletypesAlertState'
nullable: true
type: array
enabled:
type: boolean
@@ -8596,6 +8565,7 @@ components:
TelemetrytypesSource:
enum:
- meter
- ai
type: string
TelemetrytypesTelemetryFieldKey:
properties:
@@ -24495,17 +24465,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- VIEWER
- tokenizer:
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- VIEWER
summary: Query range
tags:
- querier
@@ -24572,17 +24534,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- VIEWER
- tokenizer:
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- VIEWER
summary: Query range preview
tags:
- querier

View File

@@ -63,6 +63,5 @@
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
}

View File

@@ -88,6 +88,5 @@
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
}

View File

@@ -3489,9 +3489,9 @@ export enum Querybuildertypesv5FunctionNameDTO {
}
export interface Querybuildertypesv5FunctionDTO {
/**
* @type array,null
* @type array
*/
args?: Querybuildertypesv5FunctionArgDTO[] | null;
args?: Querybuildertypesv5FunctionArgDTO[];
name?: Querybuildertypesv5FunctionNameDTO;
}
@@ -3593,18 +3593,18 @@ export interface Querybuildertypesv5SecondaryAggregationDTO {
*/
expression?: string;
/**
* @type array,null
* @type array
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
groupBy?: Querybuildertypesv5GroupByKeyDTO[];
/**
* @type integer
*/
limit?: number;
limitBy?: Querybuildertypesv5LimitByDTO;
/**
* @type array,null
* @type array
*/
order?: Querybuildertypesv5OrderByDTO[] | null;
order?: Querybuildertypesv5OrderByDTO[];
stepInterval?: Querybuildertypesv5StepDTO;
}
@@ -3631,12 +3631,13 @@ export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQue
}
export enum TelemetrytypesSourceDTO {
meter = 'meter',
ai = 'ai',
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO {
/**
* @type array,null
* @type array
*/
aggregations?: Querybuildertypesv5LogAggregationDTO[] | null;
aggregations?: Querybuildertypesv5LogAggregationDTO[];
/**
* @type string
*/
@@ -3647,13 +3648,13 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
disabled?: boolean;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
* @type array
*/
functions?: Querybuildertypesv5FunctionDTO[] | null;
functions?: Querybuildertypesv5FunctionDTO[];
/**
* @type array,null
* @type array
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
groupBy?: Querybuildertypesv5GroupByKeyDTO[];
having?: Querybuildertypesv5HavingDTO;
/**
* @type string
@@ -3673,17 +3674,17 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
*/
offset?: number;
/**
* @type array,null
* @type array
*/
order?: Querybuildertypesv5OrderByDTO[] | null;
order?: Querybuildertypesv5OrderByDTO[];
/**
* @type array,null
* @type array
*/
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[] | null;
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[];
/**
* @type array,null
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
/**
* @enum logs
* @type string
@@ -3759,9 +3760,9 @@ export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQue
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTO {
/**
* @type array,null
* @type array
*/
aggregations?: Querybuildertypesv5MetricAggregationDTO[] | null;
aggregations?: Querybuildertypesv5MetricAggregationDTO[];
/**
* @type string
*/
@@ -3772,13 +3773,13 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
disabled?: boolean;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
* @type array
*/
functions?: Querybuildertypesv5FunctionDTO[] | null;
functions?: Querybuildertypesv5FunctionDTO[];
/**
* @type array,null
* @type array
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
groupBy?: Querybuildertypesv5GroupByKeyDTO[];
having?: Querybuildertypesv5HavingDTO;
/**
* @type string
@@ -3798,17 +3799,17 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
*/
offset?: number;
/**
* @type array,null
* @type array
*/
order?: Querybuildertypesv5OrderByDTO[] | null;
order?: Querybuildertypesv5OrderByDTO[];
/**
* @type array,null
* @type array
*/
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[] | null;
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[];
/**
* @type array,null
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
/**
* @enum metrics
* @type string
@@ -3834,9 +3835,9 @@ export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQue
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO {
/**
* @type array,null
* @type array
*/
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
aggregations?: Querybuildertypesv5TraceAggregationDTO[];
/**
* @type string
*/
@@ -3847,13 +3848,13 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
disabled?: boolean;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
* @type array
*/
functions?: Querybuildertypesv5FunctionDTO[] | null;
functions?: Querybuildertypesv5FunctionDTO[];
/**
* @type array,null
* @type array
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
groupBy?: Querybuildertypesv5GroupByKeyDTO[];
having?: Querybuildertypesv5HavingDTO;
/**
* @type string
@@ -3873,17 +3874,17 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
*/
offset?: number;
/**
* @type array,null
* @type array
*/
order?: Querybuildertypesv5OrderByDTO[] | null;
order?: Querybuildertypesv5OrderByDTO[];
/**
* @type array,null
* @type array
*/
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[] | null;
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[];
/**
* @type array,null
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
/**
* @enum traces
* @type string
@@ -4272,9 +4273,9 @@ export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboa
}
export interface DashboardtypesListPanelSpecDTO {
/**
* @type array,null
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO {
@@ -4344,9 +4345,9 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
*/
expression?: string;
/**
* @type array,null
* @type array
*/
functions?: Querybuildertypesv5FunctionDTO[] | null;
functions?: Querybuildertypesv5FunctionDTO[];
having?: Querybuildertypesv5HavingDTO;
/**
* @type string
@@ -4361,9 +4362,9 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
*/
name?: string;
/**
* @type array,null
* @type array
*/
order?: Querybuildertypesv5OrderByDTO[] | null;
order?: Querybuildertypesv5OrderByDTO[];
}
export enum Querybuildertypesv5QueryEnvelopeFormulaDTOType {
@@ -4380,9 +4381,9 @@ export interface Querybuildertypesv5QueryEnvelopeFormulaDTO {
export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
/**
* @type array,null
* @type array
*/
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
aggregations?: Querybuildertypesv5TraceAggregationDTO[];
/**
* @type string
*/
@@ -4397,13 +4398,13 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
expression?: string;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
* @type array
*/
functions?: Querybuildertypesv5FunctionDTO[] | null;
functions?: Querybuildertypesv5FunctionDTO[];
/**
* @type array,null
* @type array
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
groupBy?: Querybuildertypesv5GroupByKeyDTO[];
having?: Querybuildertypesv5HavingDTO;
/**
* @type string
@@ -4422,17 +4423,17 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
*/
offset?: number;
/**
* @type array,null
* @type array
*/
order?: Querybuildertypesv5OrderByDTO[] | null;
order?: Querybuildertypesv5OrderByDTO[];
/**
* @type string
*/
returnSpansFrom?: string;
/**
* @type array,null
* @type array
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
stepInterval?: Querybuildertypesv5StepDTO;
}
@@ -8675,9 +8676,9 @@ export interface RuletypesGettableTestRuleDTO {
export interface RuletypesRenotifyDTO {
/**
* @type array,null
* @type array
*/
alertStates?: RuletypesAlertStateDTO[] | null;
alertStates?: RuletypesAlertStateDTO[];
/**
* @type boolean
*/
@@ -8690,9 +8691,9 @@ export interface RuletypesRenotifyDTO {
export interface RuletypesNotificationSettingsDTO {
/**
* @type array,null
* @type array
*/
groupBy?: string[] | null;
groupBy?: string[];
/**
* @type string
*/

View File

@@ -11,8 +11,6 @@ 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'];
@@ -150,8 +148,6 @@ export function applyCheckboxToggle({
value,
checked,
isOnlyOrAllClicked,
previousState,
sectionType,
}: {
currentQuery: Query;
activeQueryIndex: number;
@@ -161,8 +157,6 @@ export function applyCheckboxToggle({
value: string;
checked: boolean;
isOnlyOrAllClicked: boolean;
previousState?: CheckedState;
sectionType?: SectionType;
}): Query {
const activeItems =
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
@@ -222,7 +216,6 @@ export function applyCheckboxToggle({
);
if (currentFilter) {
const runningOperator = currentFilter?.op;
switch (runningOperator) {
case 'in':
if (checked) {
@@ -253,29 +246,9 @@ export function applyCheckboxToggle({
});
}
} else if (!checked) {
// 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
// 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)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
@@ -302,38 +275,11 @@ export function applyCheckboxToggle({
}
break;
case 'nin':
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)
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.
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
@@ -346,6 +292,7 @@ export function applyCheckboxToggle({
return item;
});
} else {
// in case of not an array make it one!
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
@@ -357,9 +304,8 @@ export function applyCheckboxToggle({
return item;
});
}
} else {
// Remove from NOT IN when value IS in filter and checked=true
// (user wants to include this value back)
} else if (checked) {
// opposite of above!
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
@@ -400,7 +346,6 @@ export function applyCheckboxToggle({
}
}
break;
}
case '=':
if (checked) {
const newFilter = {
@@ -444,11 +389,10 @@ export function applyCheckboxToggle({
}
}
} else {
// No filter for this key - all are visually selected.
// checked=true → user wants to select (IN), checked=false → exclude (NOT IN)
// case - when there is no filter for the current key that means all are selected right now.
const newFilterItem: TagFilterItem = {
id: uuid(),
op: checked ? getOperatorValue(OPERATORS.IN) : getNotInOperator(source),
op: getNotInOperator(source),
key: filter.attributeKey,
value,
};

View File

@@ -10,8 +10,6 @@ import {
applyCheckboxToggle,
clearFilterFromQuery,
} from './checkboxFilterQuery';
import { CheckedState } from '../../types';
import { SectionType } from './v2/itemRules';
interface UseCheckboxFilterActionsProps {
filter: IQuickFiltersConfig;
@@ -26,8 +24,6 @@ interface UseCheckboxFilterActionsReturn {
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
previousState?: CheckedState,
sectionType?: SectionType,
) => void;
onClear: () => void;
}
@@ -57,8 +53,6 @@ function useCheckboxFilterActions({
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
previousState?: CheckedState,
sectionType?: SectionType,
): void => {
dispatch(
applyCheckboxToggle({
@@ -70,8 +64,6 @@ function useCheckboxFilterActions({
value,
checked,
isOnlyOrAllClicked,
previousState,
sectionType,
}),
);
};

View File

@@ -1,103 +0,0 @@
.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;
}

View File

@@ -1,126 +0,0 @@
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',
);
}

View File

@@ -1,241 +0,0 @@
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>
);
}

View File

@@ -1,33 +0,0 @@
.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;
}

View File

@@ -1,62 +0,0 @@
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>
);
}

View File

@@ -1,106 +0,0 @@
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>
);
}

View File

@@ -1,164 +0,0 @@
.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);
}

View File

@@ -1,118 +0,0 @@
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>
);
}

View File

@@ -1,29 +0,0 @@
.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;
}

View File

@@ -1,33 +0,0 @@
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>
);
}

View File

@@ -1,302 +0,0 @@
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();
});
});
});

View File

@@ -1,768 +0,0 @@
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();
});
});
});

View File

@@ -1,499 +0,0 @@
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();
});
});
});

View File

@@ -1,207 +0,0 @@
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);
});
});
});

View File

@@ -1,156 +0,0 @@
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();
});
});
});

View File

@@ -1,297 +0,0 @@
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();
});
});
});

View File

@@ -1,131 +0,0 @@
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');
});
});
});

View File

@@ -1,382 +0,0 @@
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);
});
});
});

View File

@@ -1,118 +0,0 @@
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 };
});
}

View File

@@ -1,61 +0,0 @@
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 };
}

View File

@@ -1,97 +0,0 @@
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 };
}

View File

@@ -1,152 +0,0 @@
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 };
}

View File

@@ -1,36 +0,0 @@
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]);
}

View File

@@ -32,7 +32,6 @@ 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';
@@ -52,7 +51,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
signal,
showFilterCollapse = true,
showQueryName = true,
useFieldApis,
} = props;
const { user } = useAppContext();
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
@@ -299,45 +297,21 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
{filterConfig.map((filter) => {
switch (filter.type) {
case FiltersType.CHECKBOX:
return useFieldApis ? (
<CheckboxV2
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
useFieldApis={useFieldApis}
/>
) : (
return (
<Checkbox
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>
);
case FiltersType.DURATION:
return (
<Duration
key={filter.attributeKey.key}
filter={filter}
onFilterChange={onFilterChange}
/>
);
return <Duration filter={filter} onFilterChange={onFilterChange} />;
case FiltersType.SLIDER:
return <Slider key={filter.attributeKey.key} />;
return <Slider />;
// eslint-disable-next-line sonarjs/no-duplicated-branches
default:
return useFieldApis ? (
<CheckboxV2
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
useFieldApis={useFieldApis}
/>
) : (
return (
<Checkbox
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
@@ -407,5 +381,4 @@ QuickFilters.defaultProps = {
config: [],
showFilterCollapse: true,
showQueryName: true,
useFieldApis: undefined,
};

View File

@@ -26,11 +26,6 @@ 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;
@@ -51,7 +46,6 @@ export interface IQuickFiltersProps {
className?: string;
showFilterCollapse?: boolean;
showQueryName?: boolean;
useFieldApis?: QuickFilterCheckboxUseFieldApis;
}
export enum QuickFiltersSource {
@@ -62,19 +56,3 @@ 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;
};

View File

@@ -57,7 +57,4 @@ export enum QueryParams {
isTestAlert = 'isTestAlert',
yAxisUnit = 'yAxisUnit',
ruleName = 'ruleName',
matchType = 'matchType',
compareOp = 'compareOp',
evaluationWindowPreset = 'evaluationWindowPreset',
}

View File

@@ -1,5 +1,3 @@
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;

View File

@@ -1,110 +0,0 @@
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');
});
});

View File

@@ -1,71 +0,0 @@
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();
});
});

View File

@@ -1,63 +0,0 @@
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;
}
}

View File

@@ -23,13 +23,10 @@ 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,
@@ -126,13 +123,9 @@ 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) {
@@ -175,43 +168,34 @@ export function CreateAlertProvider(
},
});
if (urlPrefill.thresholds) {
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,
action: {
type: 'SET_THRESHOLDS',
payload: urlPrefill.thresholds,
},
});
}
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.matchType) {
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,
action: {
type: 'SET_MATCH_TYPE',
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',
},
});
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,
action: {
type: 'SET_MATCH_TYPE',
payload: AlertThresholdMatchType.IN_TOTAL,
},
});
}
if (ruleNameFromURL && !ruleNameAppliedRef.current) {
@@ -235,7 +219,7 @@ export function CreateAlertProvider(
},
});
}
}, [alertType, urlPrefill, ruleNameFromURL, yAxisUnitFromURL]);
}, [alertType, thresholdsFromURL, ruleNameFromURL, yAxisUnitFromURL]);
useEffect(() => {
if (isEditMode && initialAlertState) {

View File

@@ -1,54 +0,0 @@
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),
),
};
}

View File

@@ -238,12 +238,67 @@ export function getAdvancedOptionsStateFromAlertDef(
};
}
// 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';
// 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;
}
}
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,

View File

@@ -53,7 +53,6 @@ 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';
@@ -67,6 +66,7 @@ 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,19 +1031,26 @@ function ExplorerOptions({
/>
</div>
</Modal>
<ExportPanelContainer
<Modal
footer={null}
onOk={onCancel(false)}
onCancel={onCancel(false)}
open={isExport}
onClose={onCancel(false)}
query={isOneChartPerQuery ? queryToExport : query}
isLoading={isLoading}
onExport={(dashboard, isNewDashboard): void => {
if (isOneChartPerQuery && queryToExport) {
onExport(dashboard, isNewDashboard, queryToExport);
} else {
onExport(dashboard, isNewDashboard);
}
}}
/>
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>
</div>
);
}
@@ -1051,7 +1058,7 @@ function ExplorerOptions({
export interface ExplorerOptionsProps {
isLoading?: boolean;
onExport: (
dashboard: ExportDashboard | null,
dashboard: Dashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void;

View File

@@ -1,7 +1,6 @@
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';
@@ -81,7 +80,7 @@ const renderExplorerOptionWrapper = (
isLoading: false,
onExport: jest.fn() as jest.MockedFunction<
(
dashboard: ExportDashboard | null,
dashboard: Dashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
@@ -151,7 +150,7 @@ describe('ExplorerOptionWrapper', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const testOnExport = jest.fn() as jest.MockedFunction<
(
dashboard: ExportDashboard | null,
dashboard: Dashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
@@ -180,16 +179,15 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Click the "New dashboard" button
const newDashboardButton = screen.getByTestId('export-panel-new-dashboard');
// Click the "New Dashboard" button
const newDashboardButton = screen.getByRole('button', {
name: /new dashboard/i,
});
await user.click(newDashboardButton);
// Wait for the API call to complete and onExport to be called
await waitFor(() => {
expect(testOnExport).toHaveBeenCalledWith(
{ id: NEW_DASHBOARD_ID, title: TEST_DASHBOARD_TITLE },
true,
);
expect(testOnExport).toHaveBeenCalledWith(mockNewDashboard, true);
});
});
@@ -197,7 +195,7 @@ describe('ExplorerOptionWrapper', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const testOnExport = jest.fn() as jest.MockedFunction<
(
dashboard: ExportDashboard | null,
dashboard: Dashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
@@ -231,12 +229,13 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Wait for the dashboard select dropdown to render inside the dialog
const modal = screen.getByRole('dialog');
// Wait for dashboards to load and then click on the dashboard select dropdown
await waitFor(() => {
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
});
// Get the modal and find the dashboard select dropdown within it
const modal = screen.getByRole('dialog');
const dashboardSelect = modal.querySelector(
'[role="combobox"]',
) as HTMLElement;
@@ -252,21 +251,19 @@ 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(() => {
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
const exportButton = screen.getByRole('button', { name: /export/i });
expect(exportButton).not.toBeDisabled();
});
// Click the export button
const exportButton = screen.getByTestId('export-panel-export');
// Click the Export button
const exportButton = screen.getByRole('button', { name: /export/i });
await user.click(exportButton);
// Wait for onExport to be called with the selected dashboard
await waitFor(() => {
expect(testOnExport).toHaveBeenCalledWith(
{ id: 'dashboard-1', title: 'Dashboard 1' },
false,
);
expect(testOnExport).toHaveBeenCalledWith(mockDashboard1, false);
});
});
@@ -287,7 +284,7 @@ describe('ExplorerOptionWrapper', () => {
// Create a real handleExport function similar to LogsExplorerViews
// This should NOT call useUpdateDashboard (as per PR #8029)
const handleExport = (dashboard: ExportDashboard | null): void => {
const handleExport = (dashboard: Dashboard | null): void => {
if (!dashboard) {
return;
}
@@ -329,12 +326,13 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Wait for the dashboard select dropdown to render inside the dialog
const modal = screen.getByRole('dialog');
// Wait for dashboards to load and then click on the dashboard select dropdown
await waitFor(() => {
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
});
// Get the modal and find the dashboard select dropdown within it
const modal = screen.getByRole('dialog');
const dashboardSelect = modal.querySelector(
'[role="combobox"]',
) as HTMLElement;
@@ -350,13 +348,14 @@ 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(() => {
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
const exportButton = screen.getByRole('button', { name: /export/i });
expect(exportButton).not.toBeDisabled();
});
// Click the export button
const exportButton = screen.getByTestId('export-panel-export');
// Click the Export button
const exportButton = screen.getByRole('button', { name: /export/i });
await user.click(exportButton);
// Wait for the handleExport function to be called and navigation to occur
@@ -376,7 +375,7 @@ describe('ExplorerOptionWrapper', () => {
it('should not show export buttons when component is disabled', () => {
const testOnExport = jest.fn() as jest.MockedFunction<
(
dashboard: ExportDashboard | null,
dashboard: Dashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void

View File

@@ -1,74 +0,0 @@
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;

View File

@@ -1,42 +0,0 @@
.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%;
}

View File

@@ -1,159 +1,127 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { useMutation } from 'react-query';
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { useCreateExportDashboard } from 'hooks/dashboard/useCreateExportDashboard';
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 {
ExportDashboard,
useExportDashboards,
} from 'hooks/dashboard/useExportDashboards';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
DashboardSelect,
NewDashboardButton,
SelectWrapper,
Title,
Wrapper,
} from './styles';
import { filterOptions, getSelectOptions } from './utils';
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']);
// 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 [dashboardId, setDashboardId] = useState<string | null>(null);
const {
dashboards,
data,
isLoading: isAllDashboardsLoading,
isFetching: isDashboardsFetching,
} = useExportDashboards(searchText);
refetch,
} = useGetAllDashboard();
const { create: createNewDashboard, isLoading: createDashboardLoading } =
useCreateExportDashboard({
title: t('new_dashboard_title', { ns: 'dashboard' }),
onCreated: (dashboard) => onExport(dashboard, true),
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);
},
});
// Reset on close so each open starts fresh (the dialog stays mounted).
useEffect(() => {
if (!open) {
setSelectedDashboard(null);
setSearchText('');
}
}, [open]);
const handleSelect = useCallback(
(dashboardId: string): void => {
setSelectedDashboard(
dashboards.find(({ id }) => id === dashboardId) ?? null,
);
},
[dashboards],
);
const options = useMemo(() => getSelectOptions(data?.data || []), [data]);
const handleExportClick = useCallback((): void => {
onExport(selectedDashboard, false);
}, [selectedDashboard, onExport]);
const currentSelectedDashboard = data?.data?.find(
({ id }) => id === dashboardId,
);
const isExportDisabled =
isAllDashboardsLoading || !selectedDashboard || isLoading;
onExport(currentSelectedDashboard || null, false);
}, [data, dashboardId, onExport]);
const handleSelect = useCallback(
(selectedDashboardId: string): void => {
setDashboardId(selectedDashboardId);
},
[setDashboardId],
);
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 isDashboardLoading = isAllDashboardsLoading || createDashboardLoading;
const isDisabled =
isAllDashboardsLoading || !options?.length || !dashboardId || isLoading;
return (
<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>
<Wrapper direction="vertical">
<Title>Export Panel</Title>
<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>
<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>
);
}
ExportPanelContainer.defaultProps = {
isLoading: false,
};
export default ExportPanelContainer;

View File

@@ -0,0 +1,49 @@
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;

View File

@@ -0,0 +1,34 @@
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;
`;

View File

@@ -1,10 +1,16 @@
import { SelectProps } from 'antd';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { Dashboard } from 'types/api/dashboard/getAll';
export const getSelectOptions = (
data: ExportDashboard[],
): SelectProps['options'] =>
data.map(({ id, title }) => ({
label: title,
export const getSelectOptions = (data: Dashboard[]): SelectProps['options'] =>
data.map(({ id, data }) => ({
label: data.title,
value: id,
}));
export const filterOptions: SelectProps['filterOption'] = (
input,
options,
): boolean =>
(options?.label?.toString() ?? '')
?.toLowerCase()
.includes(input.toLowerCase());

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
@@ -23,10 +23,7 @@ import K8sBaseDetails, {
import { K8sBaseList } from 'container/InfraMonitoringK8sV2/Base/K8sBaseList';
import StatusFilter from 'container/InfraMonitoringHostsV2/StatusFilter';
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
import {
InfraMonitoringEntity,
METRIC_NAMESPACE_BY_ENTITY,
} from 'container/InfraMonitoringK8sV2/constants';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useAppContext } from 'providers/App/App';
@@ -50,7 +47,6 @@ 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);
@@ -59,17 +55,6 @@ 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;
@@ -225,12 +210,6 @@ function Hosts(): JSX.Element {
source={QuickFiltersSource.INFRA_MONITORING}
config={getHostsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={{
metricNamespace:
METRIC_NAMESPACE_BY_ENTITY[InfraMonitoringEntity.HOSTS],
startUnixMilli,
endUnixMilli,
}}
/>
</div>
)}

View File

@@ -3,10 +3,7 @@ import * as Sentry from '@sentry/react';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import {
QuickFilterCheckboxUseFieldApis,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -39,9 +36,7 @@ import {
GetPodsQuickFiltersConfig,
GetStatefulsetsQuickFiltersConfig,
GetVolumesQuickFiltersConfig,
InfraMonitoringEntity,
K8sCategories,
METRIC_NAMESPACE_BY_ENTITY,
} from './constants';
import K8sDaemonSetsList from './DaemonSets/K8sDaemonSetsList';
import K8sDeploymentsList from './Deployments/K8sDeploymentsList';
@@ -61,7 +56,6 @@ 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);
@@ -75,31 +69,6 @@ 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;
@@ -300,7 +269,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
source={QuickFiltersSource.INFRA_MONITORING}
config={selectedCategoryConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={selectedCategoryUseFieldApis}
/>
)}
</div>

View File

@@ -48,8 +48,6 @@ 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';
@@ -909,7 +907,6 @@ 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)}&${
@@ -918,9 +915,7 @@ function MultiIngestionSettings(): JSX.Element {
QueryParams.ruleName
}=${encodeURIComponent(ruleName)}&${
QueryParams.yAxisUnit
}=${encodeURIComponent(yAxisUnit)}&${QueryParams.matchType}=${
AlertThresholdMatchType.IN_TOTAL
}&${QueryParams.evaluationWindowPreset}=${EvaluationWindowPreset.METER}`;
}=${encodeURIComponent(yAxisUnit)}`;
history.push(URL);
};

View File

@@ -1,34 +1,18 @@
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';
interface AttributeMappingsTabProps {
editor: AttributeMappingEditor;
onEditGroup: (group: DraftGroup) => void;
onAddGroup: () => void;
}
function AttributeMappingsTab(): JSX.Element {
const store = useAttributeMappingStore();
// "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">
{editor.isError ? (
{store.isError ? (
<div className={styles.pageError} role="alert">
Failed to load mapping groups. Please try again.
</div>
) : (
<MappingsTable
editor={editor}
onEditGroup={onEditGroup}
onAddGroup={onAddGroup}
/>
<MappingsTable store={store} />
)}
</div>
);

View File

@@ -2,19 +2,9 @@ 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,
@@ -24,9 +14,7 @@ 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(
@@ -51,53 +39,6 @@ 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.
@@ -110,7 +51,7 @@ describe('AttributeMappingsTab (integration)', () => {
it('renders no error banner on a successful load', async () => {
setupGroups();
render(<AttributeMappingsTabWithStore />);
render(<AttributeMappingsTab />);
await waitFor(() =>
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
@@ -122,7 +63,7 @@ describe('AttributeMappingsTab (integration)', () => {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
);
render(<AttributeMappingsTabWithStore />);
render(<AttributeMappingsTab />);
await expect(screen.findByRole('alert')).resolves.toHaveTextContent(
'Failed to load mapping groups. Please try again.',
@@ -131,7 +72,7 @@ describe('AttributeMappingsTab (integration)', () => {
it('shows the empty state when there are no groups', async () => {
setupGroups([]);
render(<AttributeMappingsTabWithStore />);
render(<AttributeMappingsTab />);
await expect(
screen.findByTestId('mapper-groups-empty'),
@@ -140,96 +81,58 @@ describe('AttributeMappingsTab (integration)', () => {
it('renders each group header row with its name, condition count and status', async () => {
setupGroups();
render(<AttributeMappingsTabWithStore />);
render(<AttributeMappingsTab />);
// Condition filters are no longer shown inline as clauses — the header
// 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');
// 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.
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();
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();
// 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();
// 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();
});
it("stages a toggle of the group's enabled state via the header switch", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
it('renders the group enable state as a read-only switch', async () => {
setupGroups();
render(<AttributeMappingsTabWithStore />);
render(<AttributeMappingsTab />);
// 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();
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();
expect(toggle).toBeDisabled();
});
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(<AttributeMappingsTabWithStore />);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
// The clickable Collapse header owns the expanded state; find it by the
// aria-expanded contract rather than any framework-internal class.
// The toggle is the antd Collapse header, which owns the expanded state.
const header = screen
.getByTestId('group-expand-group-1')
.closest('[aria-expanded]') as HTMLElement;
.closest('.ant-collapse-header') as HTMLElement;
expect(header).toHaveAttribute('aria-expanded', 'false');
await expandGroup(user);
@@ -252,7 +155,7 @@ describe('AttributeMappingsTab (integration)', () => {
setupMappers([
makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model', enabled: true }),
]);
render(<AttributeMappingsTabWithStore />);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
// Mappers are not fetched until the row is expanded.
@@ -269,12 +172,29 @@ 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();
@@ -283,7 +203,7 @@ describe('AttributeMappingsTab (integration)', () => {
res(ctx.status(500)),
),
);
render(<AttributeMappingsTabWithStore />);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
@@ -297,7 +217,7 @@ describe('AttributeMappingsTab (integration)', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([]);
render(<AttributeMappingsTabWithStore />);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
@@ -323,7 +243,7 @@ describe('AttributeMappingsTab (integration)', () => {
},
}),
]);
render(<AttributeMappingsTabWithStore />);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
@@ -335,7 +255,7 @@ describe('AttributeMappingsTab (integration)', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([makeMapper({ id: 'mapper-1', config: { sources: [] } })]);
render(<AttributeMappingsTabWithStore />);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
@@ -344,167 +264,4 @@ 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();
});
});
});

View File

@@ -1,53 +0,0 @@
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;

View File

@@ -1,32 +0,0 @@
.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;
}

View File

@@ -1,61 +0,0 @@
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;

View File

@@ -7,7 +7,6 @@
.groupName {
color: var(--l1-foreground);
font-weight: var(--font-weight-semibold);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -18,15 +17,3 @@
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);
}

View File

@@ -1,14 +1,10 @@
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: DraftGroup;
group: MappingGroup;
}
function GroupHeader({ group }: GroupHeaderProps): JSX.Element {
@@ -17,33 +13,22 @@ function GroupHeader({ group }: GroupHeaderProps): JSX.Element {
return (
<div
className={styles.groupHeaderLabel}
data-testid={`group-expand-${group.localId}`}
data-testid={`group-expand-${group.id}`}
>
<Typography.Text
as="span"
className={styles.groupName}
testId={`group-name-${group.localId}`}
testId={`group-name-${group.id}`}
>
{group.name}
</Typography.Text>
<TooltipSimple
title={
<ConditionsTooltip
attributes={group.attributes}
resource={group.resource}
/>
}
side="bottom"
align="start"
<Typography.Text
as="span"
className={styles.groupCount}
testId={`group-condition-count-${group.id}`}
>
<span
className={styles.conditionCount}
data-testid={`group-condition-count-${group.localId}`}
>
· {conditionCount} {conditionCount === 1 ? 'condition' : 'conditions'}
<Info size={12} className={styles.conditionInfoIcon} />
</span>
</TooltipSimple>
· {conditionCount} {conditionCount === 1 ? 'condition' : 'conditions'}
</Typography.Text>
</div>
);
}

View File

@@ -1,22 +1,13 @@
import { Switch } from '@signozhq/ui/switch';
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
import GroupActionsMenu from '../GroupActionsMenu/GroupActionsMenu';
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
import styles from './GroupHeaderActions.module.scss';
interface GroupHeaderActionsProps {
group: DraftGroup;
onToggle: (localId: string, enabled: boolean) => void;
onEdit: (group: DraftGroup) => void;
onRemove: (localId: string) => void;
group: MappingGroup;
}
function GroupHeaderActions({
group,
onToggle,
onEdit,
onRemove,
}: GroupHeaderActionsProps): JSX.Element {
function GroupHeaderActions({ group }: GroupHeaderActionsProps): JSX.Element {
return (
<div
className={styles.actions}
@@ -24,10 +15,10 @@ function GroupHeaderActions({
>
<Switch
value={group.enabled}
onChange={(checked): void => onToggle(group.localId, checked)}
testId={`group-enabled-${group.localId}`}
// 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}`}
/>
<GroupActionsMenu group={group} onEdit={onEdit} onRemove={onRemove} />
</div>
);
}

View File

@@ -8,26 +8,7 @@
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);
}

View File

@@ -1,20 +1,14 @@
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 {
DraftGroup,
DraftMapper,
Mapper,
MappingGroup,
Mapping,
} from 'container/LLMObservability/AttributeMapping/types';
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
import { buildMappingsFromListResponse } from 'container/LLMObservability/AttributeMapping/utils';
import { COLUMN_COUNT } from '../constants';
import MapperRow from '../MapperRow/MapperRow';
import MapperRowSkeleton from '../MapperRow/MapperRowSkeleton';
import MappingsColgroup from '../MappingsColgroup/MappingsColgroup';
import MapperRow, { MapperRowSkeleton } from '../MapperRow';
import MappingsColgroup from '../MappingsColgroup';
import styles from './GroupMappers.module.scss';
const MAPPER_SKELETON_ROWS = 1;
@@ -25,135 +19,72 @@ const STATE_ROW_MOTION = {
transition: { duration: 0.18, ease: 'easeOut' },
} as const;
interface GroupMappersProps {
group: DraftGroup;
editor: AttributeMappingEditor;
onAddMapper: (groupLocalId: string) => void;
onEditMapper: (groupLocalId: string, mapper: DraftMapper) => void;
interface StateRowProps {
groupId: string;
}
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 } },
);
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}`} />
),
);
const errorRow = (
<motion.tr
key="error"
className={styles.mapperStateRow}
{...STATE_ROW_MOTION}
>
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-${group.localId}`}
data-testid={`mappers-error-${groupId}`}
>
<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>
Failed to load mappings. Please try again.
</td>
</motion.tr>
);
}
const emptyRow = (
<motion.tr
key="empty"
className={styles.mapperStateRow}
{...STATE_ROW_MOTION}
>
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-${group.localId}`}
data-testid={`mappers-empty-${groupId}`}
>
<span className={styles.stateContent}>
<ArrowLeftRight size={14} />
<Typography.Text as="span" size="base" color="muted">
No mappings in this group yet.
</Typography.Text>
</span>
No mappings in this group yet.
</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>
interface GroupMappersProps {
group: MappingGroup;
}
function GroupMappers({ group }: GroupMappersProps): JSX.Element {
const {
data: mappers = [],
isLoading,
isError,
} = useListSpanMappers<Mapping[]>(
{
groupId: group.id,
},
{
query: {
refetchOnMount: false,
select: buildMappingsFromListResponse,
},
},
);
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];
if (isError) {
rows = [<ErrorRow key="error" groupId={group.id} />];
} else if (isLoading) {
rows = Array.from({ length: MAPPER_SKELETON_ROWS }).map((_, index) => (
<MapperRowSkeleton key={`mapper-skeleton-${index}`} />
));
} else if (mappers.length === 0) {
rows = [<EmptyRow key="empty" groupId={group.id} />];
} else {
rows = [...mapperRows, addMapperRow];
rows = mappers.map((mapper, index) => (
<MapperRow key={mapper.id} mapper={mapper} index={index} />
));
}
return (

View File

@@ -1,53 +0,0 @@
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;

View File

@@ -11,22 +11,11 @@
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;
@@ -40,7 +29,7 @@
}
}
.actionsCell {
.statusCell {
text-align: right;
}
@@ -58,10 +47,6 @@
gap: var(--spacing-3);
}
.sourceChip {
font-family: var(--font-mono, monospace);
}
.sourceChipText {
display: block;
max-width: 220px;

View File

@@ -5,8 +5,7 @@ import { SpantypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas'
import cx from 'classnames';
import { motion } from 'motion/react';
import { DraftMapper } from 'container/LLMObservability/AttributeMapping/types';
import MapperActionsMenu from '../MapperActionsMenu/MapperActionsMenu';
import { Mapping } from 'container/LLMObservability/AttributeMapping/types';
import styles from './MapperRow.module.scss';
const MAX_VISIBLE_SOURCES = 3;
@@ -16,20 +15,11 @@ const MAX_STAGGERED_ROWS = 6;
const STAGGER_STEP = 0.03;
interface MapperRowProps {
mapper: DraftMapper;
mapper: Mapping;
index: number;
onEdit: (mapper: DraftMapper) => void;
onRemove: (localId: string) => void;
onToggle: (localId: string, enabled: boolean) => void;
}
function MapperRow({
mapper,
index,
onEdit,
onRemove,
onToggle,
}: MapperRowProps): JSX.Element {
function MapperRow({ mapper, index }: MapperRowProps): JSX.Element {
const sources = mapper.sources ?? [];
const visibleSources = sources.slice(0, MAX_VISIBLE_SOURCES);
const remainingSources = sources.length - visibleSources.length;
@@ -37,7 +27,7 @@ function MapperRow({
return (
<motion.tr
className={styles.mapperRow}
data-testid={`mapper-row-${mapper.localId}`}
data-testid={`mapper-row-${mapper.id}`}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{
@@ -47,38 +37,22 @@ function MapperRow({
>
<td className={cx(styles.cell, styles.targetCell)}>
<Typography.Text
weight="semibold"
truncate={1}
title={mapper.name}
className={styles.targetName}
data-testid={`mapper-target-${mapper.localId}`}
data-testid={`mapper-target-${mapper.id}`}
>
{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.localId}`}
>
<span className={styles.muted} data-testid={`mapper-sources-${mapper.id}`}>
</span>
) : (
<div
className={styles.sources}
data-testid={`mapper-sources-${mapper.localId}`}
data-testid={`mapper-sources-${mapper.id}`}
>
{visibleSources.map((source) => (
<Badge
@@ -100,14 +74,25 @@ function MapperRow({
</div>
)}
</td>
<td className={cx(styles.cell, styles.actionsCell)}>
<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)}>
<div className={styles.rowActions}>
<Switch
value={mapper.enabled}
onChange={(checked): void => onToggle(mapper.localId, checked)}
testId={`mapper-enabled-${mapper.localId}`}
disabled
testId={`mapper-enabled-${mapper.id}`}
/>
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
</div>
</td>
</motion.tr>

View File

@@ -8,7 +8,6 @@ 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}>
@@ -16,7 +15,10 @@ function MapperRowSkeleton(): JSX.Element {
<Skeleton.Button active size="small" style={{ width: 56 }} />
</div>
</td>
<td className={cx(styles.skeletonCell, styles.actionsCell)}>
<td className={styles.skeletonCell}>
<Skeleton.Button active size="small" style={{ width: 72 }} />
</td>
<td className={cx(styles.skeletonCell, styles.statusCell)}>
<div className={styles.rowActions}>
<Skeleton.Button active size="small" shape="round" />
</div>

View File

@@ -2,6 +2,10 @@
width: 32%;
}
.colActions {
width: 176px;
.colWritesTo {
width: 140px;
}
.colStatus {
width: 120px;
}

View File

@@ -5,7 +5,8 @@ function MappingsColgroup(): JSX.Element {
<colgroup>
<col className={styles.colTarget} />
<col />
<col className={styles.colActions} />
<col className={styles.colWritesTo} />
<col className={styles.colStatus} />
</colgroup>
);
}

View File

@@ -1,16 +1,3 @@
.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;
@@ -18,7 +5,7 @@
}
.headerRow {
border-top: 1px solid var(--l2-border);
border-bottom: 1px solid var(--l2-border);
}
.headerCell {
@@ -86,12 +73,7 @@
}
}
.tableState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-3);
.tableEmpty {
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--l3-foreground);

View File

@@ -1,96 +1,30 @@
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 { useState } from 'react';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { Collapse, type CollapseProps, Skeleton } from 'antd';
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 { 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 styles from './MappingsTable.module.scss';
const SKELETON_ROW_COUNT = 3;
interface MappingsTableProps {
editor: AttributeMappingEditor;
onEditGroup: (group: DraftGroup) => void;
onAddGroup: () => void;
store: AttributeMappingStore;
}
function MappingsTable({
editor,
onEditGroup,
onAddGroup,
}: MappingsTableProps): JSX.Element {
function MappingsTable({ store }: MappingsTableProps): JSX.Element {
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
const [targetGroupId, setTargetGroupId] = useState<string | null>(null);
const drawer = useMapperFormDrawer();
const { upsertMapper, removeMapper } = editor;
const isEmpty = !store.isLoading && store.groups.length === 0;
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,
const items: CollapseProps['items'] = store.groups.map((group) => ({
key: group.id,
label: <GroupHeader group={group} />,
extra: (
<GroupHeaderActions
group={group}
onToggle={editor.toggleGroup}
onEdit={onEditGroup}
onRemove={editor.removeGroup}
/>
),
children: (
<GroupMappers
group={group}
editor={editor}
onAddMapper={handleAddMapper}
onEditMapper={handleEditMapper}
/>
),
extra: <GroupHeaderActions group={group} />,
children: <GroupMappers group={group} />,
}));
const skeletonBanners = (
@@ -111,79 +45,48 @@ function MappingsTable({
</div>
<div className={styles.skeletonGroupRight}>
<Skeleton.Button active size="small" shape="round" />
<Skeleton.Avatar active size={16} shape="square" />
</div>
</div>
))}
</div>
);
return (
<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>
if (isEmpty) {
return (
<div className={styles.tableEmpty} data-testid="mapper-groups-empty">
No mapping groups yet.
</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>
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 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}
<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>

View File

@@ -1 +1 @@
export const COLUMN_COUNT = 3;
export const COLUMN_COUNT = 4;

View File

@@ -0,0 +1,33 @@
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,
};
}

View File

@@ -1,18 +1,6 @@
.llmObservabilityAttributeMapping {
display: flex;
flex-direction: column;
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);
gap: var(--spacing-8);
padding: var(--spacing-0);
}

View File

@@ -1,45 +1,17 @@
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/AttributeMappingHeader';
import AttributeMappingHeader from './components/AttributeMappingHeader';
import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
import DiscardChangesDialog from './components/DiscardChangesDialog/DiscardChangesDialog';
import GroupFormDrawer from './components/GroupFormDrawer/GroupFormDrawer';
import styles from './LLMObservabilityAttributeMapping.module.scss';
import { useAttributeMappingEditor } from './hooks/useAttributeMappingEditor';
import { useGroupFormDrawer } from './components/GroupFormDrawer/hooks/useGroupFormDrawer';
const noop = (): void => undefined;
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
editor={editor}
onEditGroup={groupDrawer.openForEdit}
onAddGroup={groupDrawer.openForAdd}
/>
),
label: 'Attribute mappings',
children: <AttributeMappingsTab />,
},
{
key: 'test',
@@ -56,39 +28,17 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
data-testid="llm-observability-attribute-mapping-page"
>
<AttributeMappingHeader
isDirty={editor.isDirty}
isSaving={editor.isSaving}
onDiscard={discardConfirm.request}
onSave={editor.save}
isDirty={false}
isSaving={false}
onDiscard={noop}
onSave={noop}
/>
{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>
);
}

View File

@@ -1,5 +1,5 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { render, screen } 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,44 +64,4 @@ 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();
});
});

View File

@@ -2,8 +2,7 @@
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-left: var(--spacing-2);
margin-top: var(--spacing-4);
gap: var(--spacing-8);
}
.pageHeaderActions {

View File

@@ -0,0 +1 @@
export { default } from './AttributeMappingHeader';

View File

@@ -1,56 +0,0 @@
import { AlertDialog } from '@signozhq/ui/alert-dialog';
import { Button } from '@signozhq/ui/button';
import { Trash2, X } from '@signozhq/icons';
interface DiscardChangesDialogProps {
open: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function DiscardChangesDialog({
open,
onConfirm,
onCancel,
}: DiscardChangesDialogProps): JSX.Element {
return (
<AlertDialog
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onCancel();
}
}}
width="narrow"
title="Discard unsaved changes?"
titleIcon={<Trash2 size={16} />}
footer={
<>
<Button
variant="solid"
color="secondary"
onClick={onCancel}
prefix={<X size={12} />}
testId="discard-changes-cancel-btn"
>
Keep editing
</Button>
<Button
variant="solid"
color="destructive"
onClick={onConfirm}
prefix={<Trash2 size={12} />}
testId="discard-changes-confirm-btn"
>
Discard changes
</Button>
</>
}
>
This reverts every unsaved group and mapping change back to the last saved
state. This action cannot be undone.
</AlertDialog>
);
}
export default DiscardChangesDialog;

View File

@@ -1,39 +0,0 @@
.groupForm {
display: flex;
flex-direction: column;
gap: var(--spacing-10);
padding: var(--spacing-2) 0;
}
.groupFormField {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.groupFormFieldRow {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.groupFormLabel {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.groupFormHint {
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.groupFormFooter {
display: flex;
align-items: center;
justify-content: flex-end;
width: 100%;
gap: var(--spacing-4);
}

View File

@@ -1,117 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import ConditionKeyList from './components/ConditionKeyList/ConditionKeyList';
import styles from './GroupFormDrawer.module.scss';
import { FieldContext, GroupDraft, MapperDraftMode } from '../../types';
import { isGroupDraftValid } from '../../utils';
interface GroupFormDrawerProps {
isOpen: boolean;
mode: MapperDraftMode;
draft: GroupDraft;
setDraft: (next: GroupDraft) => void;
onClose: () => void;
onSave: () => void;
}
function GroupFormDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
}: GroupFormDrawerProps): JSX.Element {
const isEdit = mode === 'edit';
const isValid = isGroupDraftValid(draft);
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
title={isEdit ? 'Edit group' : 'New group'}
subTitle="A group gates which spans its mappings run on"
width="wide"
testId="group-form-drawer"
footer={
<div className={styles.groupFormFooter}>
<Button
variant="ghost"
color="secondary"
onClick={onClose}
testId="group-form-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
disabled={!isValid}
testId="group-form-save"
>
{isEdit ? 'Save group' : 'Create group'}
</Button>
</div>
}
>
<div className={styles.groupForm}>
<div className={styles.groupFormField}>
<span className={styles.groupFormLabel}>Group Name</span>
<Input
placeholder="e.g. OpenAI gateway"
value={draft.name}
onChange={(event): void =>
setDraft({ ...draft, name: event.target.value })
}
testId="group-form-name"
/>
</div>
<div className={`${styles.groupFormField} ${styles.groupFormFieldRow}`}>
<span className={styles.groupFormLabel}>Enabled</span>
<Switch
value={draft.enabled}
onChange={(checked): void => setDraft({ ...draft, enabled: checked })}
testId="group-form-enabled"
/>
</div>
<ConditionKeyList
label="Condition · span attribute keys"
labelHint="·Runs when a span attribute key contains any of these"
keys={draft.attributes}
placeholder="e.g. gen_ai."
addLabel="Add attribute key"
testIdPrefix="group-form-attribute"
fieldContext={FieldContext.attribute}
onChange={(attributes): void => setDraft({ ...draft, attributes })}
/>
<ConditionKeyList
label="Condition · resource keys"
labelHint="·Or when a resource key contains any of these"
keys={draft.resource}
placeholder="e.g. service.name"
addLabel="Add resource key"
testIdPrefix="group-form-resource"
fieldContext={FieldContext.resource}
onChange={(resource): void => setDraft({ ...draft, resource })}
/>
<span className={styles.groupFormHint}>
Leave both empty to run this group on every span.
</span>
</div>
</DrawerWrapper>
);
}
export default GroupFormDrawer;

View File

@@ -1,35 +0,0 @@
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.labelHint {
font-weight: var(--font-weight-normal);
text-transform: none;
letter-spacing: normal;
}
.keys {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.keyRow {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.keyInput {
flex: 1;
}

View File

@@ -1,89 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { Plus, X } from '@signozhq/icons';
import { FieldContextValue } from 'container/LLMObservability/AttributeMapping/types';
import KeySearchInput from '../../../KeySearchInput/KeySearchInput';
import styles from './ConditionKeyList.module.scss';
interface ConditionKeyListProps {
label: string;
labelHint?: string;
keys: string[];
placeholder: string;
addLabel: string;
testIdPrefix: string;
fieldContext: FieldContextValue;
onChange: (keys: string[]) => void;
}
function ConditionKeyList({
label,
labelHint,
keys,
placeholder,
addLabel,
testIdPrefix,
fieldContext,
onChange,
}: ConditionKeyListProps): JSX.Element {
const updateKey = (index: number, value: string): void => {
onChange(keys.map((key, i) => (i === index ? value : key)));
};
const addKey = (): void => {
onChange([...keys, '']);
};
const removeKey = (index: number): void => {
onChange(keys.filter((_, i) => i !== index));
};
return (
<div className={styles.field}>
<span className={styles.label}>
{label}
{labelHint && <span className={styles.labelHint}> {labelHint}</span>}
</span>
{keys.length > 0 && (
<div className={styles.keys}>
{keys.map((key, index) => (
// eslint-disable-next-line react/no-array-index-key
<div className={styles.keyRow} key={index}>
<KeySearchInput
className={styles.keyInput}
placeholder={placeholder}
value={key}
fieldContext={fieldContext}
onChange={(next): void => updateKey(index, next)}
testId={`${testIdPrefix}-${index}`}
/>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Remove key"
onClick={(): void => removeKey(index)}
testId={`${testIdPrefix}-remove-${index}`}
>
<X size={14} />
</Button>
</div>
))}
</div>
)}
<Button
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
onClick={addKey}
testId={`${testIdPrefix}-add`}
>
{addLabel}
</Button>
</div>
);
}
export default ConditionKeyList;

View File

@@ -1,38 +0,0 @@
import { useCallback, useState } from 'react';
import { DraftGroup, GroupDraft, MapperDraftMode } from '../../../types';
import { EMPTY_GROUP_DRAFT, groupDraftFromNode } from '../../../utils';
interface UseGroupFormDrawer {
isOpen: boolean;
mode: MapperDraftMode;
draft: GroupDraft;
setDraft: (next: GroupDraft) => void;
openForAdd: () => void;
openForEdit: (group: DraftGroup) => void;
close: () => void;
}
export function useGroupFormDrawer(): UseGroupFormDrawer {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<MapperDraftMode>('add');
const [draft, setDraft] = useState<GroupDraft>(EMPTY_GROUP_DRAFT);
const openForAdd = useCallback((): void => {
setMode('add');
setDraft(EMPTY_GROUP_DRAFT);
setIsOpen(true);
}, []);
const openForEdit = useCallback((group: DraftGroup): void => {
setMode('edit');
setDraft(groupDraftFromNode(group));
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
}, []);
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
}

View File

@@ -1,49 +0,0 @@
.keySearch {
position: relative;
flex: 1;
min-width: 0;
}
.keySearchDropdown {
position: absolute;
top: calc(100% + var(--spacing-2));
left: 0;
z-index: 20;
width: 100%;
max-height: 240px;
overflow-x: hidden;
overflow-y: auto;
padding: var(--spacing-2);
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l2-background);
box-shadow: 0 8px 24px
color-mix(in srgb, var(--bg-base-black) 40%, transparent);
}
.keySearchDropdownEmpty {
padding: var(--spacing-4) var(--spacing-5);
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.keySearchOption {
display: block;
width: 100%;
max-width: 100%;
padding: var(--spacing-3) var(--spacing-5);
border: none;
border-radius: 3px;
background: transparent;
color: var(--l1-foreground);
font-size: var(--font-size-xs);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
&:hover {
background: var(--l2-background-hover);
}
}

View File

@@ -1,121 +0,0 @@
import { useMemo, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import Spinner from 'components/Spinner';
import useDebounce from 'hooks/useDebounce';
import {
FieldContext,
FieldContextValue,
} from 'container/LLMObservability/AttributeMapping/types';
import styles from './KeySearchInput.module.scss';
const SUGGESTION_LIMIT = 50;
const DEBOUNCE_MS = 300;
interface KeySearchInputProps {
value: string;
fieldContext: FieldContextValue;
placeholder?: string;
className?: string;
disabled?: boolean;
testId?: string;
onChange: (value: string) => void;
}
// Maps the mapper's attribute/resource context to the fields-endpoint context.
function toFieldsContext(
context: FieldContextValue,
): TelemetrytypesFieldContextDTO {
return context === FieldContext.resource
? TelemetrytypesFieldContextDTO.resource
: TelemetrytypesFieldContextDTO.attribute;
}
// Free-text input with span/resource key suggestions from /api/v1/fields/keys
// (signal=traces). Typing keeps the custom value; suggestions are assistive.
function KeySearchInput({
value,
fieldContext,
placeholder,
className,
disabled,
testId,
onChange,
}: KeySearchInputProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const debouncedSearch = useDebounce(value, DEBOUNCE_MS);
const { data, isFetching } = useGetFieldsKeys(
{
signal: TelemetrytypesSignalDTO.traces,
fieldContext: toFieldsContext(fieldContext),
searchText: debouncedSearch,
limit: SUGGESTION_LIMIT,
},
{ query: { enabled: isOpen && !disabled, keepPreviousData: true } },
);
const suggestions = useMemo(() => {
const keys = data?.data?.keys ?? {};
return Object.keys(keys)
.filter((key) => key !== value)
.slice(0, SUGGESTION_LIMIT);
}, [data, value]);
return (
<div className={cx(styles.keySearch, className)}>
<Input
placeholder={placeholder}
value={value}
disabled={disabled}
autoComplete="off"
onChange={(event): void => {
onChange(event.target.value);
setIsOpen(true);
}}
onFocus={(): void => setIsOpen(true)}
onBlur={(): void => setIsOpen(false)}
testId={testId}
/>
{isOpen && suggestions.length > 0 && (
<div
className={styles.keySearchDropdown}
data-testid={`${testId}-dropdown`}
>
{suggestions.map((name) => (
<button
type="button"
key={name}
className={styles.keySearchOption}
title={name}
// onMouseDown (not onClick) so selection runs before the input blur.
onMouseDown={(event): void => {
event.preventDefault();
onChange(name);
setIsOpen(false);
}}
data-testid={`${testId}-option-${name}`}
>
{name}
</button>
))}
</div>
)}
{isOpen && isFetching && suggestions.length === 0 && (
<div
className={cx(styles.keySearchDropdown, styles.keySearchDropdownEmpty)}
>
<Spinner size="small" height="auto" />
</div>
)}
</div>
);
}
export default KeySearchInput;

View File

@@ -1,63 +0,0 @@
.form {
display: flex;
flex-direction: column;
gap: var(--spacing-10);
padding: var(--spacing-2) 0;
}
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.labelHint {
font-weight: var(--font-weight-normal);
text-transform: none;
letter-spacing: normal;
}
.hint {
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.fieldContext {
width: 200px;
}
.sources {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.error {
padding: var(--spacing-5) var(--spacing-6);
border-radius: var(--radius-2);
background: var(--callout-error-background);
color: var(--callout-error-title);
font-size: var(--periscope-font-size-base);
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: var(--spacing-4);
}
.footerActions {
display: flex;
gap: var(--spacing-4);
margin-left: auto;
}

View File

@@ -1,252 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { SelectSimple } from '@signozhq/ui/select';
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { Plus, Trash2 } from '@signozhq/icons';
import { v4 as uuid } from 'uuid';
import KeySearchInput from '../KeySearchInput/KeySearchInput';
import styles from './MapperFormDrawer.module.scss';
import SourceAttributeRow from './components/SourceAttributeRow/SourceAttributeRow';
import {
FieldContext,
FieldContextValue,
MapperDraft,
MapperDraftMode,
SourceConfig,
} from '../../types';
import { createEmptySource, isMapperDraftValid } from '../../utils';
const FIELD_CONTEXT_OPTIONS = [
{ value: FieldContext.attribute, label: 'Span attribute' },
{ value: FieldContext.resource, label: 'Resource' },
];
interface MapperFormDrawerProps {
isOpen: boolean;
mode: MapperDraftMode;
draft: MapperDraft;
setDraft: (next: MapperDraft) => void;
onClose: () => void;
onSave: () => void;
onDelete: () => void;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
}
function MapperFormDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
saveError,
}: MapperFormDrawerProps): JSX.Element {
const isEdit = mode === 'edit';
const isValid = isMapperDraftValid(draft);
const [rowIds, setRowIds] = useState<string[]>(() =>
draft.sources.map(() => uuid()),
);
const sourceIds = draft.sources.map(
(_, index) => rowIds[index] ?? `pending-${index}`,
);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const updateSource = (index: number, patch: Partial<SourceConfig>): void => {
const sources = draft.sources.map((source, i) =>
i === index ? { ...source, ...patch } : source,
);
setDraft({ ...draft, sources });
};
const addSource = (): void => {
setDraft({ ...draft, sources: [...draft.sources, createEmptySource()] });
setRowIds((prev) => [...prev, uuid()]);
};
const removeSource = (index: number): void => {
const sources = draft.sources.filter((_, i) => i !== index);
if (sources.length === 0) {
setDraft({ ...draft, sources: [createEmptySource()] });
setRowIds([uuid()]);
return;
}
setDraft({ ...draft, sources });
setRowIds((prev) => prev.filter((_, i) => i !== index));
};
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const from = sourceIds.indexOf(String(active.id));
const to = sourceIds.indexOf(String(over.id));
if (from === -1 || to === -1) {
return;
}
setDraft({ ...draft, sources: arrayMove(draft.sources, from, to) });
setRowIds((prev) => arrayMove(prev, from, to));
};
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
title={isEdit ? 'Edit mapping' : 'New custom mapping'}
subTitle="Map source attributes onto a canonical target attribute"
width="wide"
testId="mapper-form-drawer"
footer={
<div className={styles.footer}>
{isEdit && (
<Button
variant="ghost"
color="destructive"
prefix={<Trash2 size={14} />}
onClick={onDelete}
disabled={isDeleting}
testId="mapper-form-delete"
>
{isDeleting ? 'Deleting…' : 'Delete'}
</Button>
)}
<div className={styles.footerActions}>
<Button
variant="ghost"
color="secondary"
onClick={onClose}
testId="mapper-form-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
disabled={!isValid || isSaving}
testId="mapper-form-save"
>
{/* eslint-disable-next-line no-nested-ternary */}
{isSaving ? 'Saving…' : isEdit ? 'Save mapping' : 'Create mapping'}
</Button>
</div>
</div>
}
>
<div className={styles.form}>
<div className={styles.field}>
<span className={styles.label}>Target attribute</span>
<KeySearchInput
placeholder="e.g. gen_ai.content.prompt"
value={draft.name}
fieldContext={draft.fieldContext}
disabled={isEdit}
onChange={(name): void => setDraft({ ...draft, name })}
testId="mapper-form-target"
/>
{isEdit && (
<span className={styles.hint}>
The target attribute can&apos;t be changed after creation.
</span>
)}
</div>
<div className={styles.field}>
<span className={styles.label}>Write target to</span>
<SelectSimple
className={styles.fieldContext}
items={FIELD_CONTEXT_OPTIONS}
value={draft.fieldContext}
withPortal={false}
onChange={(next): void =>
setDraft({ ...draft, fieldContext: next as FieldContextValue })
}
testId="mapper-form-field-context"
/>
<span className={styles.hint}>
Where the standardized attribute is written.
</span>
</div>
<div className={styles.field}>
<span className={styles.label}>
Source attributes
<span className={styles.labelHint}>
{' '}
· priority: top bottom · drag to reorder
</span>
</span>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext items={sourceIds} strategy={verticalListSortingStrategy}>
<div className={styles.sources}>
{draft.sources.map((source, index) => (
<SourceAttributeRow
key={sourceIds[index]}
id={sourceIds[index]}
index={index}
value={source}
canRemove={draft.sources.length > 1}
onChange={updateSource}
onRemove={removeSource}
/>
))}
</div>
</SortableContext>
</DndContext>
<Button
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
onClick={addSource}
testId="mapper-form-add-source"
>
Add another source
</Button>
</div>
{saveError && (
<div className={styles.error} role="alert">
{saveError}
</div>
)}
</div>
</DrawerWrapper>
);
}
export default MapperFormDrawer;

View File

@@ -1,40 +0,0 @@
.source {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.sourceHandle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing-2);
border: none;
background: transparent;
color: var(--l3-foreground);
cursor: grab;
touch-action: none;
user-select: none;
&:active {
cursor: grabbing;
}
}
.sourceIndex {
width: 20px;
text-align: center;
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.sourceInput {
flex: 1;
min-width: 0;
font-family: 'Geist Mono', monospace;
}
.sourceSelect {
flex: 0 0 auto;
width: 120px;
}

View File

@@ -1,112 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { SelectSimple } from '@signozhq/ui/select';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, X } from '@signozhq/icons';
import {
FieldContext,
FieldContextValue,
MapperOperation,
MapperOperationValue,
SourceConfig,
} from 'container/LLMObservability/AttributeMapping/types';
import KeySearchInput from '../../../KeySearchInput/KeySearchInput';
import styles from './SourceAttributeRow.module.scss';
const CONTEXT_OPTIONS = [
{ value: FieldContext.attribute, label: 'Attribute' },
{ value: FieldContext.resource, label: 'Resource' },
];
const OPERATION_OPTIONS = [
{ value: MapperOperation.move, label: 'Move' },
{ value: MapperOperation.copy, label: 'Copy' },
];
interface SourceAttributeRowProps {
id: string;
index: number;
value: SourceConfig;
canRemove: boolean;
onChange: (index: number, patch: Partial<SourceConfig>) => void;
onRemove: (index: number) => void;
}
function SourceAttributeRow({
id,
index,
value,
canRemove,
onChange,
onRemove,
}: SourceAttributeRowProps): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
return (
<div className={styles.source} ref={setNodeRef} style={style}>
<div
className={styles.sourceHandle}
data-testid={`mapper-form-source-handle-${index}`}
{...attributes}
{...listeners}
>
<GripVertical size={14} />
</div>
<span className={styles.sourceIndex}>{index + 1}</span>
<KeySearchInput
className={styles.sourceInput}
placeholder="Source attribute key"
value={value.key}
fieldContext={value.context}
onChange={(key): void => onChange(index, { key })}
testId={`mapper-form-source-${index}`}
/>
<SelectSimple
className={styles.sourceSelect}
items={CONTEXT_OPTIONS}
value={value.context}
withPortal={false}
onChange={(next): void =>
onChange(index, { context: next as FieldContextValue })
}
testId={`mapper-form-source-context-${index}`}
/>
<SelectSimple
className={styles.sourceSelect}
items={OPERATION_OPTIONS}
value={value.operation}
withPortal={false}
onChange={(next): void =>
onChange(index, { operation: next as MapperOperationValue })
}
testId={`mapper-form-source-operation-${index}`}
/>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Remove source"
disabled={!canRemove}
onClick={(): void => onRemove(index)}
testId={`mapper-form-source-remove-${index}`}
>
<X size={14} />
</Button>
</div>
);
}
export default SourceAttributeRow;

View File

@@ -1,38 +0,0 @@
import { useCallback, useState } from 'react';
import { DraftMapper, MapperDraft, MapperDraftMode } from '../../../types';
import { EMPTY_MAPPER_DRAFT, mapperDraftFromNode } from '../../../utils';
interface UseMapperFormDrawerResult {
isOpen: boolean;
mode: MapperDraftMode;
draft: MapperDraft;
setDraft: (next: MapperDraft) => void;
openForAdd: () => void;
openForEdit: (mapper: DraftMapper) => void;
close: () => void;
}
export function useMapperFormDrawer(): UseMapperFormDrawerResult {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<MapperDraftMode>('add');
const [draft, setDraft] = useState<MapperDraft>(EMPTY_MAPPER_DRAFT);
const openForAdd = useCallback((): void => {
setMode('add');
setDraft(EMPTY_MAPPER_DRAFT);
setIsOpen(true);
}, []);
const openForEdit = useCallback((mapper: DraftMapper): void => {
setMode('edit');
setDraft(mapperDraftFromNode(mapper));
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
}, []);
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
}

View File

@@ -1,322 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { cloneDeep, isEqual } from 'lodash-es';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
useCreateSpanMapper,
useCreateSpanMapperGroup,
useDeleteSpanMapper,
useDeleteSpanMapperGroup,
useListSpanMapperGroups,
useUpdateSpanMapper,
useUpdateSpanMapperGroup,
} from 'api/generated/services/spanmapper';
import { persistDraft, SaveMutations } from '../saveDraft';
import {
DraftGroup,
GroupDraft,
Mapper,
MapperDraft,
MapperGroup,
} from '../types';
import {
buildDraftGroup,
buildDraftMapper,
nodeFromGroupDraft,
nodeFromMapperDraft,
} from '../utils';
const GROUPS_KEY_PREFIX = '/api/v1/span_mapper_groups';
function clone(groups: DraftGroup[]): DraftGroup[] {
return cloneDeep(groups);
}
export interface AttributeMappingEditor {
groups: DraftGroup[];
isLoading: boolean;
isError: boolean;
isDirty: boolean;
isSaving: boolean;
saveError: string | null;
upsertGroup: (draft: GroupDraft) => void;
removeGroup: (localId: string) => void;
toggleGroup: (localId: string, enabled: boolean) => void;
hydrateGroupMappers: (groupServerId: string, mappers: Mapper[]) => void;
upsertMapper: (groupLocalId: string, draft: MapperDraft) => void;
removeMapper: (groupLocalId: string, mapperLocalId: string) => void;
toggleMapper: (
groupLocalId: string,
mapperLocalId: string,
enabled: boolean,
) => void;
save: () => Promise<void>;
discard: () => void;
}
export function useAttributeMappingEditor(): AttributeMappingEditor {
const queryClient = useQueryClient();
const groupsQuery = useListSpanMapperGroups();
const { refetch: refetchGroups } = groupsQuery;
const serverGroups: MapperGroup[] = useMemo(
() => groupsQuery.data?.data?.items ?? [],
[groupsQuery.data],
);
const ready = !groupsQuery.isLoading;
const [loadedMappers, setLoadedMappers] = useState<Record<string, Mapper[]>>(
{},
);
const loadedRef = useRef<Set<string>>(new Set());
const snapshot = useMemo<DraftGroup[]>(() => {
if (!ready) {
return [];
}
return serverGroups.map((group) =>
buildDraftGroup(group, loadedMappers[group.id] ?? []),
);
}, [ready, serverGroups, loadedMappers]);
const [draft, setDraft] = useState<DraftGroup[] | null>(null);
// Initialise the working copy once data is ready (and re-init after a save
// clears it). Never clobbers in-flight edits — only runs when draft is null.
useEffect(() => {
if (ready && draft === null) {
setDraft(clone(snapshot));
}
}, [ready, draft, snapshot]);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { mutateAsync: createGroup } = useCreateSpanMapperGroup();
const { mutateAsync: updateGroup } = useUpdateSpanMapperGroup();
const { mutateAsync: deleteGroup } = useDeleteSpanMapperGroup();
const { mutateAsync: createMapper } = useCreateSpanMapper();
const { mutateAsync: updateMapper } = useUpdateSpanMapper();
const { mutateAsync: deleteMapper } = useDeleteSpanMapper();
const mutations: SaveMutations = useMemo(
() => ({
createGroup: async (data): Promise<string> => {
const result = await createGroup({ data });
return result.data.id;
},
updateGroup: async (groupId, data): Promise<void> => {
await updateGroup({ pathParams: { groupId }, data });
},
deleteGroup: async (groupId): Promise<void> => {
await deleteGroup({ pathParams: { groupId } });
},
createMapper: async (groupId, data): Promise<void> => {
await createMapper({ pathParams: { groupId }, data });
},
updateMapper: async (groupId, mapperId, data): Promise<void> => {
await updateMapper({ pathParams: { groupId, mapperId }, data });
},
deleteMapper: async (groupId, mapperId): Promise<void> => {
await deleteMapper({ pathParams: { groupId, mapperId } });
},
}),
[
createGroup,
updateGroup,
deleteGroup,
createMapper,
updateMapper,
deleteMapper,
],
);
const upsertGroup = useCallback((groupDraft: GroupDraft): void => {
setDraft((prev) => {
const groups = prev ?? [];
if (groupDraft.id) {
return groups.map((group) =>
group.localId === groupDraft.id
? nodeFromGroupDraft(groupDraft, group)
: group,
);
}
return [...groups, nodeFromGroupDraft(groupDraft)];
});
}, []);
const removeGroup = useCallback((localId: string): void => {
setDraft((prev) => (prev ?? []).filter((group) => group.localId !== localId));
}, []);
const toggleGroup = useCallback((localId: string, enabled: boolean): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === localId ? { ...group, enabled } : group,
),
);
}, []);
const hydrateGroupMappers = useCallback(
(groupServerId: string, mappers: Mapper[]): void => {
if (loadedRef.current.has(groupServerId)) {
return;
}
loadedRef.current.add(groupServerId);
setLoadedMappers((prev) => ({ ...prev, [groupServerId]: mappers }));
setDraft((prev) =>
prev === null
? prev
: prev.map((group) =>
group.serverId === groupServerId
? {
...group,
mappers: [
...mappers.map(buildDraftMapper),
...group.mappers.filter((mapper) => mapper.serverId === null),
],
}
: group,
),
);
},
[],
);
const upsertMapper = useCallback(
(groupLocalId: string, mapperDraft: MapperDraft): void => {
setDraft((prev) =>
(prev ?? []).map((group) => {
if (group.localId !== groupLocalId) {
return group;
}
if (mapperDraft.id) {
return {
...group,
mappers: group.mappers.map((mapper) =>
mapper.localId === mapperDraft.id
? nodeFromMapperDraft(mapperDraft, mapper)
: mapper,
),
};
}
return {
...group,
mappers: [...group.mappers, nodeFromMapperDraft(mapperDraft)],
};
}),
);
},
[],
);
const removeMapper = useCallback(
(groupLocalId: string, mapperLocalId: string): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === groupLocalId
? {
...group,
mappers: group.mappers.filter(
(mapper) => mapper.localId !== mapperLocalId,
),
}
: group,
),
);
},
[],
);
const toggleMapper = useCallback(
(groupLocalId: string, mapperLocalId: string, enabled: boolean): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === groupLocalId
? {
...group,
mappers: group.mappers.map((mapper) =>
mapper.localId === mapperLocalId ? { ...mapper, enabled } : mapper,
),
}
: group,
),
);
},
[],
);
const discard = useCallback((): void => {
setSaveError(null);
setDraft(clone(snapshot));
}, [snapshot]);
const save = useCallback(async (): Promise<void> => {
if (!draft) {
return;
}
setIsSaving(true);
setSaveError(null);
try {
await persistDraft(snapshot, draft, mutations);
// Refresh the groups list in place — it stays mounted, so this just
// swaps in fresh data without a loading flash. Using the query's own
// refetch keeps it scoped to the groups list; the per-group mapper
// caches are handled separately below (mappers can change here, so
// their cached lists must be dropped rather than left stale).
const { isError: refreshFailed } = await refetchGroups();
if (refreshFailed) {
toast.warning(
'Changes saved, but the list failed to refresh. Reload to see the latest.',
);
return;
}
// Reset the mapper-load tracking so expanded groups re-hydrate from
// the fresh server data.
loadedRef.current = new Set();
setLoadedMappers({});
// Reset the working copy so the effect above re-seeds it from the
// fresh snapshot (new server ids included).
setDraft(null);
// Evict the cached per-group mapper lists so an expanded group
// refetches its mappers against the fresh server state.
queryClient.removeQueries({
predicate: (query) =>
typeof query.queryKey?.[0] === 'string' &&
(query.queryKey[0] as string).startsWith(`${GROUPS_KEY_PREFIX}/`),
});
toast.success('Attribute mapping changes saved');
} catch (error) {
const message = error instanceof Error ? error.message : 'Save failed';
setSaveError(message);
toast.error(`Failed to save changes: ${message}`);
} finally {
setIsSaving(false);
}
}, [draft, snapshot, mutations, refetchGroups, queryClient]);
const isDirty = useMemo(
() => draft !== null && !isEqual(draft, snapshot),
[draft, snapshot],
);
return {
groups: draft ?? [],
isLoading: !ready || draft === null,
isError: groupsQuery.isError,
isDirty,
isSaving,
saveError,
upsertGroup,
removeGroup,
toggleGroup,
hydrateGroupMappers,
upsertMapper,
removeMapper,
toggleMapper,
save,
discard,
};
}

View File

@@ -1,190 +0,0 @@
import { isEqual } from 'lodash-es';
import {
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DraftGroup, DraftMapper, SourceConfig } from './types';
import {
buildPostableGroup,
buildPostableMapper,
buildUpdatableGroup,
buildUpdatableMapper,
} from './utils';
// Thin persistence surface the editor wires to the generated mutations.
// createGroup returns the new server id so its mappers can be created under it.
export interface SaveMutations {
createGroup: (data: SpantypesPostableSpanMapperGroupDTO) => Promise<string>;
updateGroup: (
groupId: string,
data: SpantypesUpdatableSpanMapperGroupDTO,
) => Promise<void>;
deleteGroup: (groupId: string) => Promise<void>;
createMapper: (
groupId: string,
data: SpantypesPostableSpanMapperDTO,
) => Promise<void>;
updateMapper: (
groupId: string,
mapperId: string,
data: SpantypesUpdatableSpanMapperDTO,
) => Promise<void>;
deleteMapper: (groupId: string, mapperId: string) => Promise<void>;
}
function sourcesEqual(a: SourceConfig[], b: SourceConfig[]): boolean {
return (
a.length === b.length &&
a.every(
(source, index) =>
source.key === b[index].key &&
source.context === b[index].context &&
source.operation === b[index].operation,
)
);
}
function groupChanged(snapshot: DraftGroup, draft: DraftGroup): boolean {
return (
snapshot.name !== draft.name ||
snapshot.enabled !== draft.enabled ||
!isEqual(snapshot.attributes, draft.attributes) ||
!isEqual(snapshot.resource, draft.resource)
);
}
function mapperChanged(snapshot: DraftMapper, draft: DraftMapper): boolean {
return (
snapshot.enabled !== draft.enabled ||
snapshot.fieldContext !== draft.fieldContext ||
!sourcesEqual(snapshot.sources, draft.sources)
);
}
function groupDraftOf(
node: DraftGroup,
): Parameters<typeof buildPostableGroup>[0] {
return {
id: node.serverId,
name: node.name,
attributes: node.attributes,
resource: node.resource,
enabled: node.enabled,
};
}
function mapperDraftOf(
node: DraftMapper,
): Parameters<typeof buildPostableMapper>[0] {
return {
id: node.serverId,
name: node.name,
fieldContext: node.fieldContext,
sources: node.sources,
enabled: node.enabled,
};
}
async function persistMappers(
groupServerId: string,
snapshotMappers: DraftMapper[],
draftMappers: DraftMapper[],
m: SaveMutations,
): Promise<void> {
const snapById = new Map(
snapshotMappers
.filter((mapper) => mapper.serverId)
.map((mapper) => [mapper.serverId as string, mapper]),
);
const draftServerIds = new Set(
draftMappers
.filter((mapper) => mapper.serverId)
.map((mapper) => mapper.serverId as string),
);
// Creates/updates before deletes (see persistDraft). Sequential for
// deterministic ordering.
for (const mapper of draftMappers) {
if (!mapper.serverId) {
// eslint-disable-next-line no-await-in-loop
await m.createMapper(
groupServerId,
buildPostableMapper(mapperDraftOf(mapper)),
);
} else {
const snap = snapById.get(mapper.serverId);
if (!snap || mapperChanged(snap, mapper)) {
// eslint-disable-next-line no-await-in-loop
await m.updateMapper(
groupServerId,
mapper.serverId,
buildUpdatableMapper(mapperDraftOf(mapper)),
);
}
}
}
// Deletes last.
await Promise.all(
snapshotMappers
.filter((mapper) => mapper.serverId && !draftServerIds.has(mapper.serverId))
.map((mapper) => m.deleteMapper(groupServerId, mapper.serverId as string)),
);
}
// Diffs the staged tree against the server snapshot and issues the minimal set
// of create/update/delete calls to reconcile them.
export async function persistDraft(
snapshot: DraftGroup[],
draft: DraftGroup[],
m: SaveMutations,
): Promise<void> {
const snapById = new Map(
snapshot
.filter((group) => group.serverId)
.map((group) => [group.serverId as string, group]),
);
const draftServerIds = new Set(
draft
.filter((group) => group.serverId)
.map((group) => group.serverId as string),
);
// Creates/updates before deletes: a mid-save failure then leaves incomplete
// additions rather than groups deleted with no replacement. Deletes are
// irreversible (cascade mappers server-side), so they run last.
//
// Names are unique per scope, so deleting and recreating the same name in
// one save collides and errors — recoverable by splitting across two saves,
// which we favour over the data loss delete-first would risk.
for (const group of draft) {
if (!group.serverId) {
// eslint-disable-next-line no-await-in-loop
const newId = await m.createGroup(buildPostableGroup(groupDraftOf(group)));
// eslint-disable-next-line no-await-in-loop
await persistMappers(newId, [], group.mappers, m);
continue;
}
const snap = snapById.get(group.serverId);
if (!snap || groupChanged(snap, group)) {
// eslint-disable-next-line no-await-in-loop
await m.updateGroup(
group.serverId,
buildUpdatableGroup(groupDraftOf(group)),
);
}
// eslint-disable-next-line no-await-in-loop
await persistMappers(group.serverId, snap?.mappers ?? [], group.mappers, m);
}
// Deleted groups (cascades mappers server-side).
await Promise.all(
snapshot
.filter((group) => group.serverId && !draftServerIds.has(group.serverId))
.map((group) => m.deleteGroup(group.serverId as string)),
);
}

View File

@@ -1,58 +1,26 @@
import {
SpantypesFieldContextDTO,
SpantypesSpanMapperDTO,
SpantypesSpanMapperGroupDTO,
SpantypesSpanMapperOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
export type MapperGroup = SpantypesSpanMapperGroupDTO;
export type Mapper = SpantypesSpanMapperDTO;
export const FieldContext = SpantypesFieldContextDTO;
export type FieldContextValue = SpantypesFieldContextDTO;
export const MapperOperation = SpantypesSpanMapperOperationDTO;
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
export type MapperDraftMode = 'add' | 'edit';
export interface SourceConfig {
key: string;
context: SpantypesFieldContextDTO;
operation: SpantypesSpanMapperOperationDTO;
}
// Editable form state for a mapper. `sources` is ordered highest priority
// first; `fieldContext` is where the standardized target is written.
export interface MapperDraft {
id: string | null;
export interface Mapping {
id: string;
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
export interface GroupDraft {
id: string | null;
export interface MappingGroup {
id: string;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
}
export interface DraftMapper {
localId: string;
serverId: string | null;
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
export interface DraftGroup {
localId: string;
serverId: string | null;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
mappers: DraftMapper[];
}

View File

@@ -1,49 +1,15 @@
import {
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
ListSpanMappers200,
SpantypesSpanMapperDTO,
SpantypesSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { v4 as uuid } from 'uuid';
import {
DraftGroup,
DraftMapper,
FieldContext,
GroupDraft,
Mapper,
MapperDraft,
MapperGroup,
MapperOperation,
SourceConfig,
} from './types';
import { MappingGroup, Mapping, SourceConfig } from './types';
// Client-side id for not-yet-persisted rows. Prefixed so it never collides
// with a server UUID and is easy to spot in logs.
function genLocalId(prefix: 'group' | 'mapper'): string {
return `local-${prefix}-${uuid()}`;
}
// Trimmed, de-duplicated, non-empty keys preserving input order.
function cleanKeys(keys: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
keys.forEach((raw) => {
const key = raw.trim();
if (key && !seen.has(key)) {
seen.add(key);
result.push(key);
}
});
return result;
}
// Source configs for a mapper, highest priority first (first match wins at
// evaluation time).
function getMapperSources(mapper: Mapper): SourceConfig[] {
function getMapperSources(mapper: SpantypesSpanMapperDTO): SourceConfig[] {
const sources = mapper.config?.sources ?? [];
return [...sources]
.sort((a, b) => b.priority - a.priority)
.sort((a, b) => a.priority - b.priority)
.map((source) => ({
key: source.key,
context: source.context,
@@ -51,110 +17,9 @@ function getMapperSources(mapper: Mapper): SourceConfig[] {
}));
}
export function createEmptySource(): SourceConfig {
export function buildMapping(mapper: SpantypesSpanMapperDTO): Mapping {
return {
key: '',
context: FieldContext.attribute,
operation: MapperOperation.copy,
};
}
export const EMPTY_MAPPER_DRAFT: MapperDraft = {
id: null,
name: '',
fieldContext: FieldContext.attribute,
sources: [createEmptySource()],
enabled: true,
};
function getCleanSources(draft: MapperDraft): SourceConfig[] {
const seen = new Set<string>();
const result: SourceConfig[] = [];
draft.sources.forEach((source) => {
const key = source.key.trim();
const dedupeKey = `${source.context}:${key}`;
if (key && !seen.has(dedupeKey)) {
seen.add(dedupeKey);
result.push({ ...source, key });
}
});
return result;
}
export function isMapperDraftValid(draft: MapperDraft): boolean {
return draft.name.trim().length > 0 && getCleanSources(draft).length > 0;
}
// Priority is derived from list order so the first row wins.
function buildSources(
draft: MapperDraft,
): SpantypesPostableSpanMapperDTO['config']['sources'] {
const sources = getCleanSources(draft);
return sources.map((source, index) => ({
key: source.key,
context: source.context,
operation: source.operation,
priority: sources.length - index,
}));
}
export function buildPostableMapper(
draft: MapperDraft,
): SpantypesPostableSpanMapperDTO {
return {
name: draft.name.trim(),
fieldContext: draft.fieldContext,
enabled: draft.enabled,
config: { sources: buildSources(draft) },
};
}
// The target name is immutable on update (UpdatableSpanMapper has no name).
export function buildUpdatableMapper(
draft: MapperDraft,
): SpantypesUpdatableSpanMapperDTO {
return {
fieldContext: draft.fieldContext,
enabled: draft.enabled,
config: { sources: buildSources(draft) },
};
}
export const EMPTY_GROUP_DRAFT: GroupDraft = {
id: null,
name: '',
attributes: [''],
resource: [],
enabled: true,
};
export function isGroupDraftValid(draft: GroupDraft): boolean {
return draft.name.trim().length > 0;
}
export function buildPostableGroup(
draft: GroupDraft,
): SpantypesPostableSpanMapperGroupDTO {
return {
name: draft.name.trim(),
enabled: draft.enabled,
condition: {
attributes: cleanKeys(draft.attributes),
resource: cleanKeys(draft.resource),
},
};
}
export function buildUpdatableGroup(
draft: GroupDraft,
): SpantypesUpdatableSpanMapperGroupDTO {
return buildPostableGroup(draft);
}
export function buildDraftMapper(mapper: Mapper): DraftMapper {
return {
localId: mapper.id,
serverId: mapper.id,
id: mapper.id,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources: getMapperSources(mapper),
@@ -162,71 +27,22 @@ export function buildDraftMapper(mapper: Mapper): DraftMapper {
};
}
export function buildDraftGroup(
group: MapperGroup,
mappers: Mapper[],
): DraftGroup {
export function buildMappingsFromListResponse(
response: ListSpanMappers200,
): Mapping[] {
const items = (response.data?.items ??
[]) as unknown as SpantypesSpanMapperDTO[];
return items.map(buildMapping);
}
export function buildMappingGroup(
group: SpantypesSpanMapperGroupDTO,
): MappingGroup {
return {
localId: group.id,
serverId: group.id,
id: group.id,
name: group.name,
attributes: group.condition?.attributes ?? [],
resource: group.condition?.resource ?? [],
enabled: group.enabled,
mappers: mappers.map(buildDraftMapper),
};
}
// DraftGroup -> editable form state (id carries the localId).
export function groupDraftFromNode(group: DraftGroup): GroupDraft {
return {
id: group.localId,
name: group.name,
attributes: group.attributes.length > 0 ? group.attributes : [''],
resource: group.resource,
enabled: group.enabled,
};
}
// DraftMapper -> editable form state (id carries the localId).
export function mapperDraftFromNode(mapper: DraftMapper): MapperDraft {
return {
id: mapper.localId,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources:
mapper.sources.length > 0
? mapper.sources.map((source) => ({ ...source }))
: [createEmptySource()],
enabled: mapper.enabled,
};
}
export function nodeFromGroupDraft(
draft: GroupDraft,
existing?: DraftGroup,
): DraftGroup {
return {
localId: existing?.localId ?? genLocalId('group'),
serverId: existing?.serverId ?? null,
name: draft.name.trim(),
attributes: cleanKeys(draft.attributes),
resource: cleanKeys(draft.resource),
enabled: draft.enabled,
mappers: existing?.mappers ?? [],
};
}
export function nodeFromMapperDraft(
draft: MapperDraft,
existing?: DraftMapper,
): DraftMapper {
return {
localId: existing?.localId ?? genLocalId('mapper'),
serverId: existing?.serverId ?? null,
name: draft.name.trim(),
fieldContext: draft.fieldContext,
sources: getCleanSources(draft),
enabled: draft.enabled,
};
}

View File

@@ -4,10 +4,4 @@
height: 100%;
margin-top: var(--spacing-2);
margin-left: var(--spacing-2);
--tab-text-color: var(--l1-foreground);
--tab-active-text-color: var(--l1-foreground);
[role='tabpanel'] {
margin: 0;
padding: var(--spacing-0) var(--spacing-4);
}
}

View File

@@ -2,13 +2,26 @@
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-0) var(--spacing-0);
//TODO: remove this once we have a proper dashboard page.
// The embedded V2 DashboardContainer renders its own page header (dashboard
// title + share/feedback chrome) removed it for now
:global([class*='dashboardPageHeader']) {
display: none;
}
// remove margin added by the hidden header to avoid extra whitespace at the top of the page
margin-top: calc(var(--spacing-8) * -1);
padding: var(--spacing-12) var(--spacing-16);
}
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
.title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
}
.subtitle {
margin: var(--spacing-2) 0 0;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}
}

View File

@@ -1,15 +1,18 @@
import DashboardContainer from 'pages/DashboardPageV2/DashboardContainer';
import { useSeededDashboardV2 } from './hooks/useSeededDashboardV2';
import styles from './Overview.module.scss';
// Overview tab content for LLM Observability. Currently the feature's landing
// surface; usage/cost/performance widgets land in later PRs.
function Overview(): JSX.Element {
//TODO: this is a temporary solution to get the seeded dashboard. We should fetch this json from the backend.
const { dashboard, refetch } = useSeededDashboardV2();
return (
<div className={styles.overview} data-testid="llm-observability-overview">
<DashboardContainer dashboard={dashboard} refetch={refetch} />
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>LLM Observability</h1>
<p className={styles.subtitle}>
Monitor and analyze your LLM usage, costs, and performance
</p>
</div>
</header>
</div>
);
}

View File

@@ -1,33 +0,0 @@
import { useQueryClient } from 'react-query';
import { getGetDashboardV2QueryKey } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import dashboardV2Json from '../json/dashboard.json';
const dashboard =
dashboardV2Json as unknown as DashboardtypesGettableDashboardV2DTO;
export interface UseSeededDashboardV2Result {
dashboard: DashboardtypesGettableDashboardV2DTO;
refetch: () => void;
}
const noop = (): void => {};
export function useSeededDashboardV2(): UseSeededDashboardV2Result {
const queryClient = useQueryClient();
const key = getGetDashboardV2QueryKey({ id: dashboard.id });
if (queryClient.getQueryData<GetDashboardV2200>(key) === undefined) {
queryClient.setQueryData<GetDashboardV2200>(key, {
data: dashboard,
status: 'success',
});
}
return { dashboard, refetch: noop };
}

View File

@@ -2,10 +2,6 @@
display: flex;
flex-direction: column;
gap: var(--spacing-8);
--tab-content-padding: 0;
margin-top: var(--spacing-3);
--tab-text-color: var(--l1-foreground);
--tab-active-text-color: var(--l1-foreground);
}
.tabLabel {

View File

@@ -33,7 +33,7 @@ function LLMObservabilityModelPricing(): JSX.Element {
items={[
{
key: MODEL_COSTS_TAB,
label: 'Model Costs',
label: 'Model costs',
children: <ModelCostTabPanel />,
},
{

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