Compare commits

..

6 Commits

Author SHA1 Message Date
aks07
0b4a0d0e36 test(data-export): move export tests into __tests__ folders
Aligns with the repo's dominant test layout (__tests__/ subfolders). No test changes beyond import-path depth.
2026-07-08 09:14:38 +05:30
aks07
a542da58e0 feat(data-export): prompt Save-As dialog with a prefilled filename
downloadFile now opens the File System Access API Save-As picker (prefilled with the export filename), falling back to a direct anchor download on browsers without it, and silently no-ops when the user cancels.
2026-07-07 20:20:35 +05:30
aks07
87d0289715 feat(data-export): add useClientExport dispatch hook
Frontend-driven export hook: narrows a V5 queryRange response by request type and routes time_series to the serializer, then formats (csv/jsonl) and downloads. Adds the ExportFormat enum and a scalar-export stub (#5591). Backend-driven export stays in useServerExport.
2026-07-07 20:20:25 +05:30
aks07
5b6a05dcf9 feat(data-export): add csv/jsonl formatters and client download trigger
toCsv/toJsonl turn a SerializedTable into CSV or newline-delimited JSON; downloadFile triggers a client-side blob download. All operate on the format-agnostic SerializedTable so any exporter can reuse them.
2026-07-07 19:43:16 +05:30
aks07
83815d3392 feat(data-export): add time_series LONG/WIDE serializer
Pure serializer that walks the V5 time_series tree (results → aggregations → series) into a format-agnostic SerializedTable. Supports LONG (tidy) and WIDE (pivot) shapes, series naming via the shared getLabelName, queryName/alias qualification only when ambiguous, y-axis unit in headers, and blank gaps.
2026-07-07 19:43:06 +05:30
aks07
9b9c461fbd feat: rename existing export logic to follow the new export data structure 2026-07-06 16:22:42 +05:30
44 changed files with 1139 additions and 4480 deletions

View File

@@ -3,7 +3,7 @@ import { Button, Popover, Tooltip } from 'antd';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
import { useExportRawData } from 'hooks/useExportData/useServerExport';
import { Download, LoaderCircle } from '@signozhq/icons';
import { DataSource } from 'types/common/queryBuilder';

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,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,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,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,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,221 +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 { useSectionedValues } from './useSectionedValues';
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 { sections, totalCount } = useSectionedValues({
relatedValues,
allValues,
currentFilterState,
isSomeFilterPresentForCurrentAttribute,
isNotInOperator,
hasExistingQuery,
visibleItemsCount,
});
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 || searchText) && (
<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,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,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,166 +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 {
display: inline-flex;
align-items: center;
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,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,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,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,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,281 +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,
};
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('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,142 +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;
}
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,
];
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,
}: SectionedValuesInput): SectionedValuesOutput {
const items = useMemo(() => {
const selectedSet = buildSelectedSet(
currentFilterState,
isSomeFilterPresentForCurrentAttribute,
isNotInOperator,
);
// Combine all values - API already filters both arrays by searchText
const allUniqueValues = Array.from(new Set([...relatedValues, ...allValues]));
// Include selected values at top - may not be in API response
const finalValues = [
...new Set([...Array.from(selectedSet), ...allUniqueValues]),
];
const relatedSet = new Set(relatedValues);
return deriveItems(finalValues, relatedSet, selectedSet, {
isNotInOperator,
hasExistingQuery,
hasFilterForThisKey: isSomeFilterPresentForCurrentAttribute,
});
}, [
relatedValues,
allValues,
currentFilterState,
isSomeFilterPresentForCurrentAttribute,
isNotInOperator,
hasExistingQuery,
]);
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

@@ -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

@@ -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,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 logEvent from 'api/common/logEvent';
@@ -9,10 +9,7 @@ import { FeatureKeys } from 'constants/features';
import K8sBaseDetails from 'container/InfraMonitoringK8s/Base/K8sBaseDetails';
import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
import {
InfraMonitoringEntity,
METRIC_NAMESPACE_BY_ENTITY,
} from 'container/InfraMonitoringK8s/constants';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringPageListing,
@@ -20,8 +17,6 @@ import {
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { useAppContext } from 'providers/App/App';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
@@ -62,17 +57,6 @@ function Hosts(): JSX.Element {
entityVersion: '',
});
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]);
// Track previous urlFilters to only sync when the value actually changes
// (not when handleChangeQueryData changes due to query updates)
const prevUrlFiltersRef = useRef<string | null>(null);
@@ -171,12 +155,6 @@ function Hosts(): JSX.Element {
config={getHostsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleQuickFiltersChange}
useFieldApis={{
metricNamespace:
METRIC_NAMESPACE_BY_ENTITY[InfraMonitoringEntity.HOSTS],
startUnixMilli,
endUnixMilli,
}}
/>
</div>
)}

View File

