diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_fetch_metrics_data.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_fetch_metrics_data.ts
index 5a4c591ddd194..9c4d622bf1508 100644
--- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_fetch_metrics_data.ts
+++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/hooks/use_fetch_metrics_data.ts
@@ -20,8 +20,8 @@ import { getEsqlQuery } from '../utils/get_esql_query';
/**
* Fetches METRICS_INFO when in Metrics Experience (non-transformational ES|QL, chart visible).
- * When selectedDimensionNames has more than one item, refetches with a WHERE filter so only
- * metrics that have at least one of those dimensions are returned.
+ * When selectedDimensionNames is non-empty, refetches with a WHERE filter so only
+ * metrics that have all of the selected dimensions are returned.
* Returns loading state, error, and parsed metrics info for the grid.
*/
export function useFetchMetricsData({
diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_experience_grid.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_experience_grid.tsx
index 9f19488b0676f..17853e1ad33aa 100644
--- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_experience_grid.tsx
+++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/observability/metrics/metrics_experience_grid.tsx
@@ -103,6 +103,7 @@ export const MetricsExperienceGrid = ({
const { toggleActions, leftSideActions, rightSideActions } = useToolbarActions({
allDimensions,
+ metricItems,
renderToggleActions,
onDimensionsChange: onToolbarDimensionsChange,
isLoading: isDiscoverLoading,
diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector.test.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector.test.tsx
index 026b4f9b935e4..21006bf88db53 100644
--- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector.test.tsx
+++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector.test.tsx
@@ -11,7 +11,7 @@ import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { DimensionsSelector } from './dimensions_selector';
-import type { Dimension } from '../../types';
+import type { Dimension, ParsedMetricItem } from '../../types';
import {
MAX_DIMENSIONS_SELECTIONS,
METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ,
@@ -37,42 +37,65 @@ jest.mock('@kbn/shared-ux-toolbar-selector', () => {
popoverContentBelowSearch?: React.ReactNode;
'data-test-subj'?: string;
singleSelection?: boolean;
- }) => (
-
-
- {buttonLabel}
- {buttonTooltipContent && (
-
{buttonTooltipContent}
- )}
-
-
- {popoverContentBelowSearch}
- {options.map((option) => (
-
!option.disabled && onChange?.(option)}
- onKeyDown={(e) => {
- if ((e.key === 'Enter' || e.key === ' ') && !option.disabled) {
- e.preventDefault();
- onChange?.(option);
- }
- }}
- role="option"
- aria-selected={option.checked === 'on'}
- tabIndex={option.disabled ? -1 : 0}
- >
- {option.label}
-
- ))}
+ }) => {
+ // Simulate the real ToolbarSelector multi-selection semantics: clicking
+ // an option toggles its checked state and emits the full array of
+ // currently-checked options. For single selection, emit just the clicked
+ // option. This matches the behaviour implemented in toolbar_selector.tsx
+ // so that tests exercise the component's handleChange with the same
+ // payload shape it receives at runtime.
+ const handleOptionClick = (clickedOption: any) => {
+ if (clickedOption.disabled) return;
+ if (singleSelection) {
+ onChange?.(clickedOption);
+ return;
+ }
+ const wasChecked = clickedOption.checked === 'on';
+ const nextSelected = options
+ .filter((option) => {
+ if (option.value === clickedOption.value) return !wasChecked;
+ return option.checked === 'on';
+ })
+ .map((option) => ({ ...option, checked: 'on' }));
+ onChange?.(nextSelected);
+ };
+ return (
+
+
+ {buttonLabel}
+ {buttonTooltipContent && (
+
{buttonTooltipContent}
+ )}
+
+
+ {popoverContentBelowSearch}
+ {options.map((option) => (
+
handleOptionClick(option)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ handleOptionClick(option);
+ }
+ }}
+ role="option"
+ aria-selected={option.checked === 'on'}
+ tabIndex={option.disabled ? -1 : 0}
+ >
+ {option.label}
+
+ ))}
+
-
- ),
+ );
+ },
};
});
@@ -110,20 +133,12 @@ const mockDimensions: Dimension[] = [
{ name: 'cloud.availability_zone' },
] as Dimension[];
-const mockFields = [
- { dimensions: [mockDimensions[0], mockDimensions[1]] },
- { dimensions: [mockDimensions[0], mockDimensions[2]] },
- { dimensions: [mockDimensions[1], mockDimensions[3]] },
- { dimensions: [mockDimensions[0], mockDimensions[1], mockDimensions[2]] },
-];
-
const renderWithIntl = (component: React.ReactElement) => {
return render(
{component});
};
describe('DimensionsSelector', () => {
const defaultProps = {
- fields: mockFields,
dimensions: mockDimensions,
selectedDimensions: [],
onChange: jest.fn(),
@@ -386,4 +401,256 @@ describe('DimensionsSelector', () => {
expect(selector).toBeInTheDocument();
});
});
+
+ describe('Selected dimensions not in applicable set (race-condition guard)', () => {
+ // A selected dimension must stay visible in the picker even when it is
+ // not in `dimensions` — e.g. the latest METRICS_INFO response narrowed
+ // the applicable set and dropped it. Without this the count badge can
+ // disagree with the visible checkmarks.
+ const applicableOnly = [{ name: 'b' }, { name: 'c' }] as Dimension[];
+ const orphanPlusApplicable = [{ name: 'a' }, { name: 'b' }] as Dimension[];
+
+ it('shows selected dimensions even when they are not in the dimensions prop', () => {
+ renderWithIntl(
+
+ );
+
+ const orphanOption = screen.getByTestId(
+ `${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-a`
+ );
+ const applicableOption = screen.getByTestId(
+ `${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-b`
+ );
+ expect(orphanOption).toBeInTheDocument();
+ expect(applicableOption).toBeInTheDocument();
+ expect(orphanOption).toHaveAttribute('data-checked', 'on');
+ expect(applicableOption).toHaveAttribute('data-checked', 'on');
+ });
+
+ it('reflects both orphan and applicable selections in the popover count', () => {
+ renderWithIntl(
+
+ );
+ const popover = screen.getByTestId(`${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Popover`);
+ expect(popover).toHaveTextContent('2 dimensions selected');
+
+ const button = screen.getByTestId(`${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Button`);
+ expect(button).toHaveTextContent('2');
+ });
+
+ it('renders orphan selections before applicable options', () => {
+ renderWithIntl(
+
+ );
+ const optionElements = screen.getAllByRole('option');
+ const names = optionElements.map((el) => el.textContent);
+ // Orphan selection 'a' should come before applicable options 'b'/'c'.
+ expect(names.indexOf('a')).toBeLessThan(names.indexOf('b'));
+ expect(names.indexOf('a')).toBeLessThan(names.indexOf('c'));
+ });
+
+ it('sorts multiple orphan selections alphabetically amongst themselves', () => {
+ const dimensions = [{ name: 'z' }] as Dimension[];
+ const selected = [{ name: 'q' }, { name: 'a' }, { name: 'z' }] as Dimension[];
+
+ renderWithIntl(
+
+ );
+
+ const optionElements = screen.getAllByRole('option');
+ const names = optionElements.map((el) => el.textContent);
+ // Orphan selections a, q should appear before applicable z, and in
+ // alphabetical order relative to each other.
+ expect(names).toEqual(['a', 'q', 'z']);
+ });
+
+ it('deselecting an orphan selection calls onChange with the remaining selections', async () => {
+ const onChange = jest.fn();
+ renderWithIntl(
+
+ );
+
+ const orphanOption = screen.getByTestId(
+ `${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-a`
+ );
+ // Toggle off the orphan. The mock toolbar selector re-invokes onChange
+ // with the whole list of still-checked options; it reports the clicked
+ // option as `checked: 'on'` in its payload, so we need to verify the
+ // real component's handleChange strips off the toggled-off option.
+ fireEvent.click(orphanOption);
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalled();
+ });
+ // The last call should not contain dimension 'a' any longer.
+ const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
+ const names = lastCall[0].map((d: Dimension) => d.name);
+ expect(names).not.toContain('a');
+ expect(names).toContain('b');
+ });
+
+ it('renders only applicable options when no orphan selections exist', () => {
+ renderWithIntl(
+
+ );
+
+ expect(
+ screen.queryByTestId(`${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-a`)
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByTestId(`${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-b`)
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId(`${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-c`)
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('Optimistic filter via metricItems', () => {
+ // Once a selection is made, the picker must immediately hide dimensions
+ // that no metric carrying that selection supports — without waiting for
+ // the server round-trip — so rapid multi-select can't reach an empty grid.
+ const environment = { name: 'environment' } as Dimension;
+ const region = { name: 'region' } as Dimension;
+ const hostName = { name: 'host.name' } as Dimension;
+
+ const buildMetricItem = (
+ metricName: string,
+ dimensionFields: Dimension[]
+ ): ParsedMetricItem => ({
+ metricName,
+ dataStream: 'metrics-test',
+ units: [],
+ metricTypes: [],
+ fieldTypes: [],
+ dimensionFields,
+ });
+
+ // cpu.usage carries `environment` + `host.name`.
+ // network.bytes_in carries `region` + `host.name`.
+ // No metric carries both `environment` and `region`.
+ const metricItems: ParsedMetricItem[] = [
+ buildMetricItem('cpu.usage', [environment, hostName]),
+ buildMetricItem('network.bytes_in', [region, hostName]),
+ ];
+
+ const applicableDimensions: Dimension[] = [environment, region, hostName];
+
+ it('hides dimensions not supported by any metric carrying the current selection', () => {
+ renderWithIntl(
+
+ );
+
+ // `region` is disjoint from `environment` (no single metric carries
+ // both) so the picker must hide it optimistically, before the debounced
+ // onChange fires and the server responds.
+ expect(
+ screen.queryByTestId(`${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-region`)
+ ).not.toBeInTheDocument();
+
+ expect(
+ screen.getByTestId(`${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-environment`)
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId(`${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-host.name`)
+ ).toBeInTheDocument();
+ });
+
+ it('without metricItems, falls back to the full dimensions list', () => {
+ // The prop is optional; callers that don't provide it get the options
+ // straight from `dimensions`, no client-side narrowing.
+ renderWithIntl(
+
+ );
+
+ expect(
+ screen.getByTestId(`${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-environment`)
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId(`${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-region`)
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId(`${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-host.name`)
+ ).toBeInTheDocument();
+ });
+
+ it('orphan selections still surface even with the optimistic filter', () => {
+ // The optimistic filter operates on applicable options; a selected
+ // dimension that isn't in metricItems still renders (checked) so the
+ // count stays consistent and the user can back out.
+ const orphan = { name: 'orphan.field' } as Dimension;
+
+ renderWithIntl(
+
+ );
+
+ const orphanOption = screen.getByTestId(
+ `${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-orphan.field`
+ );
+ expect(orphanOption).toBeInTheDocument();
+ expect(orphanOption).toHaveAttribute('data-checked', 'on');
+ });
+
+ it('no selection means the full applicable list is shown', () => {
+ // With nothing selected there's no metric subset to constrain to, so
+ // every applicable dimension must remain available.
+ renderWithIntl(
+
+ );
+
+ expect(
+ screen.getByTestId(`${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-environment`)
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId(`${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-region`)
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId(`${METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ}Option-host.name`)
+ ).toBeInTheDocument();
+ });
+ });
});
diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector.tsx
index 3e928790f297d..f4efb9c904baa 100644
--- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector.tsx
+++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector.tsx
@@ -7,30 +7,12 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
-import React, { useMemo, useCallback, useRef, useEffect, useState } from 'react';
-import { FormattedMessage } from '@kbn/i18n-react';
-import {
- EuiFlexGroup,
- EuiFlexItem,
- EuiLoadingSpinner,
- EuiNotificationBadge,
- EuiText,
- EuiToolTip,
- EuiButtonEmpty,
- EuiSpacer,
- useEuiTheme,
-} from '@elastic/eui';
-import { ToolbarSelector, type SelectableEntry } from '@kbn/shared-ux-toolbar-selector';
+import React from 'react';
+import { ToolbarSelector } from '@kbn/shared-ux-toolbar-selector';
import { comboBoxFieldOptionMatcher } from '@kbn/field-utils';
-import { css } from '@emotion/react';
-import { debounce } from 'lodash';
-import type { Dimension } from '../../types';
-import {
- MAX_DIMENSIONS_SELECTIONS,
- METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ,
- DEBOUNCE_TIME,
-} from '../../common/constants';
-import { getOptionDisabledState } from './dimensions_selector_helpers';
+import type { Dimension, ParsedMetricItem } from '../../types';
+import { METRICS_BREAKDOWN_SELECTOR_DATA_TEST_SUBJ } from '../../common/constants';
+import { useDimensionsSelector } from './hooks/use_dimensions_selector';
interface DimensionsSelectorProps {
dimensions: Dimension[];
@@ -39,6 +21,14 @@ interface DimensionsSelectorProps {
onChange: (dimensions: Dimension[]) => void;
singleSelection?: boolean;
isLoading?: boolean;
+ /**
+ * When provided, the option list is filtered on the client to dimensions
+ * carried by at least one metric that also carries every current selection,
+ * preventing rapid multi-select from reaching an empty-grid state. Selected
+ * dimensions not in the applicable set always stay visible regardless of
+ * this prop (e.g. after URL restore).
+ */
+ metricItems?: ParsedMetricItem[];
}
export const DimensionsSelector = ({
@@ -48,237 +38,28 @@ export const DimensionsSelector = ({
fullWidth = false,
singleSelection = false,
isLoading = false,
+ metricItems,
}: DimensionsSelectorProps) => {
- const { euiTheme } = useEuiTheme();
- const [localSelectedDimensions, setLocalSelectedDimensions] =
- useState
(selectedDimensions);
-
- useEffect(() => {
- setLocalSelectedDimensions(selectedDimensions);
- }, [selectedDimensions]);
-
- const selectedNamesSet = useMemo(
- () => new Set(localSelectedDimensions.map((d) => d.name)),
- [localSelectedDimensions]
- );
-
- const options: SelectableEntry[] = useMemo(() => {
- const isAtMaxLimit = localSelectedDimensions.length >= MAX_DIMENSIONS_SELECTIONS;
-
- const mappedOptions = dimensions.map((dimension) => {
- const isSelected = selectedNamesSet.has(dimension.name);
-
- const isDisabled = getOptionDisabledState({
- singleSelection,
- isSelected,
- isAtMaxLimit,
- });
-
- const tooltipContent =
- isAtMaxLimit && isDisabled ? (
-
- ) : undefined;
-
- const option: SelectableEntry = {
- value: dimension.name,
- label: dimension.name,
- checked: isSelected ? 'on' : undefined,
- disabled: isDisabled,
- key: dimension.name,
- };
-
- if (tooltipContent) {
- option.append = (
-
-
-
- );
- }
-
- return option;
- });
-
- return mappedOptions;
- }, [
+ const {
+ options,
+ buttonLabel,
+ buttonTooltipContent,
+ popoverContentBelowSearch,
+ handleChange,
+ selectedValues,
+ } = useDimensionsSelector({
dimensions,
- selectedNamesSet,
- localSelectedDimensions,
+ selectedDimensions,
+ onChange,
singleSelection,
- euiTheme.levels.menu,
- ]);
-
- const onChangeRef = useRef(onChange);
- useEffect(() => {
- onChangeRef.current = onChange;
- }, [onChange]);
-
- // Create debounced onChange only for multi-selection mode
- const debouncedOnChange = useMemo(() => {
- if (singleSelection) {
- return null;
- }
- return debounce((dim: Dimension[]) => {
- onChangeRef.current(dim);
- }, DEBOUNCE_TIME);
- }, [singleSelection]);
-
- useEffect(() => {
- return () => {
- if (debouncedOnChange) {
- debouncedOnChange.cancel();
- }
- };
- }, [debouncedOnChange]);
-
- const handleChange = useCallback(
- (chosenOption?: SelectableEntry | SelectableEntry[]) => {
- const opts =
- chosenOption == null ? [] : Array.isArray(chosenOption) ? chosenOption : [chosenOption];
- const newSelection = opts
- .map((opt) => dimensions.find((d) => d.name === opt.value))
- .filter((d): d is Dimension => d !== undefined)
- .slice(0, MAX_DIMENSIONS_SELECTIONS);
-
- // For single selection, call onChange immediately
- if (singleSelection || !debouncedOnChange) {
- setLocalSelectedDimensions(newSelection);
- onChange(newSelection);
- } else {
- setLocalSelectedDimensions(newSelection);
- debouncedOnChange.cancel();
- debouncedOnChange(newSelection);
- }
- },
- [onChange, dimensions, singleSelection, debouncedOnChange]
- );
-
- const handleClearAll = useCallback(() => {
- if (debouncedOnChange) {
- debouncedOnChange.cancel();
- }
-
- setLocalSelectedDimensions([]);
- onChange([]);
- }, [onChange, debouncedOnChange]);
-
- const buttonLabel = useMemo(() => {
- const count = localSelectedDimensions.length;
-
- return (
-
-
- {count === 0 ? (
-
- ) : (
-
-
-
-
-
- {count}
-
-
- )}
-
- {isLoading && (
-
-
-
- )}
-
- );
- }, [localSelectedDimensions, isLoading]);
-
- // Create tooltip content for when at max dimensions
- const buttonTooltipContent = useMemo(() => {
- const count = localSelectedDimensions.length;
- const isAtMaxDimensions = count >= MAX_DIMENSIONS_SELECTIONS;
-
- if (isAtMaxDimensions) {
- return (
-
- );
- }
-
- return undefined;
- }, [localSelectedDimensions]);
-
- const popoverContentBelowSearch = useMemo(() => {
- const count = localSelectedDimensions.length;
- return (
- <>
-
-
-
-
-
-
-
- {count > 0 && (
-
-
-
-
-
- )}
-
-
- >
- );
- }, [localSelectedDimensions.length, handleClearAll, euiTheme.size.l]);
+ isLoading,
+ metricItems,
+ });
return (
(
+
+
+ {count === 0 ? (
+
+ ) : (
+
+
+
+
+
+ {count}
+
+
+ )}
+
+ {isLoading && (
+
+
+
+ )}
+
+);
diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_components/dimensions_popover_footer.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_components/dimensions_popover_footer.tsx
new file mode 100644
index 0000000000000..bb5be2d3a4d3e
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_components/dimensions_popover_footer.tsx
@@ -0,0 +1,70 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import React from 'react';
+import {
+ EuiButtonEmpty,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSpacer,
+ EuiText,
+ useEuiTheme,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n-react';
+import { css } from '@emotion/react';
+
+interface DimensionsPopoverFooterProps {
+ count: number;
+ onClear: () => void;
+}
+
+/**
+ * Footer row rendered below the search input in the dimensions popover.
+ * Shows the current selection count and a "Clear selection" action when
+ * there is anything to clear.
+ */
+export const DimensionsPopoverFooter = ({ count, onClear }: DimensionsPopoverFooterProps) => {
+ const { euiTheme } = useEuiTheme();
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {count > 0 && (
+
+
+
+
+
+ )}
+
+
+ >
+ );
+};
diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_components/index.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_components/index.ts
new file mode 100644
index 0000000000000..2c3c12538227e
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_components/index.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+export { MaxDimensionsWarning } from './max_dimensions_warning';
+export { MaxDimensionsTooltipOverlay } from './max_dimensions_tooltip_overlay';
+export { DimensionsButtonLabel } from './dimensions_button_label';
+export { DimensionsPopoverFooter } from './dimensions_popover_footer';
diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_components/max_dimensions_tooltip_overlay.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_components/max_dimensions_tooltip_overlay.tsx
new file mode 100644
index 0000000000000..09e0c2efda869
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_components/max_dimensions_tooltip_overlay.tsx
@@ -0,0 +1,42 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import React from 'react';
+import { EuiToolTip, useEuiTheme } from '@elastic/eui';
+import { css } from '@emotion/react';
+import { MaxDimensionsWarning } from './max_dimensions_warning';
+
+/**
+ * Absolutely-positioned overlay that fills its row so the tooltip triggers
+ * anywhere on a disabled (at-max-limit) dimension option. The overlay has to
+ * cover the option because EuiSelectable's row is focus-trapping and does not
+ * surface its own tooltip slot.
+ */
+export const MaxDimensionsTooltipOverlay = () => {
+ const { euiTheme } = useEuiTheme();
+
+ return (
+ }
+ position="top"
+ anchorProps={{
+ css: css`
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: auto;
+ z-index: ${euiTheme.levels.menu};
+ `,
+ }}
+ >
+
+
+ );
+};
diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_components/max_dimensions_warning.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_components/max_dimensions_warning.tsx
new file mode 100644
index 0000000000000..c53e95edd6716
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_components/max_dimensions_warning.tsx
@@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import React from 'react';
+import { FormattedMessage } from '@kbn/i18n-react';
+import { MAX_DIMENSIONS_SELECTIONS } from '../../../common/constants';
+
+/**
+ * Plain message announcing that the user has hit the maximum number of
+ * dimensions. Rendered both inside the popover tooltip overlay (per-option)
+ * and as the button-level tooltip.
+ */
+export const MaxDimensionsWarning = () => (
+
+);
diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_helpers.test.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_helpers.test.ts
index a8f8dcdc4324b..945b7b9466eb1 100644
--- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_helpers.test.ts
+++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_helpers.test.ts
@@ -7,7 +7,21 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
-import { getOptionDisabledState } from './dimensions_selector_helpers';
+import type { Dimension, ParsedMetricItem } from '../../types';
+import {
+ getApplicableDimensionNames,
+ getOptionDisabledState,
+ partitionDimensionsForRender,
+} from './dimensions_selector_helpers';
+
+const buildMetricItem = (metricName: string, dimensionFields: Dimension[]): ParsedMetricItem => ({
+ metricName,
+ dataStream: 'metrics-test',
+ units: [],
+ metricTypes: [],
+ fieldTypes: [],
+ dimensionFields,
+});
describe('dimensions_selector_helpers', () => {
describe('getOptionDisabledState', () => {
@@ -57,4 +71,83 @@ describe('dimensions_selector_helpers', () => {
).toBe(true);
});
});
+
+ describe('getApplicableDimensionNames', () => {
+ const environment: Dimension = { name: 'environment' };
+ const region: Dimension = { name: 'region' };
+ const hostName: Dimension = { name: 'host.name' };
+
+ const metricItems: ParsedMetricItem[] = [
+ buildMetricItem('cpu.usage', [environment, hostName]),
+ buildMetricItem('network.bytes_in', [region, hostName]),
+ ];
+
+ it('returns the union of dimensions across metrics that carry every selection', () => {
+ const applicable = getApplicableDimensionNames(metricItems, ['host.name']);
+ expect(applicable).toEqual(new Set(['environment', 'region', 'host.name']));
+ });
+
+ it('narrows to a single metric when only one carries every selected dimension', () => {
+ const applicable = getApplicableDimensionNames(metricItems, ['environment']);
+ expect(applicable).toEqual(new Set(['environment', 'host.name']));
+ });
+
+ it('returns an empty set when no metric carries every selected dimension', () => {
+ const applicable = getApplicableDimensionNames(metricItems, ['environment', 'region']);
+ expect(applicable).toEqual(new Set());
+ });
+
+ it('returns every dimension name when the selection is empty', () => {
+ const applicable = getApplicableDimensionNames(metricItems, []);
+ expect(applicable).toEqual(new Set(['environment', 'region', 'host.name']));
+ });
+ });
+
+ describe('partitionDimensionsForRender', () => {
+ const a: Dimension = { name: 'a' };
+ const b: Dimension = { name: 'b' };
+ const c: Dimension = { name: 'c' };
+
+ it('returns dimensions in caller order when no optimistic filter is active', () => {
+ const result = partitionDimensionsForRender({
+ dimensions: [b, a, c],
+ selectedDimensions: [],
+ optimisticApplicableNames: null,
+ });
+ expect(result.orphanSelections).toEqual([]);
+ expect(result.applicableDimensions).toEqual([b, a, c]);
+ });
+
+ it('narrows applicable dimensions when an optimistic filter is provided', () => {
+ const result = partitionDimensionsForRender({
+ dimensions: [a, b, c],
+ selectedDimensions: [a],
+ optimisticApplicableNames: new Set(['a', 'b']),
+ });
+ expect(result.applicableDimensions).toEqual([a, b]);
+ expect(result.orphanSelections).toEqual([]);
+ });
+
+ it('surfaces selections that fall outside the applicable set as orphans', () => {
+ const orphan: Dimension = { name: 'orphan' };
+ const result = partitionDimensionsForRender({
+ dimensions: [a, b],
+ selectedDimensions: [orphan, a],
+ optimisticApplicableNames: new Set(['a', 'b']),
+ });
+ expect(result.orphanSelections).toEqual([orphan]);
+ expect(result.applicableDimensions).toEqual([a, b]);
+ });
+
+ it('sorts multiple orphans alphabetically', () => {
+ const q: Dimension = { name: 'q' };
+ const z: Dimension = { name: 'z' };
+ const result = partitionDimensionsForRender({
+ dimensions: [],
+ selectedDimensions: [z, a, q],
+ optimisticApplicableNames: null,
+ });
+ expect(result.orphanSelections.map((d) => d.name)).toEqual(['a', 'q', 'z']);
+ });
+ });
});
diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_helpers.ts b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_helpers.ts
index 3a3b0e4c014a3..5668bad3f5f9f 100644
--- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_helpers.ts
+++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/dimensions_selector_helpers.ts
@@ -7,6 +7,49 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
+import type { ReactNode } from 'react';
+import type { SelectableEntry } from '@kbn/shared-ux-toolbar-selector';
+import type { Dimension, ParsedMetricItem } from '../../types';
+
+/**
+ * A `SelectableEntry` carrying the `Dimension` it was built from. Keeping the
+ * dimension on the option lets `handleChange` read it back without a reverse
+ * lookup against `dimensions` + `localSelectedDimensions`.
+ */
+export type DimensionEntry = SelectableEntry & { dimension: Dimension };
+
+interface BuildDimensionOptionParams {
+ dimension: Dimension;
+ isSelected: boolean;
+ isDisabled: boolean;
+ appendNode?: ReactNode;
+}
+
+/**
+ * Builds the `DimensionEntry` rendered by the toolbar selector. Kept as a
+ * helper so the hook's `options` memo stays focused on partitioning and
+ * disabled-state derivation.
+ */
+export const buildDimensionOption = ({
+ dimension,
+ isSelected,
+ isDisabled,
+ appendNode,
+}: BuildDimensionOptionParams): DimensionEntry => {
+ const option: DimensionEntry = {
+ value: dimension.name,
+ label: dimension.name,
+ checked: isSelected ? 'on' : undefined,
+ disabled: isDisabled,
+ key: dimension.name,
+ dimension,
+ };
+ if (appendNode) {
+ option.append = appendNode;
+ }
+ return option;
+};
+
interface OptionDisabledStateParams {
singleSelection: boolean;
isSelected: boolean;
@@ -28,3 +71,62 @@ export const getOptionDisabledState = ({
if (isSelected) return false;
return isAtMaxLimit;
};
+
+/**
+ * Returns the set of dimension names carried by at least one metric that
+ * also carries every currently-selected dimension. Used by the picker to
+ * optimistically hide options that, if selected, would produce an empty
+ * grid — without waiting for the server round-trip.
+ */
+export const getApplicableDimensionNames = (
+ metricItems: ParsedMetricItem[],
+ selectedNames: readonly string[]
+): Set => {
+ const carriesAllSelected = (item: ParsedMetricItem) => {
+ const itemDimNames = new Set(item.dimensionFields.map((d) => d.name));
+ return selectedNames.every((name) => itemDimNames.has(name));
+ };
+ return new Set(
+ metricItems
+ .filter(carriesAllSelected)
+ .flatMap((item) => item.dimensionFields.map((d) => d.name))
+ );
+};
+
+interface PartitionedDimensionsParams {
+ dimensions: Dimension[];
+ selectedDimensions: Dimension[];
+ optimisticApplicableNames: Set | null;
+}
+
+interface PartitionedDimensions {
+ /** Selections not in the applicable set — always surfaced so the count badge matches the visible ticks. */
+ orphanSelections: Dimension[];
+ /** Applicable dimensions in their caller-provided order, narrowed by the optimistic filter when active. */
+ applicableDimensions: Dimension[];
+}
+
+/**
+ * Splits the picker's render list into orphaned selections (shown first,
+ * alphabetically sorted) and applicable dimensions (caller order preserved).
+ * Keeps the split pure so the component stays presentational.
+ */
+export const partitionDimensionsForRender = ({
+ dimensions,
+ selectedDimensions,
+ optimisticApplicableNames,
+}: PartitionedDimensionsParams): PartitionedDimensions => {
+ const applicableNames = optimisticApplicableNames ?? new Set(dimensions.map((d) => d.name));
+
+ const applicableDimensions =
+ optimisticApplicableNames == null
+ ? dimensions
+ : dimensions.filter((dimension) => optimisticApplicableNames.has(dimension.name));
+
+ const orphanSelections = selectedDimensions
+ .filter((dimension) => !applicableNames.has(dimension.name))
+ .slice()
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ return { orphanSelections, applicableDimensions };
+};
diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/hooks/use_dimensions_selector.test.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/hooks/use_dimensions_selector.test.tsx
new file mode 100644
index 0000000000000..90531f8dfa554
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/hooks/use_dimensions_selector.test.tsx
@@ -0,0 +1,416 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import React from 'react';
+import type { ReactNode } from 'react';
+import { act, renderHook } from '@testing-library/react';
+import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
+import type { SelectableEntry } from '@kbn/shared-ux-toolbar-selector';
+import { useDimensionsSelector } from './use_dimensions_selector';
+import type { Dimension, ParsedMetricItem } from '../../../types';
+import { DEBOUNCE_TIME, MAX_DIMENSIONS_SELECTIONS } from '../../../common/constants';
+import type { DimensionEntry } from '../dimensions_selector_helpers';
+
+const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+);
+
+const dim = (name: string, type: string = 'keyword'): Dimension => ({ name, type });
+
+const makeMetric = (metricName: string, dimensionFields: Dimension[]): ParsedMetricItem => ({
+ metricName,
+ dataStream: 'metrics-test',
+ units: [],
+ metricTypes: [],
+ fieldTypes: [],
+ dimensionFields,
+});
+
+type HookArgs = Parameters[0];
+
+/**
+ * Render the hook with a stable arg object. The hook has a
+ * `useEffect([selectedDimensions])` that re-syncs local state on every
+ * reference change, so tests MUST pass the same array identity across
+ * internal re-renders to avoid tripping React's infinite-update guard.
+ * `renderHook`'s re-render calls this callback with the original `args`
+ * reference, preserving identity.
+ */
+const renderDimensionsHook = (args: HookArgs) =>
+ renderHook(() => useDimensionsSelector(args), { wrapper });
+
+describe('useDimensionsSelector', () => {
+ describe('options', () => {
+ it('attaches the source Dimension to each option so handleChange can read it back', () => {
+ const { result } = renderDimensionsHook({
+ dimensions: [dim('host.name'), dim('service.name'), dim('cloud.region')],
+ selectedDimensions: [],
+ onChange: jest.fn(),
+ singleSelection: false,
+ isLoading: false,
+ });
+
+ const options = result.current.options as DimensionEntry[];
+ expect(options.map((o) => o.dimension.name)).toEqual([
+ 'host.name',
+ 'service.name',
+ 'cloud.region',
+ ]);
+ expect(options.every((o) => typeof o.dimension === 'object')).toBe(true);
+ });
+
+ it('marks selected options as checked and leaves the rest unchecked', () => {
+ const { result } = renderDimensionsHook({
+ dimensions: [dim('host.name'), dim('service.name'), dim('cloud.region')],
+ selectedDimensions: [dim('service.name')],
+ onChange: jest.fn(),
+ singleSelection: false,
+ isLoading: false,
+ });
+
+ const byValue = Object.fromEntries(result.current.options.map((o) => [o.value, o.checked]));
+ expect(byValue['service.name']).toBe('on');
+ expect(byValue['host.name']).toBeUndefined();
+ expect(byValue['cloud.region']).toBeUndefined();
+ });
+
+ it('disables unselected options when at the max selection limit (multi)', () => {
+ const selected = Array.from({ length: MAX_DIMENSIONS_SELECTIONS }, (_, i) => dim(`d${i}`));
+ const all = [...selected, dim('extra.one'), dim('extra.two')];
+ const { result } = renderDimensionsHook({
+ dimensions: all,
+ selectedDimensions: selected,
+ onChange: jest.fn(),
+ singleSelection: false,
+ isLoading: false,
+ });
+
+ const byValue = Object.fromEntries(result.current.options.map((o) => [o.value, o]));
+ // Selected options stay enabled so they can be deselected.
+ selected.forEach((d) => expect(byValue[d.name].disabled).toBe(false));
+ // Unselected options are disabled once the limit is reached.
+ expect(byValue['extra.one'].disabled).toBe(true);
+ expect(byValue['extra.two'].disabled).toBe(true);
+ });
+
+ it('appends the tooltip overlay only on disabled-at-limit options', () => {
+ const selected = Array.from({ length: MAX_DIMENSIONS_SELECTIONS }, (_, i) => dim(`d${i}`));
+ const all = [...selected, dim('extra.one')];
+ const { result } = renderDimensionsHook({
+ dimensions: all,
+ selectedDimensions: selected,
+ onChange: jest.fn(),
+ singleSelection: false,
+ isLoading: false,
+ });
+
+ const byValue = Object.fromEntries(result.current.options.map((o) => [o.value, o]));
+ // Selected entries are enabled, so no max-limit overlay.
+ expect(byValue.d0.append).toBeUndefined();
+ // Disabled-at-limit gets the overlay.
+ expect(byValue['extra.one'].append).toBeDefined();
+ });
+
+ it('never disables options in single-selection mode, even past the limit', () => {
+ // `singleSelection: true` short-circuits the at-max-limit check.
+ const selected = Array.from({ length: MAX_DIMENSIONS_SELECTIONS + 2 }, (_, i) =>
+ dim(`d${i}`)
+ );
+ const { result } = renderDimensionsHook({
+ dimensions: selected,
+ selectedDimensions: selected,
+ onChange: jest.fn(),
+ singleSelection: true,
+ isLoading: false,
+ });
+
+ expect(result.current.options.every((o) => o.disabled === false)).toBe(true);
+ });
+
+ it('prepends orphan selections (dimensions not in `dimensions`) alphabetically', () => {
+ // Orphan = selected dimension not present in the `dimensions` array.
+ // This mirrors the URL-restore case where selections outlive the list.
+ const { result } = renderDimensionsHook({
+ dimensions: [dim('host.name')],
+ selectedDimensions: [dim('zeta.orphan'), dim('alpha.orphan')],
+ onChange: jest.fn(),
+ singleSelection: false,
+ isLoading: false,
+ });
+
+ expect(result.current.options.map((o) => o.value)).toEqual([
+ 'alpha.orphan',
+ 'zeta.orphan',
+ 'host.name',
+ ]);
+ });
+
+ it('applies the optimistic filter when metricItems is provided', () => {
+ // Only metrics that carry `service.name` also carry `cloud.region` here,
+ // so picking `service.name` should hide `host.name` from the suggestion
+ // set.
+ const metricItems: ParsedMetricItem[] = [
+ makeMetric('m1', [dim('service.name'), dim('cloud.region')]),
+ makeMetric('m2', [dim('host.name')]),
+ ];
+ const { result } = renderDimensionsHook({
+ dimensions: [dim('host.name'), dim('service.name'), dim('cloud.region')],
+ selectedDimensions: [dim('service.name')],
+ onChange: jest.fn(),
+ singleSelection: false,
+ isLoading: false,
+ metricItems,
+ });
+
+ const values = result.current.options.map((o) => o.value);
+ expect(values).toContain('service.name');
+ expect(values).toContain('cloud.region');
+ expect(values).not.toContain('host.name');
+ });
+ });
+
+ describe('selectedValues', () => {
+ it('returns the de-duplicated names of local selections', () => {
+ const { result } = renderDimensionsHook({
+ dimensions: [dim('host.name'), dim('service.name')],
+ selectedDimensions: [dim('host.name'), dim('service.name')],
+ onChange: jest.fn(),
+ singleSelection: false,
+ isLoading: false,
+ });
+
+ expect(result.current.selectedValues).toEqual(['host.name', 'service.name']);
+ });
+ });
+
+ describe('local/controlled-prop sync', () => {
+ it('re-syncs local state when `selectedDimensions` prop changes', () => {
+ // All non-changing args share the same reference across renders so only
+ // `selectedDimensions` can trigger the sync effect.
+ const dimensions = [dim('host.name'), dim('service.name')];
+ const onChange = jest.fn();
+
+ const { result, rerender } = renderHook(
+ ({ selectedDimensions }: { selectedDimensions: Dimension[] }) =>
+ useDimensionsSelector({
+ dimensions,
+ selectedDimensions,
+ onChange,
+ singleSelection: false,
+ isLoading: false,
+ }),
+ { wrapper, initialProps: { selectedDimensions: [] as Dimension[] } }
+ );
+
+ expect(result.current.selectedValues).toEqual([]);
+
+ rerender({ selectedDimensions: [dim('host.name')] });
+ expect(result.current.selectedValues).toEqual(['host.name']);
+ });
+ });
+
+ describe('handleChange (multi-select)', () => {
+ beforeEach(() => jest.useFakeTimers());
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ });
+
+ it('debounces onChange by DEBOUNCE_TIME ms', () => {
+ const onChange = jest.fn();
+ const { result } = renderDimensionsHook({
+ dimensions: [dim('host.name'), dim('service.name'), dim('cloud.region')],
+ selectedDimensions: [],
+ onChange,
+ singleSelection: false,
+ isLoading: false,
+ });
+
+ const hostOption = (result.current.options as DimensionEntry[]).find(
+ (o) => o.value === 'host.name'
+ )!;
+
+ act(() => {
+ result.current.handleChange([{ ...hostOption, checked: 'on' }]);
+ });
+
+ // Pre-debounce: no onChange fired yet, but local state updates immediately
+ // so selectedValues already reflects the pick.
+ expect(onChange).not.toHaveBeenCalled();
+ expect(result.current.selectedValues).toEqual(['host.name']);
+
+ act(() => {
+ jest.advanceTimersByTime(DEBOUNCE_TIME);
+ });
+
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange).toHaveBeenCalledWith([dim('host.name')]);
+ });
+
+ it('collapses rapid consecutive changes into a single onChange call', () => {
+ const onChange = jest.fn();
+ const { result } = renderDimensionsHook({
+ dimensions: [dim('host.name'), dim('service.name'), dim('cloud.region')],
+ selectedDimensions: [],
+ onChange,
+ singleSelection: false,
+ isLoading: false,
+ });
+ const opts = result.current.options as DimensionEntry[];
+ const host = opts.find((o) => o.value === 'host.name')!;
+ const service = opts.find((o) => o.value === 'service.name')!;
+
+ act(() => {
+ result.current.handleChange([{ ...host, checked: 'on' }]);
+ result.current.handleChange([
+ { ...host, checked: 'on' },
+ { ...service, checked: 'on' },
+ ]);
+ });
+
+ act(() => {
+ jest.advanceTimersByTime(DEBOUNCE_TIME);
+ });
+
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange).toHaveBeenCalledWith([dim('host.name'), dim('service.name')]);
+ });
+
+ it('caps the emitted selection at MAX_DIMENSIONS_SELECTIONS', () => {
+ const onChange = jest.fn();
+ const extras = Array.from({ length: MAX_DIMENSIONS_SELECTIONS + 2 }, (_, i) => dim(`d${i}`));
+ const { result } = renderDimensionsHook({
+ dimensions: extras,
+ selectedDimensions: [],
+ onChange,
+ singleSelection: false,
+ isLoading: false,
+ });
+
+ const selections = (result.current.options as DimensionEntry[]).map((o) => ({
+ ...o,
+ checked: 'on' as const,
+ }));
+
+ act(() => {
+ result.current.handleChange(selections);
+ });
+ act(() => {
+ jest.advanceTimersByTime(DEBOUNCE_TIME);
+ });
+
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange.mock.calls[0][0]).toHaveLength(MAX_DIMENSIONS_SELECTIONS);
+ });
+
+ it('coerces `undefined` into an empty-array selection', () => {
+ const onChange = jest.fn();
+ const { result } = renderDimensionsHook({
+ dimensions: [dim('host.name')],
+ selectedDimensions: [],
+ onChange,
+ singleSelection: false,
+ isLoading: false,
+ });
+
+ act(() => {
+ result.current.handleChange(undefined);
+ });
+ act(() => {
+ jest.advanceTimersByTime(DEBOUNCE_TIME);
+ });
+
+ expect(onChange).toHaveBeenCalledWith([]);
+ });
+ });
+
+ describe('handleChange (single-select)', () => {
+ it('fires onChange synchronously without debouncing', () => {
+ const onChange = jest.fn();
+ const { result } = renderDimensionsHook({
+ dimensions: [dim('host.name'), dim('service.name')],
+ selectedDimensions: [],
+ onChange,
+ singleSelection: true,
+ isLoading: false,
+ });
+
+ const hostOption = (result.current.options as DimensionEntry[]).find(
+ (o) => o.value === 'host.name'
+ )!;
+
+ act(() => {
+ // Real ToolbarSelector emits a single option (not an array) in single-selection mode.
+ result.current.handleChange({ ...hostOption, checked: 'on' });
+ });
+
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange).toHaveBeenCalledWith([dim('host.name')]);
+ });
+ });
+
+ describe('button & popover nodes', () => {
+ it('exposes a `buttonTooltipContent` only when at the max limit', () => {
+ const atLimit = Array.from({ length: MAX_DIMENSIONS_SELECTIONS }, (_, i) => dim(`d${i}`));
+
+ const { result: belowLimit } = renderDimensionsHook({
+ dimensions: atLimit,
+ selectedDimensions: atLimit.slice(0, 1),
+ onChange: jest.fn(),
+ singleSelection: false,
+ isLoading: false,
+ });
+ expect(belowLimit.current.buttonTooltipContent).toBeUndefined();
+
+ const { result: atTheLimit } = renderDimensionsHook({
+ dimensions: atLimit,
+ selectedDimensions: atLimit,
+ onChange: jest.fn(),
+ singleSelection: false,
+ isLoading: false,
+ });
+ expect(atTheLimit.current.buttonTooltipContent).toBeDefined();
+ });
+
+ it('always provides a `buttonLabel` and `popoverContentBelowSearch`', () => {
+ const { result } = renderDimensionsHook({
+ dimensions: [dim('host.name')],
+ selectedDimensions: [],
+ onChange: jest.fn(),
+ singleSelection: false,
+ isLoading: false,
+ });
+ expect(result.current.buttonLabel).toBeDefined();
+ expect(result.current.popoverContentBelowSearch).toBeDefined();
+ });
+ });
+
+ describe('type discipline', () => {
+ it('gracefully drops options missing a `dimension` field (defensive guard)', () => {
+ // Simulates a third-party shape that slipped through without a dimension.
+ // `handleChange` must not crash; it should just produce an empty selection.
+ const onChange = jest.fn();
+ const { result } = renderDimensionsHook({
+ dimensions: [dim('host.name')],
+ selectedDimensions: [],
+ onChange,
+ singleSelection: true,
+ isLoading: false,
+ });
+
+ act(() => {
+ const rogue: SelectableEntry = { value: 'mystery', label: 'mystery', key: 'mystery' };
+ result.current.handleChange([rogue]);
+ });
+
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange).toHaveBeenCalledWith([]);
+ });
+ });
+});
diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/hooks/use_dimensions_selector.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/hooks/use_dimensions_selector.tsx
new file mode 100644
index 0000000000000..e5ff7816e0753
--- /dev/null
+++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/hooks/use_dimensions_selector.tsx
@@ -0,0 +1,209 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { ReactElement } from 'react';
+import type { SelectableEntry } from '@kbn/shared-ux-toolbar-selector';
+import { debounce } from 'lodash';
+import type { Dimension, ParsedMetricItem } from '../../../types';
+import { DEBOUNCE_TIME, MAX_DIMENSIONS_SELECTIONS } from '../../../common/constants';
+import {
+ buildDimensionOption,
+ getApplicableDimensionNames,
+ getOptionDisabledState,
+ partitionDimensionsForRender,
+} from '../dimensions_selector_helpers';
+import type { DimensionEntry } from '../dimensions_selector_helpers';
+import {
+ DimensionsButtonLabel,
+ DimensionsPopoverFooter,
+ MaxDimensionsTooltipOverlay,
+ MaxDimensionsWarning,
+} from '../dimensions_selector_components';
+
+interface UseDimensionsSelectorParams {
+ dimensions: Dimension[];
+ selectedDimensions: Dimension[];
+ onChange: (dimensions: Dimension[]) => void;
+ singleSelection: boolean;
+ isLoading: boolean;
+ metricItems?: ParsedMetricItem[];
+}
+
+export interface UseDimensionsSelectorResult {
+ options: SelectableEntry[];
+ buttonLabel: ReactElement;
+ buttonTooltipContent: ReactElement | undefined;
+ popoverContentBelowSearch: ReactElement;
+ handleChange: (chosenOption?: SelectableEntry | SelectableEntry[]) => void;
+ selectedValues: string[];
+}
+
+/**
+ * Encapsulates the dimensions picker's business logic so the component can
+ * stay presentational. Owns:
+ * - local selection state (mirrors the controlled prop, lets the UI render
+ * optimistically while the debounced onChange catches up)
+ * - the optimistic applicable-dimension filter derived from `metricItems`
+ * - assembly of the `DimensionEntry[]` (orphans prepended, disabled-state
+ * tooltip overlay appended at the max limit)
+ * - change + clear handlers (debounced for multi-select, immediate for
+ * single)
+ * - the button label, tooltip, and popover footer nodes (rendered as
+ * dedicated components in `../dimensions_selector_components`)
+ */
+export const useDimensionsSelector = ({
+ dimensions,
+ selectedDimensions,
+ onChange,
+ singleSelection,
+ isLoading,
+ metricItems,
+}: UseDimensionsSelectorParams): UseDimensionsSelectorResult => {
+ const [localSelectedDimensions, setLocalSelectedDimensions] =
+ useState(selectedDimensions);
+
+ useEffect(() => {
+ setLocalSelectedDimensions(selectedDimensions);
+ }, [selectedDimensions]);
+
+ const selectedNamesSet = useMemo(
+ () => new Set(localSelectedDimensions.map((d) => d.name)),
+ [localSelectedDimensions]
+ );
+
+ // Names of dimensions still carried by at least one metric that has every
+ // current selection. `null` means no client-side filter applies (either no
+ // selection yet, or metricItems wasn't provided).
+ const optimisticApplicableNames = useMemo(() => {
+ if (!metricItems || selectedNamesSet.size === 0) {
+ return null;
+ }
+ return getApplicableDimensionNames(metricItems, [...selectedNamesSet]);
+ }, [metricItems, selectedNamesSet]);
+
+ const options = useMemo(() => {
+ const isAtMaxLimit = localSelectedDimensions.length >= MAX_DIMENSIONS_SELECTIONS;
+
+ const { orphanSelections, applicableDimensions } = partitionDimensionsForRender({
+ dimensions,
+ selectedDimensions: localSelectedDimensions,
+ optimisticApplicableNames,
+ });
+
+ const toOption = (dimension: Dimension): DimensionEntry => {
+ const isSelected = selectedNamesSet.has(dimension.name);
+ const isDisabled = getOptionDisabledState({ singleSelection, isSelected, isAtMaxLimit });
+ const showMaxTooltip = isAtMaxLimit && isDisabled;
+
+ return buildDimensionOption({
+ dimension,
+ isSelected,
+ isDisabled,
+ appendNode: showMaxTooltip ? : undefined,
+ });
+ };
+
+ // Orphan selections are prepended so they stay easy to find; the
+ // applicable set keeps its caller-provided ordering below.
+ return [...orphanSelections.map(toOption), ...applicableDimensions.map(toOption)];
+ }, [
+ dimensions,
+ localSelectedDimensions,
+ optimisticApplicableNames,
+ selectedNamesSet,
+ singleSelection,
+ ]);
+
+ const onChangeRef = useRef(onChange);
+ useEffect(() => {
+ onChangeRef.current = onChange;
+ }, [onChange]);
+
+ // Create debounced onChange only for multi-selection mode.
+ const debouncedOnChange = useMemo(() => {
+ if (singleSelection) {
+ return null;
+ }
+ return debounce((dim: Dimension[]) => {
+ onChangeRef.current(dim);
+ }, DEBOUNCE_TIME);
+ }, [singleSelection]);
+
+ useEffect(() => {
+ return () => {
+ if (debouncedOnChange) {
+ debouncedOnChange.cancel();
+ }
+ };
+ }, [debouncedOnChange]);
+
+ const handleChange = useCallback(
+ (chosenOption?: SelectableEntry | SelectableEntry[]) => {
+ const opts =
+ chosenOption == null ? [] : Array.isArray(chosenOption) ? chosenOption : [chosenOption];
+
+ // Each option carries its source `Dimension` (see `buildDimensionOption`),
+ // so we can read it straight off the option and skip the reverse lookup
+ // against `dimensions` + `localSelectedDimensions` that would otherwise
+ // be needed to recover selections no longer present in `dimensions`.
+ const newSelection = (opts as DimensionEntry[])
+ .map((opt) => opt.dimension)
+ .filter((d): d is Dimension => d !== undefined)
+ .slice(0, MAX_DIMENSIONS_SELECTIONS);
+
+ if (singleSelection || !debouncedOnChange) {
+ setLocalSelectedDimensions(newSelection);
+ onChange(newSelection);
+ return;
+ }
+
+ setLocalSelectedDimensions(newSelection);
+ debouncedOnChange.cancel();
+ debouncedOnChange(newSelection);
+ },
+ [onChange, singleSelection, debouncedOnChange]
+ );
+
+ const handleClearAll = useCallback(() => {
+ if (debouncedOnChange) {
+ debouncedOnChange.cancel();
+ }
+ setLocalSelectedDimensions([]);
+ onChange([]);
+ }, [onChange, debouncedOnChange]);
+
+ const buttonLabel = useMemo(
+ () => ,
+ [localSelectedDimensions.length, isLoading]
+ );
+
+ const buttonTooltipContent = useMemo(() => {
+ const isAtMaxDimensions = localSelectedDimensions.length >= MAX_DIMENSIONS_SELECTIONS;
+ return isAtMaxDimensions ? : undefined;
+ }, [localSelectedDimensions.length]);
+
+ const popoverContentBelowSearch = useMemo(
+ () => (
+
+ ),
+ [localSelectedDimensions.length, handleClearAll]
+ );
+
+ const selectedValues = useMemo(() => [...selectedNamesSet], [selectedNamesSet]);
+
+ return {
+ options,
+ buttonLabel,
+ buttonTooltipContent,
+ popoverContentBelowSearch,
+ handleChange,
+ selectedValues,
+ };
+};
diff --git a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/hooks/use_toolbar_actions.tsx b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/hooks/use_toolbar_actions.tsx
index 4f29d0b9ed3bc..d552086c75342 100644
--- a/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/hooks/use_toolbar_actions.tsx
+++ b/src/platform/packages/shared/kbn-unified-chart-section-viewer/src/components/toolbar/hooks/use_toolbar_actions.tsx
@@ -12,7 +12,7 @@ import { useEuiTheme, useIsWithinMaxBreakpoint } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { IconButtonGroupProps } from '@kbn/shared-ux-button-toolbar';
import { css } from '@emotion/react';
-import type { Dimension, UnifiedMetricsGridProps } from '../../../types';
+import type { Dimension, ParsedMetricItem, UnifiedMetricsGridProps } from '../../../types';
import { useMetricsExperienceState } from '../../observability/metrics/context/metrics_experience_state_provider';
import { DimensionsSelector } from '../dimensions_selector';
import { MAX_DIMENSIONS_SELECTIONS } from '../../../common/constants';
@@ -23,6 +23,8 @@ interface UseToolbarActionsProps extends Pick {
const { selectedDimensions, onDimensionsChange, isFullscreen, onToggleFullscreen } =
useMetricsExperienceState();
@@ -56,6 +59,7 @@ export const useToolbarActions = ({
singleSelection={MAX_DIMENSIONS_SELECTIONS <= 1}
fullWidth={isSmallScreen}
isLoading={isLoading}
+ metricItems={metricItems}
/>
),
],
@@ -66,6 +70,7 @@ export const useToolbarActions = ({
onDimensionsSelectionChange,
hideDimensionsSelector,
isLoading,
+ metricItems,
]
);