Compare commits

...

3 Commits

Author SHA1 Message Date
aks07
bddd80ad9f feat: minor refactor 2026-01-08 16:01:39 +05:30
aks07
82bbc35acd feat: show badge for mulitple varient attributes 2026-01-08 08:06:50 +05:30
aks07
2aaa2a8158 fix: add unique to attribute options to avoid conflicts in matching attribute names 2026-01-06 16:35:02 +05:30
13 changed files with 520 additions and 73 deletions

View File

@@ -0,0 +1,80 @@
.field-variant-badges-container {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.field-badge {
&.data-type {
display: flex;
height: 20px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 20px;
background: color-mix(in srgb, var(--bg-vanilla-100) 8%, transparent);
white-space: nowrap;
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
&.type-tag {
display: flex;
align-items: center;
height: 20px;
padding: 0px 6px;
justify-content: center;
gap: 4px;
border-radius: 50px;
text-transform: capitalize;
white-space: nowrap;
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.text {
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
&.attribute {
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
color: var(--bg-sienna-400);
.dot {
background-color: var(--bg-sienna-400);
}
.text {
color: var(--bg-sienna-400);
}
}
&.resource {
background: color-mix(in srgb, var(--bg-aqua-400) 10%, transparent);
color: var(--bg-aqua-400);
.dot {
background-color: var(--bg-aqua-400);
}
.text {
color: var(--bg-aqua-400);
}
}
}
}

View File

@@ -0,0 +1,69 @@
import './FieldVariantBadges.styles.scss';
import cx from 'classnames';
/**
* Field contexts that should display badges
*/
export enum AllowedFieldContext {
Attribute = 'attribute',
Resource = 'resource',
}
const ALLOWED_FIELD_CONTEXTS = new Set<string>([
AllowedFieldContext.Attribute,
AllowedFieldContext.Resource,
]);
interface FieldVariantBadgesProps {
fieldDataType?: string;
fieldContext?: string;
}
/**
* Determines if a fieldContext badge should be displayed
* Only shows badges for contexts in ALLOWED_FIELD_CONTEXTS
*/
const shouldShowFieldContextBadge = (
fieldContext: string | undefined | null,
): boolean => {
if (!fieldContext) {
return false;
}
return ALLOWED_FIELD_CONTEXTS.has(fieldContext);
};
function FieldVariantBadges({
fieldDataType,
fieldContext,
}: FieldVariantBadgesProps): JSX.Element | null {
// If neither value exists, don't render anything
if (!fieldDataType && !fieldContext) {
return null;
}
// Check if fieldContext should be displayed
const showFieldContext =
fieldContext && shouldShowFieldContextBadge(fieldContext);
return (
<span className="field-variant-badges-container">
{fieldDataType && (
<span className="field-badge data-type">{fieldDataType}</span>
)}
{showFieldContext && (
<section className={cx('field-badge type-tag', fieldContext)}>
<div className="dot" />
<span className="text">{fieldContext}</span>
</section>
)}
</span>
);
}
FieldVariantBadges.defaultProps = {
fieldDataType: undefined,
fieldContext: undefined,
};
export default FieldVariantBadges;

View File

@@ -16,7 +16,7 @@
z-index: 2;
position: relative;
right: -2px;
width: 240px;
width: 280px;
.font-size-dropdown {
display: flex;
@@ -314,6 +314,23 @@
background-color: var(--bg-ink-200);
cursor: pointer;
}
.name-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
cursor: pointer;
}
}
&::-webkit-scrollbar {
@@ -402,12 +419,20 @@
cursor: pointer;
}
.name {
flex: 1;
overflow: hidden;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.name-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
width: calc(100% - 26px);
gap: 8px;
min-width: 0;
.name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
cursor: pointer;
}

View File

@@ -6,8 +6,14 @@ import './LogsFormatOptionsMenu.styles.scss';
import { Button, Input, InputNumber, Popover, Tooltip, Typography } from 'antd';
import { DefaultOptionType } from 'antd/es/select';
import cx from 'classnames';
import FieldVariantBadges from 'components/FieldVariantBadges/FieldVariantBadges';
import { LogViewMode } from 'container/LogsTable';
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
import {
buildAttributeKey,
getOptionLabelCounts,
parseAttributeKey,
} from 'container/OptionsMenu/utils';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import {
Check,
@@ -18,7 +24,7 @@ import {
Sliders,
X,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
interface LogsFormatOptionsMenuProps {
items: any;
@@ -208,6 +214,16 @@ function OptionsMenu({
};
}, [selectedValue]);
const optionsLabelCounts = useMemo(() => {
const optionsKeys: { key: string }[] =
addColumn?.options?.map((option) => ({ key: String(option.value) })) || [];
const valuesKeys: { key: string }[] =
addColumn?.value?.map((column) => ({ key: buildAttributeKey(column) })) ||
[];
return getOptionLabelCounts([...optionsKeys, ...valuesKeys]);
}, [addColumn?.options, addColumn?.value]);
return (
<div
className={cx(
@@ -301,33 +317,48 @@ function OptionsMenu({
)}
<div className="column-format-new-options" ref={listRef}>
{addColumn?.options?.map(({ label, value }, index) => (
<div
className={cx('column-name', value === selectedValue && 'selected')}
key={value}
onMouseEnter={(): void => {
if (!initialMouseEnterRef.current) {
setSelectedValue(value as string | null);
}
{addColumn?.options?.map(({ label, value }, index) => {
const { name, fieldContext, fieldDataType } = parseAttributeKey(
String(value),
);
const hasMultipleVariants = (optionsLabelCounts[name] || 0) > 1;
return (
<div
className={cx('column-name', value === selectedValue && 'selected')}
key={value}
onMouseEnter={(): void => {
if (!initialMouseEnterRef.current) {
setSelectedValue(value as string | null);
}
initialMouseEnterRef.current = true;
}}
onMouseMove={(): void => {
// this is added to handle the mouse move explicit event and not the re-rendered on mouse enter event
setSelectedValue(value as string | null);
}}
onClick={(eve): void => {
eve.stopPropagation();
handleColumnSelection(index, addColumn?.options || []);
}}
>
<div className="name">
<Tooltip placement="left" title={label}>
{label}
</Tooltip>
initialMouseEnterRef.current = true;
}}
onMouseMove={(): void => {
// this is added to handle the mouse move explicit event and not the re-rendered on mouse enter event
setSelectedValue(value as string | null);
}}
onClick={(eve): void => {
eve.stopPropagation();
handleColumnSelection(index, addColumn?.options || []);
}}
>
<div className="name-wrapper">
<Tooltip placement="left" title={label}>
<span className="name">{label}</span>
</Tooltip>
{hasMultipleVariants && (
<span className="field-variant-badges">
<FieldVariantBadges
fieldDataType={fieldDataType}
fieldContext={fieldContext}
/>
</span>
)}
</div>
</div>
</div>
))}
);
})}
</div>
</div>
</div>
@@ -416,22 +447,34 @@ function OptionsMenu({
)}
<div className="column-format">
{addColumn?.value?.map(({ name }) => (
<div className="column-name" key={name}>
<div className="name">
<Tooltip placement="left" title={name}>
{name}
{addColumn?.value?.map((column) => {
const uniqueKey = buildAttributeKey(column);
const showBadge = (optionsLabelCounts[column.name] || 0) > 1;
return (
<div className="column-name" key={uniqueKey}>
<Tooltip placement="left" title={column.name}>
<div className="name-wrapper">
<span className="name">{column.name}</span>
{showBadge && (
<span className="field-variant-badges">
<FieldVariantBadges
fieldDataType={column.fieldDataType}
fieldContext={column.fieldContext}
/>
</span>
)}
</div>
</Tooltip>
{addColumn?.value?.length > 1 && (
<X
className="delete-btn"
size={14}
onClick={(): void => addColumn.onRemove(uniqueKey)}
/>
)}
</div>
{addColumn?.value?.length > 1 && (
<X
className="delete-btn"
size={14}
onClick={(): void => addColumn.onRemove(name)}
/>
)}
</div>
))}
);
})}
{addColumn && addColumn?.value?.length === 0 && (
<div className="column-name no-columns-selected">
No columns selected

View File

@@ -1,5 +1,6 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { FontSize } from 'container/OptionsMenu/types';
import { fireEvent, render, waitFor } from 'tests/test-utils';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import LogsFormatOptionsMenu from '../LogsFormatOptionsMenu';
@@ -56,7 +57,16 @@ describe('LogsFormatOptionsMenu (unit)', () => {
addColumn: {
isFetching: false,
value: [],
options: [],
options: [
{
label: 'http.status_code_attribute',
value: 'http.status_code_attribute::dataType_string::context_attribute',
},
{
label: 'http.status_code_attribute',
value: 'http.status_code_attribute::dataType_number::context_resource',
},
],
onFocus: jest.fn(),
onBlur: jest.fn(),
onSearch: jest.fn(),
@@ -154,4 +164,22 @@ describe('LogsFormatOptionsMenu (unit)', () => {
expect(fontSizeOnChange).toHaveBeenCalledWith(FontSize.MEDIUM);
});
});
it('renders FieldVariantBadges for duplicate-name attribute options', async () => {
setup();
// Open the "columns" section and then the add-new-column container by clicking plus
// The popover is already open from setup()
const plusInColumns = document.querySelector(
'.selected-item-content-container .title svg',
) as SVGElement;
fireEvent.click(plusInColumns);
const items = screen.getAllByText('http.status_code_attribute', {
exact: true,
});
expect(items).toHaveLength(2);
expect(screen.getByText('context_attribute')).toBeInTheDocument();
expect(screen.getByText('context_resource')).toBeInTheDocument();
});
});

View File

@@ -1,22 +1,76 @@
import { SearchOutlined } from '@ant-design/icons';
import { Input, Spin, Typography } from 'antd';
import { Input, Spin } from 'antd';
import { BaseOptionType } from 'antd/es/select';
import FieldVariantBadges from 'components/FieldVariantBadges/FieldVariantBadges';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { FieldTitle } from '../styles';
import { OptionsMenuConfig } from '../types';
import {
buildAttributeKey,
getOptionLabelCounts,
parseAttributeKey,
} from '../utils';
import {
AddColumnItem,
AddColumnSelect,
AddColumnWrapper,
DeleteOutlinedIcon,
Name,
NameWrapper,
OptionContent,
SearchIconWrapper,
} from './styles';
function OptionRenderer({
option,
optionsLabelCounts,
}: {
option: BaseOptionType;
optionsLabelCounts: Record<string, number>;
}): JSX.Element {
const { label, data } = option;
const key = data?.value as string;
const { name, fieldContext, fieldDataType } = parseAttributeKey(key);
const hasMultipleVariants = (optionsLabelCounts[name] || 0) > 1;
return (
<OptionContent>
<span className="option-label">{label}</span>
{hasMultipleVariants && (
<FieldVariantBadges
fieldDataType={fieldDataType}
fieldContext={fieldContext}
/>
)}
</OptionContent>
);
}
function AddColumnField({ config }: AddColumnFieldProps): JSX.Element | null {
const { t } = useTranslation(['trace']);
const isDarkMode = useIsDarkMode();
const optionsLabelCounts = useMemo(() => {
if (!config) return {};
const optionsKeys: { key: string }[] =
config?.options?.map((option) => ({ key: String(option.value) })) || [];
const valuesKeys: { key: string }[] =
config?.value?.map((column) => ({ key: buildAttributeKey(column) })) || [];
return getOptionLabelCounts([...optionsKeys, ...valuesKeys]);
}, [config]);
const renderOption = useCallback(
function renderOption(option: BaseOptionType): JSX.Element {
return (
<OptionRenderer option={option} optionsLabelCounts={optionsLabelCounts} />
);
},
[optionsLabelCounts],
);
if (!config) return null;
return (
@@ -33,21 +87,32 @@ function AddColumnField({ config }: AddColumnFieldProps): JSX.Element | null {
value={[]}
onSelect={config.onSelect}
onSearch={config.onSearch}
onFocus={config.onFocus}
onBlur={config.onBlur}
notFoundContent={config.isFetching ? <Spin size="small" /> : null}
optionRender={renderOption}
/>
<SearchIconWrapper $isDarkMode={isDarkMode}>
<SearchOutlined />
</SearchIconWrapper>
</Input.Group>
{config.value?.map(({ name }) => (
<AddColumnItem direction="horizontal" key={name}>
<Typography>{name}</Typography>
<DeleteOutlinedIcon onClick={(): void => config.onRemove(name)} />
</AddColumnItem>
))}
{config.value?.map((column) => {
const uniqueKey = buildAttributeKey(column);
const showBadge = (optionsLabelCounts[column.name] || 0) > 1;
return (
<AddColumnItem key={uniqueKey}>
<NameWrapper>
<Name>{column.name}</Name>
{showBadge && (
<FieldVariantBadges
fieldDataType={column.fieldDataType}
fieldContext={column.fieldContext}
/>
)}
</NameWrapper>
<DeleteOutlinedIcon onClick={(): void => config.onRemove(uniqueKey)} />
</AddColumnItem>
);
})}
</AddColumnWrapper>
);
}

View File

@@ -28,8 +28,9 @@ export const AddColumnWrapper = styled(Space)`
width: 100%;
`;
export const AddColumnItem = styled(Space)`
export const AddColumnItem = styled.div`
width: 100%;
min-width: 300px;
display: flex;
justify-content: space-between;
`;
@@ -37,3 +38,35 @@ export const AddColumnItem = styled(Space)`
export const DeleteOutlinedIcon = styled(DeleteOutlined)`
color: red;
`;
export const OptionContent = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 8px;
min-width: 0;
.option-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
export const NameWrapper = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
width: calc(100% - 26px);
gap: 8px;
min-width: 0;
`;
export const Name = styled.span`
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;

View File

@@ -52,7 +52,7 @@ export const defaultTraceSelectedColumns: TelemetryFieldKey[] = [
name: 'duration_nano',
signal: 'traces',
fieldContext: 'span',
fieldDataType: '',
fieldDataType: 'number',
},
{
name: 'http_method',

View File

@@ -41,7 +41,18 @@ function OptionsMenu({
return (
<OptionsContainer>
<Popover placement="bottom" trigger="click" content={OptionsContent}>
<Popover
placement="bottom"
trigger="click"
content={OptionsContent}
onOpenChange={(open: boolean): void => {
if (!open) {
config?.addColumn?.onBlur?.();
} else {
config?.addColumn?.onFocus?.();
}
}}
>
<Space align="center">
{t('options_menu.options')}
<SettingIcon />

View File

@@ -31,12 +31,11 @@ export type OptionsMenuConfig = {
};
maxLines?: Pick<InputNumberProps, 'value' | 'onChange'>;
fontSize?: FontSizeProps;
addColumn?: Pick<
SelectProps,
'options' | 'onSelect' | 'onFocus' | 'onSearch' | 'onBlur'
> & {
addColumn?: Pick<SelectProps, 'options' | 'onSelect' | 'onSearch'> & {
isFetching: boolean;
value: TelemetryFieldKey[];
onRemove: (key: string) => void;
onFocus?: () => void;
onBlur?: () => void;
};
};

View File

@@ -36,7 +36,7 @@ import {
OptionsMenuConfig,
OptionsQuery,
} from './types';
import { getOptionsFromKeys } from './utils';
import { buildAttributeKey, getOptionsFromKeys } from './utils';
interface UseOptionsMenuProps {
storageKey?: string;
@@ -262,7 +262,7 @@ const useOptionsMenu = ({
}, [dataSource, initialOptions, initialSelectedColumns]);
const selectedColumnKeys = useMemo(
() => preferences?.columns?.map(({ name }) => name) || [],
() => preferences?.columns?.map(buildAttributeKey) || [],
[preferences?.columns],
);
@@ -292,7 +292,7 @@ const useOptionsMenu = ({
const column = [
...searchedAttributeKeys,
...(preferences?.columns || []),
].find(({ name }) => name === key);
].find((k) => buildAttributeKey(k) === key);
if (!column) return acc;
return [...acc, column];
@@ -321,7 +321,7 @@ const useOptionsMenu = ({
const handleRemoveSelectedColumn = useCallback(
(columnKey: string) => {
const newSelectedColumns = preferences?.columns?.filter(
({ name }) => name !== columnKey,
(k) => buildAttributeKey(k) !== columnKey,
);
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {

View File

@@ -1,16 +1,42 @@
import { SelectProps } from 'antd';
import { TelemetryFieldKey } from 'api/v5/v5';
export const buildAttributeKey = (k: TelemetryFieldKey): string =>
`${k?.name || ''}::${k?.fieldContext || ''}::${k?.fieldDataType || ''}`;
export const getOptionsFromKeys = (
keys: TelemetryFieldKey[],
selectedKeys: (string | undefined)[],
selectedKeys: string[],
): SelectProps['options'] => {
const options = keys.map(({ name }) => ({
label: name,
value: name,
const options = keys.map((k) => ({
label: k.name,
value: buildAttributeKey(k),
}));
return options.filter(
({ value }) => !selectedKeys.find((key) => key === value),
);
};
export const parseAttributeKey = (
key: string,
): {
name: string;
fieldContext: string;
fieldDataType: string;
} => {
const [name = '', fieldContext = '', fieldDataType = ''] = key.split('::');
return { name, fieldContext, fieldDataType };
};
export const getOptionLabelCounts = (
options: Array<{ key: string }>,
): Record<string, number> => {
const labelCounts: Record<string, number> = {};
(options || []).forEach(({ key }) => {
const { name } = parseAttributeKey(key || '');
if (!name) return;
labelCounts[name] = (labelCounts[name] || 0) + 1;
});
return labelCounts;
};

View File

@@ -1,3 +1,6 @@
/* eslint-disable sonarjs/no-duplicate-string */
import userEvent from '@testing-library/user-event';
import { ENVIRONMENT } from 'constants/env';
import { rest, server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
@@ -186,3 +189,68 @@ describe('Traces ListView - Error and Empty States', () => {
});
});
});
describe('AttributeKey handling (UI)', () => {
const BASE_URL = ENVIRONMENT.baseURL;
beforeEach(() => {
server.use(
rest.get(`${BASE_URL}/api/v1/fields/keys`, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: {
attributeKeys: [
{
name: 'http.status_code_attribute',
signal: 'traces',
fieldContext: 'attribute',
fieldDataType: 'string',
},
{
name: 'http.status_code_attribute',
signal: 'traces',
fieldContext: 'attribute',
fieldDataType: 'number',
},
{
name: 'test options',
signal: 'traces',
fieldContext: 'attribute',
fieldDataType: 'string',
},
],
},
},
}),
),
),
);
});
it('opens Options menu and shows attribute variants in the Add Column select', async () => {
renderListView({ isFilterApplied: true });
verifyControlsVisibility();
// Open options popover
const optionsButton = screen.getByText(/options_menu.options|options/i);
await userEvent.click(optionsButton);
const selectSearchInput = document.querySelector(
'.ant-popover-content input.ant-select-selection-search-input',
) as HTMLInputElement;
expect(selectSearchInput).toBeInTheDocument();
await userEvent.click(selectSearchInput);
// Ensure dropdown items have rendered by waiting for a known option
await screen.findByText('test options');
expect(screen.getAllByText('http.status_code_attribute').length).toBe(2);
});
});