@@ -66,6 +66,20 @@
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.ant-collapse-header) {
border-bottom: 1px solid var(--l1-border);
padding: 12px 8px;
}
:global(.ant-collapse-content-box) {
padding: 0 !important;
padding-block: 0 !important;
:global(.quick-filters .checkbox-filter) {
padding-left: 18px;
}
}
:global(.quick-filters) {
overflow-y: auto;
overflow-x: hidden;
@@ -97,6 +111,22 @@
justify-content: space-between;
}
.quickFiltersCategoryLabel {
display: flex;
align-items: center;
gap: 4px;
}
.quickFiltersCategoryLabelIcon {
margin-right: 8px;
}
.quickFiltersCategoryLabelContainer {
display: flex;
align-items: center;
gap: 4px;
}
.listContainer {
flex: 1;
min-width: 0;
@@ -130,106 +160,3 @@
.listContainerFiltersVisible {
max-width: calc(100% - 280px);
}
.categorySelectorSection {
padding: var(--spacing-4);
}
.sectionHeader {
display: flex;
align-items: center;
gap: var(--spacing-4);
margin-bottom: var(--spacing-4);
&[data-type='filter'] {
padding: 0 var(--spacing-4);
}
}
.sectionLabel {
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;
}
.sectionLine {
flex: 1;
height: 1px;
background: var(--l1-border);
}
.categoryCard {
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--spacing-3);
padding: var(--spacing-2);
}
.categoryList {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.categoryItem {
display: flex;
align-items: center;
gap: var(--spacing-5);
padding: var(--spacing-3) var(--spacing-4);
border: none;
border-radius: var(--spacing-2);
background: transparent;
cursor: pointer;
width: 100%;
text-align: left;
transition:
background-color 0.2s ease,
color 0.2s ease;
color: var(--l2-foreground);
font-size: var(--periscope-font-size-base);
--typography-color: var(--l1-foreground);
&:hover:not(.categoryItemSelected) {
background: var(--l2-background-hover);
}
svg {
color: var(--l3-foreground);
flex-shrink: 0;
transition: color 0.2s ease;
}
&.categoryItemSelected {
background: var(--accent-primary);
color: var(--l1-background);
font-weight: 600;
&:hover {
background: var(--accent-primary-hover, var(--accent-primary));
}
svg {
color: var(--l1-foreground);
}
}
}
.quickFiltersSection {
flex: 1;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
}

View File

@@ -1,13 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as Sentry from '@sentry/react';
import { Button, Tooltip } from 'antd';
import { Button, CollapseProps } from 'antd';
import { Collapse, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import {
QuickFilterCheckboxUseFieldApis,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
@@ -25,8 +23,6 @@ import {
Workflow,
} from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { FeatureKeys } from '../../constants/features';
@@ -42,9 +38,7 @@ import {
GetPodsQuickFiltersConfig,
GetStatefulsetsQuickFiltersConfig,
GetVolumesQuickFiltersConfig,
InfraMonitoringEntity,
K8sCategories,
METRIC_NAMESPACE_BY_ENTITY,
} from './constants';
import K8sDaemonSetsList from './DaemonSets/K8sDaemonSetsList';
import K8sDeploymentsList from './Deployments/K8sDeploymentsList';
@@ -104,26 +98,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || 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 handleFilterChange = (query: Query): void => {
// update the current query with the new filters
// in infra monitoring k8s, we are using only one query, hence updating the 0th index of queryData
@@ -139,79 +113,192 @@ export default function InfraMonitoringK8s(): JSX.Element {
});
};
const categories = useMemo(
() => [
{
key: K8sCategories.PODS,
label: 'Pods',
icon: <Container size={14} />,
config: GetPodsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.NODES,
label: 'Nodes',
icon: <Workflow size={14} />,
config: GetNodesQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.NAMESPACES,
label: 'Namespaces',
icon: <FilePenLine size={14} />,
config: GetNamespaceQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.CLUSTERS,
label: 'Clusters',
icon: <Boxes size={14} />,
config: GetClustersQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.DEPLOYMENTS,
label: 'Deployments',
icon: <Computer size={14} />,
config: GetDeploymentsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.JOBS,
label: 'Jobs',
icon: <Bolt size={14} />,
config: GetJobsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.DAEMONSETS,
label: 'DaemonSets',
icon: <Group size={14} />,
config: GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.STATEFULSETS,
label: 'StatefulSets',
icon: <ArrowUpDown size={14} />,
config: GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.VOLUMES,
label: 'Volumes',
icon: <HardDrive size={14} />,
config: GetVolumesQuickFiltersConfig(dotMetricsEnabled),
},
],
[dotMetricsEnabled],
const renderCategoryLabel = (
icon: JSX.Element,
label: string,
): JSX.Element => (
<div className={styles.quickFiltersCategoryLabel}>
<div className={styles.quickFiltersCategoryLabelContainer}>
{icon}
<Typography.Text>{label}</Typography.Text>
</div>
</div>
);
const selectedCategoryConfig = useMemo(
() => categories.find((cat) => cat.key === selectedCategory)?.config,
[categories, selectedCategory],
);
const items: CollapseProps['items'] = [
{
label: renderCategoryLabel(
<Container size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Pods',
),
key: K8sCategories.PODS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetPodsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Workflow size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Nodes',
),
key: K8sCategories.NODES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetNodesQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<FilePenLine size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Namespaces',
),
key: K8sCategories.NAMESPACES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetNamespaceQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Boxes size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Clusters',
),
key: K8sCategories.CLUSTERS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetClustersQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Computer size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Deployments',
),
key: K8sCategories.DEPLOYMENTS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetDeploymentsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Bolt size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Jobs',
),
key: K8sCategories.JOBS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetJobsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Group size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'DaemonSets',
),
key: K8sCategories.DAEMONSETS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<ArrowUpDown size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'StatefulSets',
),
key: K8sCategories.STATEFULSETS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<HardDrive size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Volumes',
),
key: K8sCategories.VOLUMES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetVolumesQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
// TODO: Enable once we have implemented containers.
// {
// label: (
// <div className="k8s-quick-filters-category-label">
// <div className="k8s-quick-filters-category-label-container">
// <PackageOpen
// size={14}
// className="k8s-quick-filters-category-label-icon"
// />
// <Typography.Text>Containers</Typography.Text>
// </div>
// </div>
// ),
// key: K8sCategories.CONTAINERS,
// showArrow: false,
// children: (
// <QuickFilters
// source={QuickFiltersSource.INFRA_MONITORING}
// config={ContainersQuickFiltersConfig}
// handleFilterVisibilityChange={handleFilterVisibilityChange}
// onFilterChange={handleFilterChange}
// />
// ),
// },
];
const selectedCategoryUseFieldApis = useMemo(
() => getUseFieldApis(selectedCategory as InfraMonitoringEntity),
[getUseFieldApis, selectedCategory],
);
const handleCategorySelect = (key: string): void => {
if (key !== selectedCategory) {
setSelectedCategory(key);
const handleCategoryChange = (key: string | string[]): void => {
if (Array.isArray(key) && key.length > 0) {
setSelectedCategory(key[0] as string);
// Reset filters
setUrlFilters(null);
setOrderBy(null);
@@ -245,59 +332,26 @@ export default function InfraMonitoringK8s(): JSX.Element {
<div className={styles.infraContentRow}>
{showFilters && (
<div className={styles.quickFiltersContainer}>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
Viewing · Resource
</Typography.Text>
<div className={styles.sectionLine} />
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
</Tooltip>
</div>
<div className={styles.categoryCard}>
<div className={styles.categoryList}>
{categories.map((category) => (
<button
key={category.key}
type="button"
className={`${styles.categoryItem} ${
selectedCategory === category.key
? styles.categoryItemSelected
: ''
}`}
onClick={(): void => handleCategorySelect(category.key)}
data-testid={`category-${category.key}`}
>
{category.icon}
<Typography.Text>{category.label}</Typography.Text>
</button>
))}
</div>
</div>
</div>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<div className={styles.quickFiltersSection}>
<div className={styles.sectionHeader} data-type="filter">
<Typography.Text className={styles.sectionLabel}>
Filter by
</Typography.Text>
<div className={styles.sectionLine} />
</div>
{selectedCategoryConfig && (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={selectedCategoryConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
useFieldApis={selectedCategoryUseFieldApis}
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
)}
</Tooltip>
</div>
<Collapse
onChange={handleCategoryChange}
items={items}
defaultActiveKey={[selectedCategory]}
activeKey={[selectedCategory]}
accordion
bordered={false}
ghost
/>
</div>
)}

View File

@@ -21,21 +21,6 @@ export enum InfraMonitoringEntity {
VOLUMES = 'volumes',
}
export const METRIC_NAMESPACE_BY_ENTITY: Record<InfraMonitoringEntity, string> =
{
[InfraMonitoringEntity.HOSTS]: 'system.',
[InfraMonitoringEntity.PODS]: 'k8s.pod.',
[InfraMonitoringEntity.NODES]: 'k8s.node.',
[InfraMonitoringEntity.NAMESPACES]: 'k8s.pod.',
[InfraMonitoringEntity.CLUSTERS]: 'k8s.node.',
[InfraMonitoringEntity.DEPLOYMENTS]: 'k8s.',
[InfraMonitoringEntity.STATEFULSETS]: 'k8s.',
[InfraMonitoringEntity.DAEMONSETS]: 'k8s.',
[InfraMonitoringEntity.CONTAINERS]: 'k8s.pod.',
[InfraMonitoringEntity.JOBS]: 'k8s.',
[InfraMonitoringEntity.VOLUMES]: 'k8s.volume.',
};
export enum VIEWS {
METRICS = 'metrics',
LOGS = 'logs',

View File

@@ -0,0 +1,116 @@
import { act, renderHook } from '@testing-library/react';
import { downloadFile } from 'lib/exportData/downloadFile';
import { ExportFormat, TimeseriesShape } from 'lib/exportData/types';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { useClientExport } from '../useClientExport';
jest.mock('lib/exportData/downloadFile', () => ({ downloadFile: jest.fn() }));
const mockMessageError = jest.fn();
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
return {
...actual,
message: { error: (...args: unknown[]): void => mockMessageError(...args) },
};
});
const mockDownloadFile = downloadFile as jest.Mock;
function timeSeriesResponse(): QueryRangeResponseV5 {
return {
type: 'time_series',
data: {
results: [
{
queryName: 'A',
aggregations: [
{
index: 0,
alias: '',
meta: {},
series: [
{
labels: [{ key: { name: 'service' }, value: 'a' }],
values: [{ timestamp: 1000, value: 12 }],
},
],
},
],
},
],
},
meta: {},
} as unknown as QueryRangeResponseV5;
}
describe('useClientExport', () => {
beforeEach(() => jest.clearAllMocks());
it('exports time_series as CSV to <fileName>.csv', async () => {
const { result } = renderHook(() =>
useClientExport({
response: timeSeriesResponse(),
fileName: 'chart',
legendMap: { A: '{{service}}' },
}),
);
await act(async () => {
await result.current.handleExport({
format: ExportFormat.Csv,
shape: TimeseriesShape.Long,
});
});
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
const [content, name, mime] = mockDownloadFile.mock.calls[0];
expect(name).toBe('chart.csv');
expect(mime).toContain('text/csv');
expect(content).toContain('service');
expect(content).toContain('a');
});
it('exports as JSONL to <fileName>.jsonl with the ndjson mime', async () => {
const { result } = renderHook(() =>
useClientExport({ response: timeSeriesResponse() }),
);
await act(async () => {
await result.current.handleExport({ format: ExportFormat.Jsonl });
});
const [content, name, mime] = mockDownloadFile.mock.calls[0];
expect(name).toBe('export.jsonl');
expect(mime).toContain('ndjson');
expect(content).toContain('"series"');
});
it('does nothing when there is no response', async () => {
const { result } = renderHook(() => useClientExport({}));
await act(async () => {
await result.current.handleExport({ format: ExportFormat.Csv });
});
expect(mockDownloadFile).not.toHaveBeenCalled();
expect(mockMessageError).not.toHaveBeenCalled();
});
it('shows an error and does not download for unsupported result types', async () => {
const raw = {
type: 'raw',
data: { results: [] },
meta: {},
} as unknown as QueryRangeResponseV5;
const { result } = renderHook(() => useClientExport({ response: raw }));
await act(async () => {
await result.current.handleExport({ format: ExportFormat.Csv });
});
expect(mockDownloadFile).not.toHaveBeenCalled();
expect(mockMessageError).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,106 @@
import { message } from 'antd';
import { downloadFile } from 'lib/exportData/downloadFile';
import { exportScalarData } from 'lib/exportData/exportScalarData';
import { exportTimeseriesData } from 'lib/exportData/exportTimeseriesData';
import { toCsv } from 'lib/exportData/toCsv';
import { toJsonl } from 'lib/exportData/toJsonl';
import {
ExportFormat,
SerializedTable,
TimeseriesShape,
} from 'lib/exportData/types';
import { useCallback, useState } from 'react';
import {
QueryRangeResponseV5,
ScalarData,
TimeSeriesData,
} from 'types/api/v5/queryRange';
const FORMAT_META: Record<ExportFormat, { mime: string; extension: string }> = {
[ExportFormat.Csv]: { mime: 'text/csv;charset=utf-8;', extension: 'csv' },
[ExportFormat.Jsonl]: {
mime: 'application/x-ndjson;charset=utf-8;',
extension: 'jsonl',
},
};
// Picks the serializer for the response's request type. Narrows the results
// union via the response discriminant (raw/trace/distribution aren't client-exported).
function serialize(
response: QueryRangeResponseV5,
shape: TimeseriesShape,
yAxisUnit?: string,
legendMap?: Record<string, string>,
): SerializedTable {
switch (response.type) {
case 'time_series':
return exportTimeseriesData({
data: response.data.results as TimeSeriesData[],
shape,
yAxisUnit,
legendMap,
});
case 'scalar':
return exportScalarData({
data: response.data.results as ScalarData[],
yAxisUnit,
});
default:
throw new Error(`Export is not supported for "${response.type}" results`);
}
}
interface UseClientExportProps {
response?: QueryRangeResponseV5;
yAxisUnit?: string;
fileName?: string;
legendMap?: Record<string, string>;
}
interface ClientExportOptions {
format: ExportFormat;
shape?: TimeseriesShape;
}
interface UseClientExportReturn {
isExporting: boolean;
handleExport: (options: ClientExportOptions) => Promise<void>;
}
// Frontend-driven export: serializes in-memory query results and downloads them
// client-side. Backend-driven export lives in useServerExport.
export function useClientExport({
response,
yAxisUnit,
fileName = 'export',
legendMap,
}: UseClientExportProps): UseClientExportReturn {
const [isExporting, setIsExporting] = useState<boolean>(false);
const handleExport = useCallback(
async ({
format,
shape = TimeseriesShape.Long,
}: ClientExportOptions): Promise<void> => {
if (!response) {
return;
}
setIsExporting(true);
try {
const table = serialize(response, shape, yAxisUnit, legendMap);
const content =
format === ExportFormat.Jsonl ? toJsonl(table) : toCsv(table);
const { mime, extension } = FORMAT_META[format];
await downloadFile(content, `${fileName}.${extension}`, mime);
} catch {
message.error('Failed to export data. Please try again.');
} finally {
setIsExporting(false);
}
},
[response, yAxisUnit, fileName, legendMap],
);
return { isExporting, handleExport };
}

View File

@@ -0,0 +1,75 @@
import { downloadFile } from '../downloadFile';
// jsdom doesn't implement the object-URL APIs; define stubs so jest.spyOn can wrap them.
if (typeof URL.createObjectURL !== 'function') {
URL.createObjectURL = (): string => '';
}
if (typeof URL.revokeObjectURL !== 'function') {
URL.revokeObjectURL = (): void => undefined;
}
type PickerWindow = { showSaveFilePicker?: unknown };
describe('downloadFile', () => {
afterEach(() => {
jest.restoreAllMocks();
delete (window as unknown as PickerWindow).showSaveFilePicker;
});
it('writes via the Save-As dialog when available, prefilled with the file name', async () => {
const write = jest.fn().mockResolvedValue(undefined);
const close = jest.fn().mockResolvedValue(undefined);
const createWritable = jest.fn().mockResolvedValue({ write, close });
const showSaveFilePicker = jest.fn().mockResolvedValue({ createWritable });
(window as unknown as PickerWindow).showSaveFilePicker = showSaveFilePicker;
const createElement = jest.spyOn(document, 'createElement');
await downloadFile('hello', 'export.csv', 'text/csv');
expect(showSaveFilePicker).toHaveBeenCalledWith({
suggestedName: 'export.csv',
});
expect(write).toHaveBeenCalledWith('hello');
expect(close).toHaveBeenCalledTimes(1);
expect(createElement).not.toHaveBeenCalled();
});
it('does nothing when the user cancels the Save-As dialog', async () => {
const showSaveFilePicker = jest
.fn()
.mockRejectedValue(new DOMException('aborted', 'AbortError'));
(window as unknown as PickerWindow).showSaveFilePicker = showSaveFilePicker;
const createElement = jest.spyOn(document, 'createElement');
await downloadFile('hello', 'export.csv', 'text/csv');
expect(createElement).not.toHaveBeenCalled();
});
it('falls back to an anchor download when the Save-As API is unavailable', async () => {
const click = jest.fn();
const remove = jest.fn();
const anchor = {
href: '',
download: '',
click,
remove,
} as unknown as HTMLAnchorElement;
(
jest.spyOn(document, 'createElement') as unknown as jest.Mock
).mockReturnValue(anchor);
const createObjectURL = jest
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:mock');
const revokeObjectURL = jest.spyOn(URL, 'revokeObjectURL');
await downloadFile('hello', 'export.csv', 'text/csv');
expect(anchor.download).toBe('export.csv');
expect(anchor.href).toBe('blob:mock');
expect(click).toHaveBeenCalledTimes(1);
expect(createObjectURL).toHaveBeenCalled();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock');
});
});

View File

@@ -0,0 +1,205 @@
import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
import { exportTimeseriesData } from '../exportTimeseriesData';
import { TimeseriesShape } from '../types';
const iso = (ms: number): string => new Date(ms).toISOString();
function makeSeries(
labels: Record<string, string>,
values: [number, number][],
): TimeSeries {
return {
labels: Object.entries(labels).map(([name, value]) => ({
key: { name },
value,
})),
values: values.map(([timestamp, value]) => ({ timestamp, value })),
};
}
function makeQuery(
queryName: string,
buckets: { index?: number; alias?: string; series: TimeSeries[] }[],
): TimeSeriesData {
return {
queryName,
aggregations: buckets.map((bucket, i) => ({
index: bucket.index ?? i,
alias: bucket.alias ?? '',
meta: {},
series: bucket.series,
})),
};
}
describe('exportTimeseriesData', () => {
it('LONG: query column, label column, unit in value header, legend naming', () => {
const data = [
makeQuery('A', [
{
series: [
makeSeries({ service_name: 'frontend' }, [
[1000, 12],
[2000, 15],
]),
],
},
]),
];
const table = exportTimeseriesData({
data,
shape: TimeseriesShape.Long,
yAxisUnit: 'ms',
legendMap: { A: '{{service_name}}' },
});
expect(table.headers).toStrictEqual([
'timestamp',
'query',
'series',
'service_name',
'value (ms)',
]);
expect(table.rows).toStrictEqual([
[iso(1000), 'A', 'frontend', 'frontend', 12],
[iso(2000), 'A', 'frontend', 'frontend', 15],
]);
});
it('LONG: no legend falls back to the label-set name from getLabelName', () => {
const data = [
makeQuery('A', [
{ series: [makeSeries({ service_name: 'frontend' }, [[1000, 12]])] },
]),
];
const table = exportTimeseriesData({ data, shape: TimeseriesShape.Long });
expect(table.headers).toStrictEqual([
'timestamp',
'query',
'series',
'service_name',
'value',
]);
expect(table.rows).toStrictEqual([
[iso(1000), 'A', '{service_name="frontend"}', 'frontend', 12],
]);
});
it('WIDE: one column per series, sorted timestamps, blank for gaps', () => {
const data = [
makeQuery('A', [
{
series: [
makeSeries({ service: 'a' }, [
[1000, 1],
[2000, 2],
]),
makeSeries({ service: 'b' }, [[2000, 20]]),
],
},
]),
];
const table = exportTimeseriesData({
data,
shape: TimeseriesShape.Wide,
legendMap: { A: '{{service}}' },
});
expect(table.headers).toStrictEqual(['timestamp', 'a', 'b']);
expect(table.rows).toStrictEqual([
[iso(1000), 1, ''],
[iso(2000), 2, 20],
]);
});
it('WIDE: appends the y-axis unit to every series header', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'a' }, [[1000, 1]])] }]),
];
const table = exportTimeseriesData({
data,
shape: TimeseriesShape.Wide,
yAxisUnit: 'ms',
legendMap: { A: '{{service}}' },
});
expect(table.headers).toStrictEqual(['timestamp', 'a (ms)']);
expect(table.rows).toStrictEqual([[iso(1000), 1]]);
});
it('LONG multi-query: series column is not query-prefixed (query has its own column)', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'x' }, [[1000, 1]])] }]),
makeQuery('B', [{ series: [makeSeries({ service: 'y' }, [[1000, 2]])] }]),
];
const table = exportTimeseriesData({
data,
shape: TimeseriesShape.Long,
legendMap: { A: '{{service}}', B: '{{service}}' },
});
expect(table.headers).toStrictEqual([
'timestamp',
'query',
'series',
'service',
'value',
]);
expect(table.rows).toStrictEqual([
[iso(1000), 'A', 'x', 'x', 1],
[iso(1000), 'B', 'y', 'y', 2],
]);
});
it('WIDE multi-query: headers are prefixed with queryName', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'x' }, [[1000, 1]])] }]),
makeQuery('B', [{ series: [makeSeries({ service: 'y' }, [[1000, 2]])] }]),
];
const table = exportTimeseriesData({
data,
shape: TimeseriesShape.Wide,
legendMap: { A: '{{service}}', B: '{{service}}' },
});
expect(table.headers).toStrictEqual(['timestamp', 'A: x', 'B: y']);
expect(table.rows).toStrictEqual([[iso(1000), 1, 2]]);
});
it('WIDE multi-aggregation: headers are prefixed with the aggregation alias', () => {
const data = [
makeQuery('A', [
{ index: 0, alias: 'count', series: [makeSeries({}, [[1000, 5]])] },
{ index: 1, alias: 'p99', series: [makeSeries({}, [[1000, 300]])] },
]),
];
const table = exportTimeseriesData({ data, shape: TimeseriesShape.Wide });
expect(table.headers).toStrictEqual(['timestamp', 'count: A', 'p99: A']);
expect(table.rows).toStrictEqual([[iso(1000), 5, 300]]);
});
it('empty data: returns a headers-only table', () => {
expect(
exportTimeseriesData({ data: [], shape: TimeseriesShape.Long }),
).toStrictEqual({
headers: ['timestamp', 'query', 'series', 'value'],
rows: [],
});
expect(
exportTimeseriesData({ data: [], shape: TimeseriesShape.Wide }),
).toStrictEqual({
headers: ['timestamp'],
rows: [],
});
});
});

