Skip to content

Commit 17e140c

Browse files
fix(apm): make service map environment control single-select (#271754)
## Summary - The service map environment filter was rendered through the Controls API as a multi-select. Picking a second environment produced a multi-value `phrases` filter that the dedicated single-value `?environment=` URL param could not round-trip, so the map collapsed to "all environments" on the second pick. - Sets `single_select: true` on the `service.environment` options list control via a new per-field flag on `ServiceMapControlConfig`. The other controls (`service.name`, `cloud.region`, `cloud.availability_zone`) keep the default multi-select behavior. - Uses the Controls API's built-in `single_select` capability — no changes to the Controls plugin itself, no URL/state plumbing changes. The Controls runtime trims any pre-existing multi-selection down to one value as a safety net, so refreshing an old URL won't break. This matches the behavior of the env dropdown on every other APM page. ## How to test 1. Use a deployment with 2+ APM environments. 2. Go to APM -> Service Map. 3. Open the Environment control and pick env A, then pick env B. 4. Only env B should remain selected, the map should refilter, and `?environment=` in the URL should reflect the latest single pick. 5. Refresh the page -> the selection should be restored. 6. The other controls (Service name, Cloud region, Cloud availability zone) should still allow multi-select. https://github.com/user-attachments/assets/8cbc3a4b-54bf-4610-915f-da4b6819d27b ## References Closes #271721 Made with [Cursor](https://cursor.com) Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f254f19 commit 17e140c

3 files changed

Lines changed: 117 additions & 0 deletions

File tree

x-pack/solutions/observability/plugins/apm/public/components/app/service_map/service_map_control_panels_config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ export interface ServiceMapControlConfig {
1313
title: string;
1414
width: 'small' | 'medium' | 'large';
1515
grow: boolean;
16+
/**
17+
* When `true`, the underlying options list control allows only one selected
18+
* value at a time. Defaults to `false` (multi-select).
19+
*/
20+
single_select?: boolean;
1621
}
1722

1823
/**
@@ -27,6 +32,10 @@ export const SERVICE_MAP_CONTROLS_CONFIG: ServiceMapControlConfig[] = [
2732
}),
2833
width: 'small',
2934
grow: true,
35+
// APM uses a single-value `?environment=` URL param everywhere else, so the
36+
// service map env filter must also be single-select to stay consistent and
37+
// round-trip correctly through the URL.
38+
single_select: true,
3039
},
3140
{
3241
field_name: 'service.name',
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { render, waitFor } from '@testing-library/react';
10+
import type {
11+
ControlGroupRuntimeState,
12+
ControlGroupStateBuilder,
13+
} from '@kbn/control-group-renderer';
14+
import type { DataView } from '@kbn/data-views-plugin/public';
15+
import { ServiceMapControls } from './service_map_controls';
16+
17+
type GetCreationOptions = (
18+
initialState: ControlGroupRuntimeState,
19+
builder: ControlGroupStateBuilder
20+
) => Promise<unknown>;
21+
22+
const capturedProps: { getCreationOptions?: GetCreationOptions } = {};
23+
24+
jest.mock('@kbn/control-group-renderer', () => ({
25+
ControlGroupRenderer: jest.fn().mockImplementation((props) => {
26+
capturedProps.getCreationOptions = props.getCreationOptions;
27+
return <div data-testid="control-group-renderer" />;
28+
}),
29+
}));
30+
31+
const dataView = { id: 'apm-data-view' } as DataView;
32+
const baseProps = {
33+
dataView,
34+
timeRange: { from: 'now-15m', to: 'now' },
35+
filters: [],
36+
query: { query: '', language: 'kuery' as const },
37+
onFiltersChange: jest.fn(),
38+
};
39+
40+
describe('ServiceMapControls', () => {
41+
beforeEach(() => {
42+
capturedProps.getCreationOptions = undefined;
43+
});
44+
45+
it('configures single_select=true for service.environment and leaves the other controls multi-select', async () => {
46+
render(<ServiceMapControls {...baseProps} />);
47+
48+
await waitFor(() => {
49+
expect(capturedProps.getCreationOptions).toBeDefined();
50+
});
51+
52+
const addOptionsListControl = jest.fn();
53+
const builder = { addOptionsListControl } as unknown as ControlGroupStateBuilder;
54+
55+
await capturedProps.getCreationOptions!({} as ControlGroupRuntimeState, builder);
56+
57+
const callsByField = Object.fromEntries(
58+
addOptionsListControl.mock.calls.map(([, controlState]) => [
59+
controlState.field_name,
60+
controlState,
61+
])
62+
);
63+
64+
expect(callsByField['service.environment'].single_select).toBe(true);
65+
expect(callsByField['service.name'].single_select).toBeUndefined();
66+
expect(callsByField['cloud.region'].single_select).toBeUndefined();
67+
expect(callsByField['cloud.availability_zone'].single_select).toBeUndefined();
68+
});
69+
70+
it('passes single_select from a custom controlsConfig through to the builder', async () => {
71+
render(
72+
<ServiceMapControls
73+
{...baseProps}
74+
controlsConfig={[
75+
{
76+
field_name: 'service.environment',
77+
title: 'Environment',
78+
width: 'small',
79+
grow: true,
80+
single_select: true,
81+
},
82+
{
83+
field_name: 'cloud.region',
84+
title: 'Cloud region',
85+
width: 'small',
86+
grow: true,
87+
},
88+
]}
89+
/>
90+
);
91+
92+
await waitFor(() => {
93+
expect(capturedProps.getCreationOptions).toBeDefined();
94+
});
95+
96+
const addOptionsListControl = jest.fn();
97+
const builder = { addOptionsListControl } as unknown as ControlGroupStateBuilder;
98+
99+
await capturedProps.getCreationOptions!({} as ControlGroupRuntimeState, builder);
100+
101+
expect(addOptionsListControl).toHaveBeenCalledTimes(2);
102+
const [envCall, regionCall] = addOptionsListControl.mock.calls;
103+
expect(envCall[1]).toMatchObject({ field_name: 'service.environment', single_select: true });
104+
expect(regionCall[1]).toMatchObject({ field_name: 'cloud.region' });
105+
expect(regionCall[1].single_select).toBeUndefined();
106+
});
107+
});

x-pack/solutions/observability/plugins/apm/public/components/app/service_map/service_map_controls.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export function ServiceMapControls({
6868
width: config.width,
6969
grow: config.grow,
7070
selected_options: initialSelectionsRef.current?.[config.field_name] ?? [],
71+
single_select: config.single_select,
7172
});
7273
}
7374
return { initialState: state as ControlGroupRuntimeState };

0 commit comments

Comments
 (0)