mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-30 17:50:41 +01:00
Compare commits
7 Commits
infraM/goo
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a062b3fb1 | ||
|
|
39bf182213 | ||
|
|
2247968c62 | ||
|
|
d2e10d1d9c | ||
|
|
90205a376f | ||
|
|
59c4f5c7e3 | ||
|
|
7cc728a9c8 |
@@ -45,4 +45,5 @@ export enum LOCALSTORAGE {
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
|
||||
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
|
||||
}
|
||||
|
||||
@@ -15,6 +15,18 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
mockUseAuthZDenyAll,
|
||||
mockUseAuthZGrantAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
// Admin gating on the write controls flows through useAuthZ. Mock it directly
|
||||
// (synchronous) so the functional tests stay synchronous; the read-only block
|
||||
// below flips it to deny-all.
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
import {
|
||||
GROUPS_ENDPOINT,
|
||||
makeGroupsResponse,
|
||||
@@ -102,6 +114,8 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
beforeEach(() => {
|
||||
// Reset URL state between tests — jsdom shares window.location across a file.
|
||||
window.history.pushState(null, '', '/');
|
||||
// Default to an admin; the read-only block overrides this.
|
||||
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -507,4 +521,55 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// The write APIs (create/update/delete group & mapper) are Admin-only on the
|
||||
// backend, so a non-admin gets a read-only view: the data renders, but every
|
||||
// write control is hidden.
|
||||
describe('read-only (non-admin)', () => {
|
||||
beforeEach(() => {
|
||||
mockedUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
|
||||
});
|
||||
|
||||
it('hides the "Add a new group" button', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
expect(screen.queryByTestId('add-group-row')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the group enable toggle and actions menu', async () => {
|
||||
setupGroups();
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
expect(
|
||||
screen.queryByTestId('group-enabled-group-1'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('group-actions-group-1'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders mappers read-only: no "Add mapping" button or row actions', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupGroups();
|
||||
setupMappers([makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model' })]);
|
||||
render(<AttributeMappingsTabWithStore />);
|
||||
|
||||
await screen.findByTestId('group-name-group-1');
|
||||
await expandGroup(user);
|
||||
|
||||
// The mapper still renders...
|
||||
await screen.findByTestId('mapper-target-mapper-1');
|
||||
// ...but every write control is gone.
|
||||
expect(screen.queryByTestId('add-mapper-group-1')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('mapper-enabled-mapper-1'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Mapping actions' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
|
||||
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
|
||||
import GroupActionsMenu from '../GroupActionsMenu/GroupActionsMenu';
|
||||
import styles from './GroupHeaderActions.module.scss';
|
||||
|
||||
@@ -16,7 +17,11 @@ function GroupHeaderActions({
|
||||
onToggle,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: GroupHeaderActionsProps): JSX.Element {
|
||||
}: GroupHeaderActionsProps): JSX.Element | null {
|
||||
const canManage = useCanManageAttributeMapping();
|
||||
if (!canManage) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={styles.actions}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Mapper,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
|
||||
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
|
||||
import { COLUMN_COUNT } from '../constants';
|
||||
import MapperRow from '../MapperRow/MapperRow';
|
||||
import MapperRowSkeleton from '../MapperRow/MapperRowSkeleton';
|
||||
@@ -39,6 +40,7 @@ function GroupMappers({
|
||||
onEditMapper,
|
||||
}: GroupMappersProps): JSX.Element {
|
||||
const { hydrateGroupMappers, removeMapper, toggleMapper } = editor;
|
||||
const canManage = useCanManageAttributeMapping();
|
||||
|
||||
const hasServerId = group.serverId !== null;
|
||||
const { data, isLoading, isError } = useListSpanMappers(
|
||||
@@ -144,16 +146,20 @@ function GroupMappers({
|
||||
/>
|
||||
));
|
||||
|
||||
// The add-mapping row trails every non-error state (including loading/empty).
|
||||
// The add-mapping row trails every non-error state (including loading/empty),
|
||||
// but only for users who can manage mappings — non-admins get a read-only view.
|
||||
let rows: JSX.Element[];
|
||||
if (isErrorMappers) {
|
||||
rows = [errorRow];
|
||||
} else if (isLoadingMappers && mapperCount === 0) {
|
||||
rows = [...skeletonRows, addMapperRow];
|
||||
rows = [...skeletonRows];
|
||||
} else if (mapperCount === 0) {
|
||||
rows = [emptyRow, addMapperRow];
|
||||
rows = [emptyRow];
|
||||
} else {
|
||||
rows = [...mapperRows, addMapperRow];
|
||||
rows = [...mapperRows];
|
||||
}
|
||||
if (canManage && !isErrorMappers) {
|
||||
rows.push(addMapperRow);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ import cx from 'classnames';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import { DraftMapper } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
|
||||
import MapperActionsMenu from '../MapperActionsMenu/MapperActionsMenu';
|
||||
import styles from './MapperRow.module.scss';
|
||||
|
||||
@@ -30,6 +31,7 @@ function MapperRow({
|
||||
onRemove,
|
||||
onToggle,
|
||||
}: MapperRowProps): JSX.Element {
|
||||
const canManage = useCanManageAttributeMapping();
|
||||
const sources = mapper.sources ?? [];
|
||||
const visibleSources = sources.slice(0, MAX_VISIBLE_SOURCES);
|
||||
const remainingSources = sources.length - visibleSources.length;
|
||||
@@ -101,14 +103,16 @@ function MapperRow({
|
||||
)}
|
||||
</td>
|
||||
<td className={cx(styles.cell, styles.actionsCell)}>
|
||||
<div className={styles.rowActions}>
|
||||
<Switch
|
||||
value={mapper.enabled}
|
||||
onChange={(checked): void => onToggle(mapper.localId, checked)}
|
||||
testId={`mapper-enabled-${mapper.localId}`}
|
||||
/>
|
||||
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className={styles.rowActions}>
|
||||
<Switch
|
||||
value={mapper.enabled}
|
||||
onChange={(checked): void => onToggle(mapper.localId, checked)}
|
||||
testId={`mapper-enabled-${mapper.localId}`}
|
||||
/>
|
||||
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DraftMapper,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
|
||||
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
|
||||
import GroupHeader from './GroupHeader/GroupHeader';
|
||||
import GroupHeaderActions from './GroupHeaderActions/GroupHeaderActions';
|
||||
import GroupMappers from './GroupMappers/GroupMappers';
|
||||
@@ -33,6 +34,7 @@ function MappingsTable({
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||
const [targetGroupId, setTargetGroupId] = useState<string | null>(null);
|
||||
const drawer = useMapperFormDrawer();
|
||||
const canManage = useCanManageAttributeMapping();
|
||||
|
||||
const { upsertMapper, removeMapper } = editor;
|
||||
|
||||
@@ -120,18 +122,20 @@ function MappingsTable({
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrapper}>
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onAddGroup}
|
||||
testId="add-group-row"
|
||||
disabled={editor.isLoading}
|
||||
>
|
||||
Add a new group
|
||||
</Button>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onAddGroup}
|
||||
testId="add-group-row"
|
||||
disabled={editor.isLoading}
|
||||
>
|
||||
Add a new group
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty ? (
|
||||
<div className={styles.tableState} data-testid="mapper-groups-empty">
|
||||
|
||||
@@ -8,12 +8,18 @@ import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
|
||||
import DiscardChangesDialog from './components/DiscardChangesDialog/DiscardChangesDialog';
|
||||
import GroupFormDrawer from './components/GroupFormDrawer/GroupFormDrawer';
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import TestTab from './TestTab/TestTab';
|
||||
import { useAttributeMappingEditor } from './hooks/useAttributeMappingEditor';
|
||||
import { useGroupFormDrawer } from './components/GroupFormDrawer/hooks/useGroupFormDrawer';
|
||||
import { useTestSpanMapper } from './TestTab/useTestSpanMapper';
|
||||
|
||||
const MAPPINGS_TAB_KEY = 'attribute-mappings';
|
||||
const TEST_TAB_KEY = 'test';
|
||||
|
||||
function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
const editor = useAttributeMappingEditor();
|
||||
const groupDrawer = useGroupFormDrawer();
|
||||
const spanTest = useTestSpanMapper(editor.snapshot, editor.groups);
|
||||
|
||||
const { discard } = editor;
|
||||
// Discarding wipes the whole working copy, so gate it behind a confirm
|
||||
@@ -31,7 +37,7 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'attribute-mappings',
|
||||
key: MAPPINGS_TAB_KEY,
|
||||
label: 'Attribute Mappings',
|
||||
children: (
|
||||
<AttributeMappingsTab
|
||||
@@ -42,11 +48,9 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'test',
|
||||
key: TEST_TAB_KEY,
|
||||
label: 'Test',
|
||||
disabled: true,
|
||||
disabledReason: 'Coming soon',
|
||||
children: null,
|
||||
children: <TestTab spanTest={spanTest} />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -71,7 +75,7 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
|
||||
<Tabs
|
||||
testId="attribute-mapping-tabs"
|
||||
defaultValue="attribute-mappings"
|
||||
defaultValue={MAPPINGS_TAB_KEY}
|
||||
items={tabItems}
|
||||
/>
|
||||
{groupDrawer.isOpen && (
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
.skeleton {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.lineNumber {
|
||||
flex: 0 0 40px;
|
||||
padding-right: var(--padding-3);
|
||||
text-align: right;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
opacity: 0.6;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.lineContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 8px;
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--l2-border);
|
||||
animation: jsonSkeletonPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bar {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes jsonSkeletonPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import styles from './JsonEditorSkeleton.module.scss';
|
||||
|
||||
interface SkeletonLine {
|
||||
indent: number;
|
||||
barWidths: number[];
|
||||
}
|
||||
|
||||
const SKELETON_LINES: SkeletonLine[] = [
|
||||
{ indent: 0, barWidths: [10] },
|
||||
{ indent: 1, barWidths: [90, 10] },
|
||||
{ indent: 2, barWidths: [155, 190] },
|
||||
{ indent: 2, barWidths: [135, 190] },
|
||||
{ indent: 2, barWidths: [150, 50] },
|
||||
{ indent: 2, barWidths: [180, 35] },
|
||||
{ indent: 2, barWidths: [185, 200] },
|
||||
{ indent: 1, barWidths: [14] },
|
||||
{ indent: 1, barWidths: [75, 10] },
|
||||
{ indent: 2, barWidths: [95, 90] },
|
||||
{ indent: 2, barWidths: [170, 85] },
|
||||
{ indent: 1, barWidths: [10] },
|
||||
{ indent: 0, barWidths: [10] },
|
||||
];
|
||||
|
||||
const INDENT_WIDTH_PX = 14;
|
||||
|
||||
function JsonEditorSkeleton(): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.skeleton}
|
||||
data-testid="json-editor-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{SKELETON_LINES.map((line, lineIndex) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={lineIndex} className={styles.line}>
|
||||
<span className={styles.lineNumber}>{lineIndex + 1}</span>
|
||||
<span
|
||||
className={styles.lineContent}
|
||||
style={{ paddingLeft: line.indent * INDENT_WIDTH_PX }}
|
||||
>
|
||||
{line.barWidths.map((width, barIndex) => (
|
||||
<span
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={barIndex}
|
||||
className={styles.bar}
|
||||
style={{ width }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonEditorSkeleton;
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { SpantypesSpanMapperTestSpanDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
AttrChangeStatus,
|
||||
AttrDiffEntry,
|
||||
diffAttributeMaps,
|
||||
formatAttributeValue,
|
||||
} from './testPayload';
|
||||
import styles from './TestTab.module.scss';
|
||||
import { TestTabAttributes, TestTabResource } from './useTestSpanMapper';
|
||||
|
||||
interface TestResultProps {
|
||||
index: number;
|
||||
span: SpantypesSpanMapperTestSpanDTO;
|
||||
inputAttributes: TestTabAttributes;
|
||||
inputResource: TestTabResource;
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Partial<
|
||||
Record<
|
||||
AttrChangeStatus,
|
||||
{ color: 'success' | 'robin' | 'sienna'; label: string }
|
||||
>
|
||||
> = {
|
||||
added: { color: 'success', label: 'populated' },
|
||||
changed: { color: 'robin', label: 'remapped' },
|
||||
removed: { color: 'sienna', label: 'moved out' },
|
||||
};
|
||||
|
||||
const ROW_CLASS: Partial<Record<AttrChangeStatus, string>> = {
|
||||
added: styles.added,
|
||||
removed: styles.removed,
|
||||
};
|
||||
|
||||
interface ResultSection {
|
||||
key: string;
|
||||
title: string;
|
||||
entries: AttrDiffEntry[];
|
||||
}
|
||||
|
||||
function TestResult({
|
||||
index,
|
||||
span,
|
||||
inputAttributes,
|
||||
inputResource,
|
||||
}: TestResultProps): JSX.Element {
|
||||
const attributeEntries = useMemo(
|
||||
() => diffAttributeMaps(inputAttributes, span.attributes ?? {}),
|
||||
[inputAttributes, span.attributes],
|
||||
);
|
||||
const resourceEntries = useMemo(
|
||||
() => diffAttributeMaps(inputResource, span.resource ?? {}),
|
||||
[inputResource, span.resource],
|
||||
);
|
||||
|
||||
const sections: ResultSection[] = [
|
||||
{
|
||||
key: 'attributes',
|
||||
title: 'Resulting attributes',
|
||||
entries: attributeEntries,
|
||||
},
|
||||
];
|
||||
if (resourceEntries.length > 0) {
|
||||
sections.push({
|
||||
key: 'resource',
|
||||
title: 'Resulting resource',
|
||||
entries: resourceEntries,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.resultCard} data-testid={`test-result-${index}`}>
|
||||
{sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className={styles.resultSection}
|
||||
data-testid={`test-result-${index}-${section.key}`}
|
||||
>
|
||||
<div className={styles.resultTitle}>{section.title}</div>
|
||||
|
||||
{section.entries.length === 0 ? (
|
||||
<div className={styles.resultEmpty}>No keys in this map.</div>
|
||||
) : (
|
||||
<div className={styles.attrRows}>
|
||||
{section.entries.map((entry) => {
|
||||
const badge = STATUS_BADGE[entry.status];
|
||||
return (
|
||||
<div
|
||||
key={entry.key}
|
||||
className={`${styles.attrRow} ${ROW_CLASS[entry.status] ?? ''}`}
|
||||
>
|
||||
<span className={styles.attrKey} title={entry.key}>
|
||||
{entry.key}
|
||||
</span>
|
||||
<span
|
||||
className={styles.attrValue}
|
||||
title={formatAttributeValue(entry.value)}
|
||||
>
|
||||
{formatAttributeValue(entry.value)}
|
||||
</span>
|
||||
{badge ? (
|
||||
<Badge color={badge.color} variant="outline">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestResult;
|
||||
@@ -0,0 +1,177 @@
|
||||
.testTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
margin-top: var(--spacing-6);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.headerText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-6);
|
||||
height: calc(100vh - 320px);
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.validationCallout {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// No top padding so results align with the editor's first line, which sits
|
||||
// flush against the top of its own panel.
|
||||
.resultsPanel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: var(--padding-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
// Pre-run empty state that fills the blank right half.
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-2);
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.placeholderTitle {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.resultCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.resultSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.resultTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.resultEmpty {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.attrRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.attrRow {
|
||||
display: grid;
|
||||
grid-template-columns: clamp(200px, 40%, 340px) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--padding-3);
|
||||
border-radius: var(--radius-2);
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
&.added {
|
||||
background: var(--callout-success-background);
|
||||
}
|
||||
|
||||
&.removed {
|
||||
opacity: 0.65;
|
||||
|
||||
.attrKey,
|
||||
.attrValue {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attrKey,
|
||||
.attrValue {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.attrKey {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.attrValue {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import MEditor from '@monaco-editor/react';
|
||||
import { Play, RotateCcw } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import styles from './TestTab.module.scss';
|
||||
import JsonEditorSkeleton from './JsonEditorSkeleton';
|
||||
import TestResult from './TestResult';
|
||||
import {
|
||||
defineSignozJsonTheme,
|
||||
SIGNOZ_JSON_THEME_DARK,
|
||||
SIGNOZ_JSON_THEME_LIGHT,
|
||||
} from './jsonEditorTheme';
|
||||
import { UseTestSpanMapper } from './useTestSpanMapper';
|
||||
|
||||
interface TestTabProps {
|
||||
spanTest: UseTestSpanMapper;
|
||||
}
|
||||
|
||||
function TestTab({ spanTest }: TestTabProps): JSX.Element {
|
||||
const {
|
||||
input,
|
||||
setInput,
|
||||
run,
|
||||
resetToTemplate,
|
||||
isTemplateInput,
|
||||
isRunning,
|
||||
result,
|
||||
testedAttributes,
|
||||
testedResource,
|
||||
error,
|
||||
validationError,
|
||||
} = spanTest;
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
function renderResults(): JSX.Element {
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.error} role="alert" data-testid="test-error">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (result) {
|
||||
if (result.length === 0) {
|
||||
return (
|
||||
<div className={styles.resultEmpty} data-testid="test-results-empty">
|
||||
No spans returned. The mappers produced no output for this input.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.results} data-testid="test-results">
|
||||
{result.map((span, index) => (
|
||||
<TestResult
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
index={index}
|
||||
span={span}
|
||||
inputAttributes={testedAttributes ?? {}}
|
||||
inputResource={testedResource ?? {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.placeholder} data-testid="test-results-placeholder">
|
||||
<span className={styles.placeholderTitle}>No results yet</span>
|
||||
<span>
|
||||
Run the test to see which target attributes your mappers populate.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.testTab} data-testid="test-tab">
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerText}>
|
||||
<h3 className={styles.heading}>Test with sample span</h3>
|
||||
<p className={styles.description}>
|
||||
Paste a JSON span object to see which target attributes get populated and
|
||||
which source key matched.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.headerActions}>
|
||||
<Button
|
||||
testId="reset-template-button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<RotateCcw size={14} />}
|
||||
onClick={resetToTemplate}
|
||||
disabled={isTemplateInput}
|
||||
>
|
||||
Reset to Default Span
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
testId="run-test-button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Play size={14} />}
|
||||
onClick={run}
|
||||
loading={isRunning}
|
||||
disabled={isRunning || validationError !== null}
|
||||
>
|
||||
Run Test
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div
|
||||
className={styles.editor}
|
||||
data-testid="test-span-input"
|
||||
aria-label="Sample span JSON"
|
||||
>
|
||||
<MEditor
|
||||
language="json"
|
||||
value={input}
|
||||
onChange={(value): void => setInput(value ?? '')}
|
||||
height="100%"
|
||||
loading={<JsonEditorSkeleton />}
|
||||
theme={isDarkMode ? SIGNOZ_JSON_THEME_DARK : SIGNOZ_JSON_THEME_LIGHT}
|
||||
beforeMount={defineSignozJsonTheme}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: 'on',
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
formatOnPaste: true,
|
||||
tabSize: 2,
|
||||
automaticLayout: true,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.resultsPanel} data-testid="test-results-panel">
|
||||
{renderResults()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
title={validationError}
|
||||
testId="test-input-error"
|
||||
className={styles.validationCallout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestTab;
|
||||
@@ -0,0 +1,38 @@
|
||||
//TODO: This is a local theme for now, but ideally for the editor as well. Just like prettyViewer, we have a theme. We should have a theme for the editor as well.
|
||||
import { Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
|
||||
export const SIGNOZ_JSON_THEME_DARK = 'signoz-attr-mapping-json-dark';
|
||||
export const SIGNOZ_JSON_THEME_LIGHT = 'signoz-attr-mapping-json-light';
|
||||
|
||||
const SHARED_THEME = {
|
||||
inherit: true,
|
||||
colors: {
|
||||
'editor.background': '#00000000', // transparent — inherit the panel bg
|
||||
},
|
||||
fontFamily: 'SF Mono, Geist Mono, Fira Code, monospace',
|
||||
fontSize: 12,
|
||||
fontWeight: 'normal',
|
||||
lineHeight: 18,
|
||||
letterSpacing: -0.06,
|
||||
};
|
||||
|
||||
export function defineSignozJsonTheme(monaco: Monaco): void {
|
||||
monaco.editor.defineTheme(SIGNOZ_JSON_THEME_DARK, {
|
||||
...SHARED_THEME,
|
||||
base: 'vs-dark',
|
||||
rules: [
|
||||
{ token: 'string.key.json', foreground: Color.BG_ROBIN_400 },
|
||||
{ token: 'string.value.json', foreground: Color.BG_VANILLA_400 },
|
||||
],
|
||||
});
|
||||
|
||||
monaco.editor.defineTheme(SIGNOZ_JSON_THEME_LIGHT, {
|
||||
...SHARED_THEME,
|
||||
base: 'vs',
|
||||
rules: [
|
||||
{ token: 'string.key.json', foreground: Color.BG_ROBIN_500 },
|
||||
{ token: 'string.value.json', foreground: Color.BG_INK_400 },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import get from 'api/browser/localstorage/get';
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { parseSpanInput } from './testPayload';
|
||||
|
||||
export const SAMPLE_SPAN_JSON = `{
|
||||
"attributes": {
|
||||
"my_company.llm.input": "What is quantum computing?",
|
||||
"llm.input_messages": "What is quantum computing?",
|
||||
"gen_ai.request.model": "gpt-4",
|
||||
"gen_ai.usage.total_tokens": 1250,
|
||||
"gen_ai.content.completion": "Quantum computing leverages..."
|
||||
},
|
||||
"resource": {
|
||||
"service.name": "llm-gateway",
|
||||
"deployment.environment": "production"
|
||||
}
|
||||
}`;
|
||||
|
||||
function hasNoSpanData(input: string): boolean {
|
||||
let span;
|
||||
try {
|
||||
span = parseSpanInput(input);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
Object.keys(span.attributes ?? {}).length === 0 &&
|
||||
Object.keys(span.resource ?? {}).length === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function getStoredSpanInput(): string {
|
||||
const stored = get(LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN);
|
||||
if (!stored?.trim() || hasNoSpanData(stored)) {
|
||||
return SAMPLE_SPAN_JSON;
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function setStoredSpanInput(value: string): void {
|
||||
if (!value.trim() || hasNoSpanData(value)) {
|
||||
remove(LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN);
|
||||
return;
|
||||
}
|
||||
set(LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN, value);
|
||||
}
|
||||
|
||||
export function clearStoredSpanInput(): void {
|
||||
remove(LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperTestDTO,
|
||||
SpantypesPostableSpanMapperTestGroupDTO,
|
||||
SpantypesSpanMapperTestSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import { DraftGroup } from '../types';
|
||||
import {
|
||||
buildPostableGroup,
|
||||
buildPostableMapper,
|
||||
groupDraftFromNode,
|
||||
mapperDraftFromNode,
|
||||
} from '../utils';
|
||||
|
||||
function shouldSendMappers(
|
||||
snap: DraftGroup | undefined,
|
||||
group: DraftGroup,
|
||||
): boolean {
|
||||
if (!snap) {
|
||||
return true;
|
||||
}
|
||||
return !isEqual(snap.mappers, group.mappers);
|
||||
}
|
||||
|
||||
export function buildTestGroups(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
): SpantypesPostableSpanMapperTestGroupDTO[] {
|
||||
const snapById = new Map(
|
||||
snapshot
|
||||
.filter((group) => group.serverId)
|
||||
.map((group) => [group.serverId as string, group]),
|
||||
);
|
||||
|
||||
return draft.map((group) => {
|
||||
const base = buildPostableGroup(groupDraftFromNode(group));
|
||||
const snap = group.serverId ? snapById.get(group.serverId) : undefined;
|
||||
return {
|
||||
...base,
|
||||
mappers: shouldSendMappers(snap, group)
|
||||
? group.mappers.map((mapper) =>
|
||||
buildPostableMapper(mapperDraftFromNode(mapper)),
|
||||
)
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isSpanEnvelope(parsed: Record<string, unknown>): boolean {
|
||||
const keys = Object.keys(parsed);
|
||||
return (
|
||||
keys.length > 0 &&
|
||||
keys.every((key) => key === 'attributes' || key === 'resource') &&
|
||||
(isPlainObject(parsed.attributes) || isPlainObject(parsed.resource))
|
||||
);
|
||||
}
|
||||
|
||||
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Paste a JSON span object to run the test.');
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Invalid JSON — check for trailing commas or missing quotes.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPlainObject(parsed)) {
|
||||
throw new Error('Span must be a JSON object of attribute key-value pairs.');
|
||||
}
|
||||
|
||||
if (isSpanEnvelope(parsed)) {
|
||||
return {
|
||||
attributes: isPlainObject(parsed.attributes) ? parsed.attributes : {},
|
||||
resource: isPlainObject(parsed.resource) ? parsed.resource : {},
|
||||
};
|
||||
}
|
||||
|
||||
return { attributes: parsed, resource: {} };
|
||||
}
|
||||
|
||||
export function buildTestRequest(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
input: string,
|
||||
): SpantypesPostableSpanMapperTestDTO {
|
||||
return {
|
||||
groups: buildTestGroups(snapshot, draft),
|
||||
spans: [parseSpanInput(input)],
|
||||
};
|
||||
}
|
||||
|
||||
export type AttrChangeStatus = 'added' | 'changed' | 'unchanged' | 'removed';
|
||||
|
||||
export interface AttrDiffEntry {
|
||||
key: string;
|
||||
value: unknown;
|
||||
status: AttrChangeStatus;
|
||||
}
|
||||
|
||||
// Renders a diff value for display: strings as-is, everything else JSON-encoded.
|
||||
export function formatAttributeValue(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function diffAttributeMaps(
|
||||
inputAttributes: Record<string, unknown>,
|
||||
resultAttributes: Record<string, unknown>,
|
||||
): AttrDiffEntry[] {
|
||||
const added: AttrDiffEntry[] = [];
|
||||
const changed: AttrDiffEntry[] = [];
|
||||
const unchanged: AttrDiffEntry[] = [];
|
||||
const removed: AttrDiffEntry[] = [];
|
||||
|
||||
Object.entries(resultAttributes).forEach(([key, value]) => {
|
||||
if (!(key in inputAttributes)) {
|
||||
added.push({ key, value, status: 'added' });
|
||||
} else if (!isEqual(inputAttributes[key], value)) {
|
||||
changed.push({ key, value, status: 'changed' });
|
||||
} else {
|
||||
unchanged.push({ key, value, status: 'unchanged' });
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(inputAttributes).forEach(([key, value]) => {
|
||||
if (!(key in resultAttributes)) {
|
||||
removed.push({ key, value, status: 'removed' });
|
||||
}
|
||||
});
|
||||
|
||||
return [...added, ...changed, ...unchanged, ...removed];
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesSpanMapperTestSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useTestSpanMappers } from 'api/generated/services/spanmapper';
|
||||
import { AxiosError } from 'axios';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
||||
import { buildTestRequest, parseSpanInput } from './testPayload';
|
||||
import {
|
||||
clearStoredSpanInput,
|
||||
getStoredSpanInput,
|
||||
SAMPLE_SPAN_JSON,
|
||||
setStoredSpanInput,
|
||||
} from './spanInputStorage';
|
||||
import { DraftGroup } from '../types';
|
||||
|
||||
export type TestTabAttributes = Record<string, unknown>;
|
||||
export type TestTabResource = Record<string, unknown>;
|
||||
|
||||
const PERSIST_DEBOUNCE_MS = 500;
|
||||
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
const axiosError = error as AxiosError<RenderErrorResponseDTO>;
|
||||
return (
|
||||
axiosError?.response?.data?.error?.message ??
|
||||
(error instanceof Error ? error.message : 'Test failed. Please try again.')
|
||||
);
|
||||
}
|
||||
|
||||
export interface UseTestSpanMapper {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
run: () => void;
|
||||
reset: () => void;
|
||||
resetToTemplate: () => void;
|
||||
isTemplateInput: boolean;
|
||||
isRunning: boolean;
|
||||
validationError: string | null;
|
||||
result: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
testedAttributes: TestTabAttributes | null;
|
||||
testedResource: TestTabResource | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useTestSpanMapper(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
): UseTestSpanMapper {
|
||||
const [input, setInputValue] = useState<string>(getStoredSpanInput);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<SpantypesSpanMapperTestSpanDTO[] | null>(
|
||||
null,
|
||||
);
|
||||
const [testedAttributes, setTestedAttributes] =
|
||||
useState<TestTabAttributes | null>(null);
|
||||
const [testedResource, setTestedResource] = useState<TestTabResource | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { mutate, isLoading } = useTestSpanMappers();
|
||||
|
||||
const persistInput = useMemo(
|
||||
() => debounce(setStoredSpanInput, PERSIST_DEBOUNCE_MS),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
persistInput.flush();
|
||||
},
|
||||
[persistInput],
|
||||
);
|
||||
|
||||
const setInput = useCallback(
|
||||
(value: string): void => {
|
||||
setInputValue(value);
|
||||
persistInput(value);
|
||||
},
|
||||
[persistInput],
|
||||
);
|
||||
|
||||
const validationError = useMemo((): string | null => {
|
||||
try {
|
||||
parseSpanInput(input);
|
||||
return null;
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : 'Invalid span JSON.';
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setTestedAttributes(null);
|
||||
setTestedResource(null);
|
||||
}, []);
|
||||
|
||||
const resetToTemplate = useCallback((): void => {
|
||||
persistInput.cancel();
|
||||
clearStoredSpanInput();
|
||||
setInputValue(SAMPLE_SPAN_JSON);
|
||||
reset();
|
||||
}, [persistInput, reset]);
|
||||
|
||||
const isTemplateInput = input.trim() === SAMPLE_SPAN_JSON;
|
||||
|
||||
const run = useCallback((): void => {
|
||||
reset();
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = buildTestRequest(snapshot, draft, input);
|
||||
} catch (parseError) {
|
||||
setError(apiErrorMessage(parseError));
|
||||
return;
|
||||
}
|
||||
|
||||
const submittedSpan = body.spans?.[0];
|
||||
const submittedAttributes = (submittedSpan?.attributes ??
|
||||
{}) as TestTabAttributes;
|
||||
const submittedResource = (submittedSpan?.resource ?? {}) as TestTabResource;
|
||||
|
||||
mutate(
|
||||
{ data: body },
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
setTestedAttributes(submittedAttributes);
|
||||
setTestedResource(submittedResource);
|
||||
setResult(response.data?.spans ?? []);
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setResult(null);
|
||||
setError(apiErrorMessage(mutationError));
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [snapshot, draft, input, mutate, reset]);
|
||||
|
||||
return {
|
||||
input,
|
||||
setInput,
|
||||
run,
|
||||
reset,
|
||||
resetToTemplate,
|
||||
isTemplateInput,
|
||||
isRunning: isLoading,
|
||||
validationError,
|
||||
result,
|
||||
testedAttributes,
|
||||
testedResource,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,32 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
// The header's Save/Discard and the group toggle are Admin-gated via useAuthZ;
|
||||
// render as an admin so those controls are present.
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
// Monaco can't run in jsdom — stand in a textarea so the span input is editable.
|
||||
jest.mock('@monaco-editor/react', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next?: string) => void;
|
||||
}): JSX.Element => (
|
||||
<textarea
|
||||
aria-label="span-json-editor"
|
||||
data-testid="span-json-editor"
|
||||
value={value}
|
||||
onChange={(event): void => onChange(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
|
||||
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
|
||||
|
||||
@@ -16,6 +42,7 @@ describe('LLMObservabilityAttributeMapping', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
setupGroups();
|
||||
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -86,6 +113,26 @@ describe('LLMObservabilityAttributeMapping', () => {
|
||||
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the sample span input when switching away from the test tab and back', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Test' }));
|
||||
const editor = await screen.findByTestId('span-json-editor');
|
||||
await user.clear(editor);
|
||||
await user.type(editor, '{{ "my.attr": 1 }');
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Attribute Mappings' }));
|
||||
await screen.findByTestId('attribute-mappings-tab');
|
||||
expect(screen.queryByTestId('span-json-editor')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Test' }));
|
||||
|
||||
await expect(screen.findByTestId('span-json-editor')).resolves.toHaveValue(
|
||||
'{ "my.attr": 1 }',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps staged changes when the discard prompt is dismissed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservabilityAttributeMapping />);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { useCanManageAttributeMapping } from '../../hooks/useCanManageAttributeMapping';
|
||||
import styles from './AttributeMappingHeader.module.scss';
|
||||
|
||||
interface AttributeMappingHeaderProps {
|
||||
@@ -16,12 +17,13 @@ function AttributeMappingHeader({
|
||||
onDiscard,
|
||||
onSave,
|
||||
}: AttributeMappingHeaderProps): JSX.Element {
|
||||
const canManage = useCanManageAttributeMapping();
|
||||
return (
|
||||
<header className={styles.pageHeader}>
|
||||
<Typography.Text as="p" size="base" color="muted">
|
||||
Configure source-to-target attribute remapping for LLM traces
|
||||
</Typography.Text>
|
||||
{isDirty && (
|
||||
{canManage && isDirty && (
|
||||
<div className={styles.pageHeaderActions}>
|
||||
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
|
||||
Unsaved changes
|
||||
|
||||
@@ -35,6 +35,7 @@ function clone(groups: DraftGroup[]): DraftGroup[] {
|
||||
|
||||
export interface AttributeMappingEditor {
|
||||
groups: DraftGroup[];
|
||||
snapshot: DraftGroup[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isDirty: boolean;
|
||||
@@ -304,6 +305,7 @@ export function useAttributeMappingEditor(): AttributeMappingEditor {
|
||||
|
||||
return {
|
||||
groups: draft ?? [],
|
||||
snapshot,
|
||||
isLoading: !ready || draft === null,
|
||||
isError: groupsQuery.isError,
|
||||
isDirty,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsAdminPermission } from 'lib/authz/hooks/useAuthZ/legacy';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
const ADMIN_PERMISSION = [IsAdminPermission];
|
||||
|
||||
export function useCanManageAttributeMapping(): boolean {
|
||||
const { allowed } = useAuthZ(ADMIN_PERMISSION);
|
||||
return allowed;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../variableFormModel';
|
||||
import { dtoToFormModel, formModelToDto } from '../variableAdapters';
|
||||
|
||||
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
|
||||
return { ...emptyVariableFormModel(), name: 'v', ...overrides };
|
||||
}
|
||||
|
||||
/** The list spec a saved variable carries, whatever its plugin. */
|
||||
function listSpec(m: VariableFormModel): {
|
||||
allowMultiple?: boolean;
|
||||
allowAllValue?: boolean;
|
||||
} {
|
||||
return formModelToDto(m).spec as {
|
||||
allowMultiple?: boolean;
|
||||
allowAllValue?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
describe('formModelToDto — the ALL flag needs multi-select', () => {
|
||||
it('keeps ALL for a multi-select dynamic variable', () => {
|
||||
expect(
|
||||
listSpec(
|
||||
model({
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: 'host.name',
|
||||
multiSelect: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ allowMultiple: true, allowAllValue: true });
|
||||
});
|
||||
|
||||
it('drops ALL for a single-select dynamic variable', () => {
|
||||
// The API rejects allowAllValue without allowMultiple, and this used to be forced
|
||||
// true for every dynamic variable — which blocked saving the whole dashboard.
|
||||
expect(
|
||||
listSpec(
|
||||
model({
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: 'host.name',
|
||||
multiSelect: false,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ allowMultiple: false, allowAllValue: false });
|
||||
});
|
||||
|
||||
it('drops ALL for a single-select query variable that still carries the flag', () => {
|
||||
expect(
|
||||
listSpec(
|
||||
model({
|
||||
type: 'QUERY',
|
||||
queryValue: 'SELECT 1',
|
||||
multiSelect: false,
|
||||
showAllOption: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ allowMultiple: false, allowAllValue: false });
|
||||
});
|
||||
|
||||
it('respects the toggle on a multi-select query variable', () => {
|
||||
expect(
|
||||
listSpec(
|
||||
model({
|
||||
type: 'QUERY',
|
||||
queryValue: 'SELECT 1',
|
||||
multiSelect: true,
|
||||
showAllOption: false,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ allowMultiple: true, allowAllValue: false });
|
||||
});
|
||||
|
||||
it('round-trips a single-select dynamic variable unchanged', () => {
|
||||
// What the dashboard in the report holds: saving it must not flip the flag.
|
||||
const dto = {
|
||||
kind: 'ListVariable',
|
||||
spec: {
|
||||
name: 'host.name',
|
||||
display: { name: 'host.name', description: '' },
|
||||
allowMultiple: false,
|
||||
allowAllValue: false,
|
||||
sort: 'alphabetical-asc',
|
||||
plugin: {
|
||||
kind: 'signoz/DynamicVariable',
|
||||
spec: { name: 'host.name', signal: 'all' },
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
|
||||
expect(listSpec(dtoToFormModel(dto))).toMatchObject({
|
||||
allowMultiple: false,
|
||||
allowAllValue: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -133,9 +133,14 @@ export function formModelToDto(
|
||||
name: model.name,
|
||||
display,
|
||||
allowMultiple: model.multiSelect,
|
||||
// Dynamic variables always expose the aggregate "ALL" entry (matches V1,
|
||||
// which forced showALLOption true on save); other types respect the toggle.
|
||||
allowAllValue: model.type === 'DYNAMIC' ? true : model.showAllOption,
|
||||
// Dynamic variables always expose the aggregate "ALL" entry (matches V1, which
|
||||
// forced showALLOption true on save); other types respect the toggle. Either
|
||||
// way it needs multi-select — ALL is a set of values, and the API rejects the
|
||||
// flag without it, which used to make a single-select dynamic variable
|
||||
// unsaveable and blocked every other edit to the dashboard with it.
|
||||
allowAllValue:
|
||||
model.multiSelect &&
|
||||
(model.type === 'DYNAMIC' ? true : model.showAllOption),
|
||||
// model.sort is already a Perses sort token (`none` / `alphabetical-*`).
|
||||
sort: model.sort,
|
||||
defaultValue: model.defaultValue,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/Toolt
|
||||
|
||||
import HiddenVariablesTooltip from './components/HiddenVariablesTooltip/HiddenVariablesTooltip';
|
||||
import { useVariableSelection } from './hooks/useVariableSelection';
|
||||
import { resolveDefaultSelection } from './utils/resolveVariableSelection';
|
||||
import VariableSelector from './components/VariableSelector/VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
@@ -96,12 +97,10 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
selections={selection}
|
||||
selection={
|
||||
selection[variable.name] ?? {
|
||||
value: variable.multiSelect ? [] : '',
|
||||
allSelected: false,
|
||||
}
|
||||
}
|
||||
// Until the seed commits a selection, stand in the same default it will
|
||||
// commit, through the one resolver — an empty stand-in reads as "nothing
|
||||
// selected" to a control that snapshots it on mount.
|
||||
selection={selection[variable.name] ?? resolveDefaultSelection(variable)}
|
||||
onChange={(next): void => setSelection(variable.name, next)}
|
||||
onAutoSelect={(next): void => autoSelect(variable.name, next)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import TextSelector from '../components/selectors/TextSelector';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
function renderSelector(
|
||||
selection: VariableSelection,
|
||||
onChange = jest.fn(),
|
||||
): { rerender: (selection: VariableSelection) => void; onChange: jest.Mock } {
|
||||
const { rerender } = render(
|
||||
<TextSelector
|
||||
selection={selection}
|
||||
defaultValue="flower"
|
||||
onChange={onChange}
|
||||
testId="variable-input-service"
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
onChange,
|
||||
rerender: (next): void =>
|
||||
rerender(
|
||||
<TextSelector
|
||||
selection={next}
|
||||
defaultValue="flower"
|
||||
onChange={onChange}
|
||||
testId="variable-input-service"
|
||||
/>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function input(): HTMLInputElement {
|
||||
return screen.getByTestId('variable-input-service') as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe('TextSelector', () => {
|
||||
it('shows the value the seed commits after mount', () => {
|
||||
// The bar mounts before the seed resolves the definition's default, so the first
|
||||
// render sees nothing selected. The box must follow the value once it lands.
|
||||
const { rerender } = renderSelector({ value: '', allSelected: false });
|
||||
|
||||
rerender({ value: 'flower', allSelected: false });
|
||||
|
||||
expect(input().value).toBe('flower');
|
||||
});
|
||||
|
||||
it('follows a selection replaced from outside (share link, reset)', () => {
|
||||
const { rerender } = renderSelector({ value: 'flower', allSelected: false });
|
||||
|
||||
rerender({ value: 'rose', allSelected: false });
|
||||
|
||||
expect(input().value).toBe('rose');
|
||||
});
|
||||
|
||||
it('keeps what the user types until they commit it', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderSelector({ value: 'flower', allSelected: false });
|
||||
|
||||
await user.clear(input());
|
||||
await user.type(input(), 'tulip');
|
||||
|
||||
// Still local — a keystroke must not cascade to dependent panels.
|
||||
expect(input().value).toBe('tulip');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
await user.tab();
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
value: 'tulip',
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the default when emptied', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderSelector({ value: 'tulip', allSelected: false });
|
||||
|
||||
await user.clear(input());
|
||||
await user.tab();
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
value: 'flower',
|
||||
allSelected: false,
|
||||
});
|
||||
expect(input().value).toBe('flower');
|
||||
});
|
||||
});
|
||||
@@ -87,4 +87,99 @@ describe('ValueSelector', () => {
|
||||
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('an ALL selection whose options are still loading', () => {
|
||||
it('reads ALL, not the "Select value" placeholder', () => {
|
||||
// Options arrive after the selection is known (first fetch, or a dynamic ALL,
|
||||
// which carries no values at all) — the control must not read as unselected.
|
||||
renderSelector({ value: null, allSelected: true }, [], true);
|
||||
|
||||
expect(
|
||||
document.querySelector('.ant-select-selection-placeholder'),
|
||||
).toHaveTextContent('ALL');
|
||||
});
|
||||
|
||||
it('still shows concrete values while options load', () => {
|
||||
renderSelector(
|
||||
{ value: ['checkout-service-prod'], allSelected: false },
|
||||
[],
|
||||
true,
|
||||
);
|
||||
|
||||
expect(
|
||||
document.querySelector('.ant-select-selection-placeholder'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearing', () => {
|
||||
function clearIcon(): Element | null {
|
||||
return document.querySelector('.ant-select-clear');
|
||||
}
|
||||
|
||||
async function openDropdown(): Promise<void> {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
const control = screen.getByTestId('variable-select-env');
|
||||
await user.click(control.querySelector('input') as HTMLInputElement);
|
||||
}
|
||||
|
||||
it('offers no clear icon while the list is closed', () => {
|
||||
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
|
||||
|
||||
expect(clearIcon()).toBeNull();
|
||||
});
|
||||
|
||||
it('offers it once the list is open', async () => {
|
||||
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
expect(clearIcon()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('offers no clear icon while every option is selected', async () => {
|
||||
// ALL is every option, so there is nothing to clear — and the shared control
|
||||
// refuses to empty an ALL selection, which would leave the icon inert.
|
||||
renderSelector({ value: OPTIONS, allSelected: true }, OPTIONS);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
expect(clearIcon()).toBeNull();
|
||||
});
|
||||
|
||||
it('empties the list and commits nothing until it closes', async () => {
|
||||
const onChange = jest.fn();
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ValueSelector
|
||||
options={OPTIONS}
|
||||
variableType="query"
|
||||
multiSelect
|
||||
showAllOption
|
||||
selection={{ value: VALUES, allSelected: false }}
|
||||
onChange={onChange}
|
||||
emptyFallback={{ value: [OPTIONS[0]], allSelected: false }}
|
||||
testId="variable-select-env"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
await openDropdown();
|
||||
await user.click(clearIcon() as Element);
|
||||
|
||||
expect(document.querySelectorAll('.ant-select-selection-item')).toHaveLength(
|
||||
0,
|
||||
);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
// Closing fills in whatever the variable should hold.
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
value: [OPTIONS[0]],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,47 @@ describe('useAutoSelect', () => {
|
||||
expect(next).toBeUndefined();
|
||||
});
|
||||
|
||||
it('selects ALL for an ALL-enabled multi-select with nothing selected', () => {
|
||||
const next = run(
|
||||
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
|
||||
['a', 'b'],
|
||||
{ value: [], allSelected: false },
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: true });
|
||||
});
|
||||
|
||||
it('falls back to ALL, not the first option, when every selected value is gone', () => {
|
||||
const next = run(
|
||||
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
|
||||
['x', 'y'],
|
||||
{ value: ['a', 'b'], allSelected: false },
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['x', 'y'], allSelected: true });
|
||||
});
|
||||
|
||||
it('selects the ALL sentinel for an empty ALL-enabled dynamic multi-select', () => {
|
||||
const next = run(
|
||||
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
|
||||
['a', 'b'],
|
||||
{ value: [], allSelected: false },
|
||||
);
|
||||
expect(next).toStrictEqual({ value: null, allSelected: true });
|
||||
});
|
||||
|
||||
it('still honours a configured default over ALL', () => {
|
||||
const next = run(
|
||||
model({
|
||||
type: 'QUERY',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
defaultValue: 'b',
|
||||
}),
|
||||
['a', 'b'],
|
||||
{ value: [], allSelected: false },
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['b'], allSelected: false });
|
||||
});
|
||||
|
||||
it('keeps the still-valid subset of a multi-select when options re-scope', () => {
|
||||
const next = run(
|
||||
model({ type: 'QUERY', multiSelect: true }),
|
||||
|
||||
@@ -97,6 +97,125 @@ describe('useSeedVariableSelection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the configured default over a stored empty text value', () => {
|
||||
// Persisted from a session where the box was never filled — the default the
|
||||
// definition carries must win, or the variable reads as unset forever.
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setVariableValues('d1', { env: { value: '', allSelected: false } });
|
||||
const dash = dashboard('d1', [
|
||||
model({ name: 'env', type: 'TEXT', textValue: 'prod' }),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: 'prod',
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults an ALL-enabled multi-select to ALL over a stored empty array', () => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setVariableValues('d1', { env: { value: [], allSelected: false } });
|
||||
const dash = dashboard('d1', [
|
||||
model({
|
||||
name: 'env',
|
||||
type: 'CUSTOM',
|
||||
customValue: 'a,b',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
// Custom options need no request, so ALL is materialized here rather than left as
|
||||
// a flag for the post-fetch reconcile to expand.
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: ['a', 'b'],
|
||||
allSelected: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('materializes ALL for a custom variable so nothing has to reconcile it later', () => {
|
||||
const dash = dashboard('d1', [
|
||||
model({
|
||||
name: 'env',
|
||||
type: 'CUSTOM',
|
||||
customValue: 'a,b,c',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: ['a', 'b', 'c'],
|
||||
allSelected: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops values a custom variable no longer offers, at seed time', () => {
|
||||
useDashboardStore.getState().setVariableValues('d1', {
|
||||
env: { value: ['a', 'gone'], allSelected: false },
|
||||
});
|
||||
const dash = dashboard('d1', [
|
||||
model({
|
||||
name: 'env',
|
||||
type: 'CUSTOM',
|
||||
customValue: 'a,b',
|
||||
multiSelect: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: ['a'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a stored ALL selection that has not materialized yet', () => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setVariableValues('d1', { env: { value: null, allSelected: true } });
|
||||
const dash = dashboard('d1', [
|
||||
model({
|
||||
name: 'env',
|
||||
type: 'QUERY',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
defaultValue: 'b',
|
||||
}),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: null,
|
||||
allSelected: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not rewrite the store when the effect re-runs with the same values', () => {
|
||||
// A dashboard refetch or any spec edit hands back an equal-but-new variables array,
|
||||
// re-running the seed. Writing an identical map would re-render every subscriber.
|
||||
const variable = model({ name: 'env', type: 'TEXT', textValue: 'prod' });
|
||||
const { rerender } = renderHook(
|
||||
({ dash }) => useSeedVariableSelection(dash),
|
||||
{ initialProps: { dash: dashboard('d1', [variable]) } },
|
||||
);
|
||||
const afterSeed = useDashboardStore.getState().variableValues;
|
||||
|
||||
rerender({ dash: dashboard('d1', [{ ...variable }]) });
|
||||
|
||||
expect(useDashboardStore.getState().variableValues).toBe(afterSeed);
|
||||
});
|
||||
|
||||
it('initializes the fetch context with idle states for every variable', () => {
|
||||
const dash = dashboard('d1', [
|
||||
model({ name: 'env', type: 'TEXT' }),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useVariableSelection } from '../hooks/useVariableSelection';
|
||||
|
||||
jest.mock('nuqs', () => ({
|
||||
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
|
||||
useQueryState: (): unknown => [null, jest.fn()],
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({
|
||||
globalTime: { minTime: 1, maxTime: 2, selectedTime: '5m' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../DashboardSettings/Variables/variableAdapters', () => ({
|
||||
dtoToFormModel: (dto: unknown): unknown => dto,
|
||||
}));
|
||||
|
||||
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
|
||||
return { ...emptyVariableFormModel(), ...overrides };
|
||||
}
|
||||
|
||||
// env → svc, so a cascade off `env` is observable as a cycle bump on `svc`.
|
||||
const env = model({
|
||||
name: 'env',
|
||||
type: 'QUERY',
|
||||
multiSelect: true,
|
||||
queryValue: 'SELECT env',
|
||||
});
|
||||
const svc = model({
|
||||
name: 'svc',
|
||||
type: 'QUERY',
|
||||
queryValue: 'SELECT svc WHERE env = $env',
|
||||
});
|
||||
|
||||
const dashboard = {
|
||||
id: 'd1',
|
||||
spec: { variables: [env, svc] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
function svcCycleId(): number {
|
||||
return useDashboardStore.getState().variableCycleIds.svc ?? 0;
|
||||
}
|
||||
|
||||
describe('useVariableSelection — setSelection', () => {
|
||||
beforeEach(() => {
|
||||
useDashboardStore.setState({
|
||||
variableValues: {},
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableResolvedEmpty: {},
|
||||
variableFetchContext: null,
|
||||
lastFetchAllKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-cascade when the picked value is the one already held', () => {
|
||||
const { result } = renderHook(() => useVariableSelection(dashboard));
|
||||
|
||||
act(() => {
|
||||
result.current.setSelection('env', {
|
||||
value: ['a', 'b'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
const afterFirstPick = svcCycleId();
|
||||
|
||||
// Same values, then the same set in a different order: neither is a change, so
|
||||
// the dependent must not be re-fetched.
|
||||
act(() => {
|
||||
result.current.setSelection('env', {
|
||||
value: ['a', 'b'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
result.current.setSelection('env', {
|
||||
value: ['b', 'a'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(svcCycleId()).toBe(afterFirstPick);
|
||||
});
|
||||
|
||||
it('ignores an auto-fill that the store already satisfies', async () => {
|
||||
const { result } = renderHook(() => useVariableSelection(dashboard));
|
||||
act(() => {
|
||||
result.current.setSelection('env', { value: ['a'], allSelected: false });
|
||||
});
|
||||
const before = svcCycleId();
|
||||
|
||||
// What a selector's first-render reconcile produces before the seed commits: a
|
||||
// value the store then resolves to on its own.
|
||||
await act(async () => {
|
||||
result.current.autoSelect('env', { value: ['a'], allSelected: false });
|
||||
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
||||
});
|
||||
|
||||
expect(svcCycleId()).toBe(before);
|
||||
});
|
||||
|
||||
it('still applies an auto-fill that changes the value', async () => {
|
||||
const { result } = renderHook(() => useVariableSelection(dashboard));
|
||||
act(() => {
|
||||
result.current.setSelection('env', { value: ['a'], allSelected: false });
|
||||
});
|
||||
const before = svcCycleId();
|
||||
|
||||
await act(async () => {
|
||||
result.current.autoSelect('env', { value: ['a', 'b'], allSelected: false });
|
||||
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
||||
});
|
||||
|
||||
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
|
||||
value: ['a', 'b'],
|
||||
allSelected: false,
|
||||
});
|
||||
expect(svcCycleId()).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('still cascades a genuine change', () => {
|
||||
const { result } = renderHook(() => useVariableSelection(dashboard));
|
||||
|
||||
act(() => {
|
||||
result.current.setSelection('env', { value: ['a'], allSelected: false });
|
||||
});
|
||||
const before = svcCycleId();
|
||||
|
||||
act(() => {
|
||||
result.current.setSelection('env', {
|
||||
value: ['a', 'c'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
|
||||
value: ['a', 'c'],
|
||||
allSelected: false,
|
||||
});
|
||||
expect(svcCycleId()).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../../selectionTypes';
|
||||
import { textDefault } from '../../utils/resolveVariableSelection';
|
||||
import { computeVariableDependencies } from '../../utils/variableDependencies';
|
||||
import TextSelector from '../selectors/TextSelector';
|
||||
import VariableValueControl from '../selectors/VariableValueControl';
|
||||
@@ -65,7 +66,7 @@ function VariableSelector({
|
||||
variable.type === 'TEXT' ? (
|
||||
<TextSelector
|
||||
selection={selection}
|
||||
defaultValue={variable.textValue}
|
||||
defaultValue={textDefault(variable)}
|
||||
onChange={onChange}
|
||||
testId={`variable-input-${variable.name}`}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { InputRef } from 'antd';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
|
||||
import { Input } from 'antd';
|
||||
@@ -31,6 +31,17 @@ function TextSelector({
|
||||
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
|
||||
);
|
||||
|
||||
// The committed value can land after this input mounts — the seed resolves the
|
||||
// definition's default one tick later, and a share link or a reset can replace it
|
||||
// at any point. Without following it the box would keep showing its first render
|
||||
// (empty, since nothing is seeded yet) until the user focused and blurred it.
|
||||
// Typing never touches the selection, so an edit in progress cannot be interrupted.
|
||||
useEffect(() => {
|
||||
setValue(
|
||||
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
|
||||
);
|
||||
}, [selection.value, defaultValue]);
|
||||
|
||||
const commit = useCallback(
|
||||
(next: string): void => {
|
||||
void logEvent(
|
||||
|
||||
@@ -54,11 +54,26 @@ function ValueSelector({
|
||||
[selection, options],
|
||||
);
|
||||
|
||||
// That "all" path needs the options, so an ALL selection whose options have not
|
||||
// arrived yet has nothing to render and the control would read "Select value"
|
||||
// while it spins — as if nothing were selected. Say ALL in that slot instead: the
|
||||
// selection is known, only its options are pending. Display only, so it can never
|
||||
// be committed as a value.
|
||||
const isAllPendingOptions = selection.allSelected && options.length === 0;
|
||||
|
||||
// Buffer edits while the dropdown is open; the committed selection is shown
|
||||
// when closed. This defers the dependent cascade to a single commit-on-close.
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<string[]>(committedValues);
|
||||
|
||||
// ALL is every option, so there is nothing to clear — and the shared control refuses
|
||||
// to empty an ALL selection anyway, which would leave the icon inert. Unchecking ALL
|
||||
// in the list is the way out of it.
|
||||
const draftIsAll =
|
||||
showAllOption &&
|
||||
options.length > 0 &&
|
||||
options.every((option) => draft.includes(option));
|
||||
|
||||
const commit = (values: string[]): void => {
|
||||
// CustomMultiSelect emits the full value set when ALL is picked.
|
||||
const isAll =
|
||||
@@ -94,8 +109,11 @@ function ValueSelector({
|
||||
errorMessage={errorMessage}
|
||||
onRetry={onRetry}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select value"
|
||||
// Clearing belongs to the open list: on the closed control the icon would
|
||||
// appear on hover, in a row of variable pills, for an action whose result is
|
||||
// not visible.
|
||||
allowClear={isOpen && !draftIsAll}
|
||||
placeholder={isAllPendingOptions ? 'ALL' : 'Select value'}
|
||||
maxTagCount={1}
|
||||
maxTagTextLength={10}
|
||||
maxTagPlaceholder={(omitted): JSX.Element => (
|
||||
@@ -129,12 +147,10 @@ function ValueSelector({
|
||||
void logEvent(DashboardDetailEvents.VariableMultiSelectCleared, {
|
||||
variableType,
|
||||
});
|
||||
// Empties the list, committing nothing. Closing resolves an empty draft
|
||||
// to whatever the variable should hold — its configured default, else ALL
|
||||
// where it offers one, else the first option.
|
||||
setDraft([]);
|
||||
// A clear on the closed control falls back to the default immediately;
|
||||
// while open it just empties the draft (committed on close).
|
||||
if (!isOpen) {
|
||||
onChange(emptyFallback);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,13 @@ import { dtoToFormModel } from '../../DashboardSettings/Variables/variableAdapte
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { selectVariableValues } from '../../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { resolveDefaultSelection } from '../utils/resolveVariableSelection';
|
||||
import { knownVariableOptions } from '../utils/knownVariableOptions';
|
||||
import {
|
||||
areSelectionsEqual,
|
||||
reconcileWithOptions,
|
||||
resolveDefaultSelection,
|
||||
} from '../utils/resolveVariableSelection';
|
||||
import { hasUsableValue } from '../utils/selectionUtils';
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
@@ -18,6 +24,44 @@ import {
|
||||
} from '../utils/variableDependencies';
|
||||
import { ALL_SELECTED, variablesUrlParser } from '../utils/variablesUrlState';
|
||||
|
||||
function isStoredSelectionSet(
|
||||
stored: VariableSelection,
|
||||
model: VariableFormModel,
|
||||
): boolean {
|
||||
return !!stored.allSelected || hasUsableValue(stored, model.type);
|
||||
}
|
||||
|
||||
/** Whether the seeded map matches, entry for entry, what the store already holds. */
|
||||
function isSameSelection(
|
||||
seeded: VariableSelectionMap,
|
||||
current: VariableSelectionMap,
|
||||
): boolean {
|
||||
const names = Object.keys(seeded);
|
||||
return (
|
||||
names.length === Object.keys(current).length &&
|
||||
names.every(
|
||||
(name) => !!current[name] && areSelectionsEqual(seeded[name], current[name]),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A seeded value taken as far as it can go: for a variable whose options need no
|
||||
* request, resolve against them now — ALL becomes the concrete array, values the list
|
||||
* no longer offers are dropped. Left alone otherwise; the post-fetch reconcile does it
|
||||
* once the options arrive.
|
||||
*/
|
||||
function resolveAgainstKnownOptions(
|
||||
value: VariableSelection,
|
||||
model: VariableFormModel,
|
||||
): VariableSelection {
|
||||
const options = knownVariableOptions(model);
|
||||
if (options.length === 0) {
|
||||
return value;
|
||||
}
|
||||
return reconcileWithOptions(model, value, options) ?? value;
|
||||
}
|
||||
|
||||
// The `__ALL__` sentinel only means "ALL" for variables that support it — a
|
||||
// legitimate value of "__ALL__" (e.g. a text var) is taken literally.
|
||||
function fromUrlValue(
|
||||
@@ -70,22 +114,31 @@ export function useSeedVariableSelection(
|
||||
variables.forEach((variable) => {
|
||||
const urlValue = urlValues?.[variable.name];
|
||||
const stored = selection[variable.name];
|
||||
const seed = (value: VariableSelection): void => {
|
||||
seeded[variable.name] = resolveAgainstKnownOptions(value, variable);
|
||||
};
|
||||
if (urlValue !== undefined) {
|
||||
const fromUrl = fromUrlValue(urlValue, variable);
|
||||
// When the URL carries only the ALL sentinel but the store already holds
|
||||
// the materialized full-option array, reuse it — avoids the re-fetch +
|
||||
// re-materialize round-trip (and its dependent-refetch cascade) on load.
|
||||
seeded[variable.name] =
|
||||
seed(
|
||||
fromUrl.allSelected && stored?.allSelected && Array.isArray(stored.value)
|
||||
? stored
|
||||
: fromUrl;
|
||||
} else if (stored) {
|
||||
seeded[variable.name] = stored;
|
||||
: fromUrl,
|
||||
);
|
||||
} else if (stored && isStoredSelectionSet(stored, variable)) {
|
||||
seed(stored);
|
||||
} else {
|
||||
seeded[variable.name] = resolveDefaultSelection(variable);
|
||||
seed(resolveDefaultSelection(variable));
|
||||
}
|
||||
});
|
||||
setVariableValues(dashboardId, seeded);
|
||||
// This runs again whenever the spec's variable array changes identity — a refetch
|
||||
// or any spec edit — and writing an identical map would re-render every selection
|
||||
// subscriber for nothing.
|
||||
if (!isSameSelection(seeded, selection)) {
|
||||
setVariableValues(dashboardId, seeded);
|
||||
}
|
||||
|
||||
// Read-once: a share link's `?variables=` seeds the store, then the param is
|
||||
// dropped so the store is the sole source of truth. Selection changes never
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import {
|
||||
sortValuesByOrder,
|
||||
VARIABLE_TYPE_EVENT_LABEL,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { VARIABLE_TYPE_EVENT_LABEL } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelectionMap } from '../selectionTypes';
|
||||
import { knownVariableOptions } from '../utils/knownVariableOptions';
|
||||
import {
|
||||
useFetchedVariableOptions,
|
||||
type VariableOptions,
|
||||
@@ -30,14 +27,12 @@ export function useVariableOptions(
|
||||
): VariableOptions {
|
||||
const fetched = useFetchedVariableOptions(variable, variables, selections);
|
||||
|
||||
// Keyed on the fields the parse actually reads, not the model identity: a dashboard
|
||||
// refetch hands back an equal-but-new model, and a new options array would re-fire
|
||||
// the post-fetch reconcile for nothing.
|
||||
const customOptions = useMemo(
|
||||
() =>
|
||||
variable.type === 'CUSTOM'
|
||||
? sortValuesByOrder(
|
||||
commaValuesParser(variable.customValue),
|
||||
variable.sort,
|
||||
).map(String)
|
||||
: ([] as string[]),
|
||||
() => knownVariableOptions(variable),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[variable.type, variable.customValue, variable.sort],
|
||||
);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { areSelectionsEqual } from '../utils/resolveVariableSelection';
|
||||
import { useSeedVariableSelection } from './useSeedVariableSelection';
|
||||
|
||||
/**
|
||||
@@ -103,6 +104,10 @@ export function useVariableSelection(
|
||||
|
||||
const setSelection = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
const current = selectionRef.current[name];
|
||||
if (current && areSelectionsEqual(next, current)) {
|
||||
return;
|
||||
}
|
||||
setVariableValue(dashboardId, name, next);
|
||||
enqueueDescendants(name);
|
||||
},
|
||||
@@ -124,8 +129,19 @@ export function useVariableSelection(
|
||||
if (names.length === 0 || !dashboardId) {
|
||||
return;
|
||||
}
|
||||
setVariableValues(dashboardId, { ...selectionRef.current, ...fills });
|
||||
enqueueDescendantsBatch(names);
|
||||
// A fill can arrive already satisfied: a selector reconciles against the options
|
||||
// on its first render, before the seed has committed, and the seed then resolves
|
||||
// to the same value. Refresh only what actually moved, so a settled load ends
|
||||
// without a write or a dependent refetch.
|
||||
const current = selectionRef.current;
|
||||
const changed = names.filter(
|
||||
(name) => !current[name] || !areSelectionsEqual(fills[name], current[name]),
|
||||
);
|
||||
if (changed.length === 0) {
|
||||
return;
|
||||
}
|
||||
setVariableValues(dashboardId, { ...current, ...fills });
|
||||
enqueueDescendantsBatch(changed);
|
||||
}, [dashboardId, setVariableValues, enqueueDescendantsBatch]);
|
||||
|
||||
const autoSelect = useCallback(
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
|
||||
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
|
||||
/**
|
||||
* The options of a variable whose list needs no request — a CUSTOM variable's comma
|
||||
* list. Empty for QUERY and DYNAMIC, whose options only exist once fetched, and for
|
||||
* TEXT, which has none.
|
||||
*
|
||||
* Knowing them up front lets the seed resolve such a variable's value completely
|
||||
* (materializing ALL, dropping values the list no longer offers) instead of leaving
|
||||
* that to the post-fetch reconcile, which would cost a second store write and a
|
||||
* refetch of everything downstream.
|
||||
*/
|
||||
export function knownVariableOptions(model: VariableFormModel): string[] {
|
||||
if (model.type !== 'CUSTOM') {
|
||||
return [];
|
||||
}
|
||||
return sortValuesByOrder(commaValuesParser(model.customValue), model.sort).map(
|
||||
String,
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ function firstConfiguredDefault(model: VariableFormModel): string | undefined {
|
||||
}
|
||||
|
||||
/** A TEXT variable's default: its configured default, else its textValue (always a string). */
|
||||
function textDefault(model: VariableFormModel): string {
|
||||
export function textDefault(model: VariableFormModel): string {
|
||||
return firstConfiguredDefault(model) ?? model.textValue;
|
||||
}
|
||||
|
||||
@@ -53,15 +53,31 @@ function isAllDefault(
|
||||
);
|
||||
}
|
||||
|
||||
/** The configured default (or first option) as a fresh selection. */
|
||||
/**
|
||||
* The default selection for a variable with nothing usable selected: its configured
|
||||
* default, else ALL for an ALL-enabled multi-select, else the first option.
|
||||
*/
|
||||
function fillDefault(
|
||||
model: VariableFormModel,
|
||||
options: string[],
|
||||
): VariableSelection {
|
||||
const fallback = firstConfiguredDefault(model);
|
||||
const initial = fallback && options.includes(fallback) ? fallback : options[0];
|
||||
if (fallback && options.includes(fallback)) {
|
||||
return {
|
||||
value: model.multiSelect ? [fallback] : fallback,
|
||||
allSelected: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No usable configured default: an ALL-enabled multi-select defaults to ALL, not
|
||||
// to an arbitrary first option — the same default the seed applies (keep in sync
|
||||
// with resolveDefaultSelection).
|
||||
if (model.multiSelect && model.showAllOption) {
|
||||
return materializeAll(model, options, null) ?? ALL_SELECTION;
|
||||
}
|
||||
|
||||
return {
|
||||
value: model.multiSelect ? [initial] : initial,
|
||||
value: model.multiSelect ? [options[0]] : options[0],
|
||||
allSelected: false,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user