View File

@@ -0,0 +1,42 @@
import { toCsv } from '../toCsv';
import { toJsonl } from '../toJsonl';
import { SerializedTable } from '../types';
const table: SerializedTable = {
headers: ['timestamp', 'value'],
rows: [
['t1', 12],
['t2', 15],
],
};
describe('toCsv', () => {
it('emits a header row then one row per record, in column order', () => {
expect(toCsv(table).split(/\r?\n/)).toStrictEqual([
'timestamp,value',
't1,12',
't2,15',
]);
});
it('quotes values containing the delimiter', () => {
const csv = toCsv({ headers: ['name', 'value'], rows: [['a,b', 1]] });
expect(csv.split(/\r?\n/)).toStrictEqual(['name,value', '"a,b",1']);
});
it('emits only the header row when there are no data rows', () => {
expect(toCsv({ headers: ['timestamp'], rows: [] })).toBe('timestamp\r\n');
});
});
describe('toJsonl', () => {
it('emits one JSON object per row keyed by header', () => {
expect(toJsonl(table)).toBe(
'{"timestamp":"t1","value":12}\n{"timestamp":"t2","value":15}',
);
});
it('emits an empty string when there are no rows', () => {
expect(toJsonl({ headers: ['timestamp'], rows: [] })).toBe('');
});
});

