mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-08 10:49:56 +00:00
Compare commits
3 Commits
multiple-t
...
fix/duplic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bddd80ad9f | ||
|
|
82bbc35acd | ||
|
|
2aaa2a8158 |
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -52,7 +52,7 @@ export const defaultTraceSelectedColumns: TelemetryFieldKey[] = [
|
||||
name: 'duration_nano',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: '',
|
||||
fieldDataType: 'number',
|
||||
},
|
||||
{
|
||||
name: 'http_method',
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user