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, ] );