View File

@@ -0,0 +1,64 @@
interface FileSystemWritableLike {
write: (data: string) => Promise<void>;
close: () => Promise<void>;
}
interface FileSystemFileHandleLike {
createWritable: () => Promise<FileSystemWritableLike>;
}
interface SaveFilePickerWindow {
showSaveFilePicker: (options: {
suggestedName?: string;
}) => Promise<FileSystemFileHandleLike>;
}
function supportsSaveFilePicker(): boolean {
return (
typeof (window as unknown as Partial<SaveFilePickerWindow>)
.showSaveFilePicker === 'function'
);
}
// Fallback for browsers without the File System Access API (Firefox/Safari):
// download straight to the default Downloads folder.
function anchorDownload(content: string, fileName: string, mime: string): void {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
link.remove();
URL.revokeObjectURL(url);
}
/**
* Prompts a Save-As dialog (prefilled with fileName) via the File System Access
* API, falling back to a direct download where it isn't supported. Silently
* returns if the user cancels the dialog.
*/
export async function downloadFile(
content: string,
fileName: string,
mime: string,
): Promise<void> {
if (supportsSaveFilePicker()) {
try {
const picker = window as unknown as SaveFilePickerWindow;
const handle = await picker.showSaveFilePicker({ suggestedName: fileName });
const writable = await handle.createWritable();
await writable.write(content);
await writable.close();
return;
} catch (error) {
// User dismissed the dialog — nothing to download.
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
// Any other failure: fall back to a direct download.
}
}
anchorDownload(content, fileName, mime);
}

View File

@@ -0,0 +1,16 @@
import { ScalarData } from 'types/api/v5/queryRange';
import { SerializedTable } from './types';
interface ExportScalarDataArgs {
data: ScalarData[];
yAxisUnit?: string;
}
/**
* Serializes a V5 scalar (table/value/pie) result into a table.
* TODO(#5591): implement — stubbed so useClientExport's dispatch compiles.
*/
export function exportScalarData(_args: ExportScalarDataArgs): SerializedTable {
throw new Error('Scalar export is not implemented yet');
}

View File

@@ -0,0 +1,223 @@
import getLabelName from 'lib/getLabelName';
import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
import { SerializedTable, TimeseriesShape } from './types';
interface ExportTimeseriesDataArgs {
data: TimeSeriesData[];
shape: TimeseriesShape;
yAxisUnit?: string;
legendMap?: Record<string, string>;
}
// One row of the flattened V5 tree: a single (query, aggregation, label-set) series.
interface FlatSeries {
queryName: string;
aggIndex: number;
alias: string;
labels: Record<string, string>;
baseName: string;
values: { timestamp: number; value: number }[];
}
// V5 labels [{key:{name}, value}] → {name: value} (the getLabelName contract).
function foldLabels(labels: TimeSeries['labels']): Record<string, string> {
const record: Record<string, string> = {};
(labels ?? []).forEach((label) => {
if (label.key?.name) {
record[label.key.name] = String(label.value);
}
});
return record;
}
// Walk results → aggregations → series into a flat, named list.
function flatten(
data: TimeSeriesData[],
legendMap?: Record<string, string>,
): FlatSeries[] {
const flat: FlatSeries[] = [];
data.forEach((query) => {
const queryName = query.queryName ?? '';
const legend = legendMap?.[queryName] ?? '';
(query.aggregations ?? []).forEach((bucket) => {
(bucket.series ?? []).forEach((series) => {
const labels = foldLabels(series.labels);
flat.push({
queryName,
aggIndex: bucket.index ?? 0,
alias: bucket.alias ?? '',
labels,
baseName: getLabelName(labels, queryName, legend),
values: (series.values ?? []).map((value) => ({
timestamp: value.timestamp,
value: value.value,
})),
});
});
});
});
return flat;
}
interface Qualifiers {
manyQueries: boolean;
multiAggByQuery: Record<string, boolean>;
}
// A series name only needs a query/aggregation prefix when that dimension is
// ambiguous — >1 query overall, or >1 aggregation within its query.
function computeQualifiers(flat: FlatSeries[]): Qualifiers {
const queries = new Set<string>();
const aggsByQuery: Record<string, Set<number>> = {};
flat.forEach((series) => {
queries.add(series.queryName);
if (!aggsByQuery[series.queryName]) {
aggsByQuery[series.queryName] = new Set<number>();
}
aggsByQuery[series.queryName].add(series.aggIndex);
});
const multiAggByQuery: Record<string, boolean> = {};
Object.keys(aggsByQuery).forEach((query) => {
multiAggByQuery[query] = aggsByQuery[query].size > 1;
});
return { manyQueries: queries.size > 1, multiAggByQuery };
}
function qualifiedName(
series: FlatSeries,
opts: { includeQuery: boolean; includeAlias: boolean },
): string {
let prefix = '';
if (opts.includeQuery) {
prefix += `${series.queryName}: `;
}
if (opts.includeAlias) {
const aggLabel = series.alias || `agg[${series.aggIndex}]`;
prefix += `${aggLabel}: `;
}
return `${prefix}${series.baseName}`;
}
// Guarantee unique column headers if two series still collide after qualifying.
function dedupeHeaders(names: string[]): string[] {
const counts: Record<string, number> = {};
return names.map((name) => {
if (counts[name] === undefined) {
counts[name] = 1;
return name;
}
counts[name] += 1;
return `${name} (${counts[name]})`;
});
}
// Appends the y-axis unit to a header: `value` → `value (ms)`, `frontend` →
// `frontend (ms)`. Shared by LONG's value column and every WIDE series column.
function withUnit(header: string, yAxisUnit?: string): string {
return yAxisUnit ? `${header} (${yAxisUnit})` : header;
}
function toIso(timestamp: number): string {
return new Date(timestamp).toISOString();
}
// LONG (tidy): one row per (series, timestamp). query is its own column.
function buildLong(
flat: FlatSeries[],
qualifiers: Qualifiers,
yAxisUnit?: string,
): SerializedTable {
const labelKeySet = new Set<string>();
flat.forEach((series) => {
Object.keys(series.labels).forEach((key) => labelKeySet.add(key));
});
const labelKeys = Array.from(labelKeySet).sort();
const headers = [
'timestamp',
'query',
'series',
...labelKeys,
withUnit('value', yAxisUnit),
];
const rows: (string | number)[][] = [];
flat.forEach((series) => {
const seriesName = qualifiedName(series, {
includeQuery: false,
includeAlias: qualifiers.multiAggByQuery[series.queryName],
});
series.values.forEach(({ timestamp, value }) => {
rows.push([
toIso(timestamp),
series.queryName,
seriesName,
...labelKeys.map((key) => series.labels[key] ?? ''),
value,
]);
});
});
return { headers, rows };
}
// WIDE (pivot): one row per timestamp, one column per series.
function buildWide(
flat: FlatSeries[],
qualifiers: Qualifiers,
yAxisUnit?: string,
): SerializedTable {
const timestampSet = new Set<number>();
flat.forEach((series) => {
series.values.forEach((value) => timestampSet.add(value.timestamp));
});
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
const valueBySeries = flat.map((series) => {
const map = new Map<number, number>();
series.values.forEach((value) => map.set(value.timestamp, value.value));
return map;
});
const seriesHeaders = dedupeHeaders(
flat.map((series) =>
qualifiedName(series, {
includeQuery: qualifiers.manyQueries,
includeAlias: qualifiers.multiAggByQuery[series.queryName],
}),
),
).map((header) => withUnit(header, yAxisUnit));
const headers = ['timestamp', ...seriesHeaders];
const rows: (string | number)[][] = timestamps.map((timestamp) => {
const row: (string | number)[] = [toIso(timestamp)];
valueBySeries.forEach((map) => {
const value = map.get(timestamp);
row.push(value === undefined ? '' : value);
});
return row;
});
return { headers, rows };
}
/**
* Serializes a V5 time_series result into a format-agnostic table (LONG or WIDE).
* Pure — walks the V5 tree directly and names series via the shared getLabelName.
*/
export function exportTimeseriesData({
data,
shape,
yAxisUnit,
legendMap,
}: ExportTimeseriesDataArgs): SerializedTable {
const flat = flatten(data, legendMap);
const qualifiers = computeQualifiers(flat);
return shape === TimeseriesShape.Wide
? buildWide(flat, qualifiers, yAxisUnit)
: buildLong(flat, qualifiers, yAxisUnit);
}

View File

@@ -0,0 +1,8 @@
import { unparse } from 'papaparse';
import { SerializedTable } from './types';
/** Serializes a table to CSV. `fields` pins column order regardless of row keys. */
export function toCsv(table: SerializedTable): string {
return unparse({ fields: table.headers, data: table.rows });
}

View File

@@ -0,0 +1,12 @@
import { SerializedTable } from './types';
/** Serializes a table to newline-delimited JSON: one object per row, keyed by header. */
export function toJsonl(table: SerializedTable): string {
return table.rows
.map((row) =>
JSON.stringify(
Object.fromEntries(table.headers.map((header, i) => [header, row[i]])),
),
)
.join('\n');
}

View File

@@ -0,0 +1,19 @@
/** Format-agnostic tabular result produced by every exporter. Consumed by the
* CSV/JSONL formatters */
export interface SerializedTable {
headers: string[];
// One entry per header, in header order. Empty string marks a gap.
rows: (string | number)[][];
}
/** TimeSeries export layouts: LONG (tidy, one row per point) or WIDE (pivot). */
export enum TimeseriesShape {
Long = 'long',
Wide = 'wide',
}
/** File formats a client-side export can be downloaded as. */
export enum ExportFormat {
Csv = 'csv',
Jsonl = 'jsonl',
}