From 242981d806dd38f8d456b8fb29aae3d812e1caf2 Mon Sep 17 00:00:00 2001 From: Paulina Shakirova Date: Wed, 27 Nov 2024 23:49:56 +0100 Subject: [PATCH] [8.x] feat: [Dashboards] Hide clear control button in floating actions when no filters are selected (#200177) (#201927) # Backport This will backport the following commits from `main` to `8.x`: - [feat: [Dashboards] Hide clear control button in floating actions when no filters are selected (#200177)](https://github.com/elastic/kibana/pull/200177) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) --- .../actions/clear_control_action.test.tsx | 71 ++++++++++++++ .../public/actions/clear_control_action.tsx | 26 +++++- .../get_options_list_control_factory.tsx | 30 +++++- .../get_range_slider_control_factory.tsx | 1 + .../range_slider/range_control_selections.ts | 4 + .../get_timeslider_control_factory.tsx | 5 + src/plugins/controls/public/controls/types.ts | 2 +- src/plugins/controls/public/types.ts | 7 +- .../floating_actions/floating_actions.tsx | 92 +++++++++++++------ src/plugins/presentation_util/tsconfig.json | 1 + .../cypress/tasks/alerts.ts | 15 ++- 11 files changed, 217 insertions(+), 37 deletions(-) create mode 100644 src/plugins/controls/public/actions/clear_control_action.test.tsx diff --git a/src/plugins/controls/public/actions/clear_control_action.test.tsx b/src/plugins/controls/public/actions/clear_control_action.test.tsx new file mode 100644 index 0000000000000..1c0b0d0392bfb --- /dev/null +++ b/src/plugins/controls/public/actions/clear_control_action.test.tsx @@ -0,0 +1,71 @@ +/* + * 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 { BehaviorSubject } from 'rxjs'; +import { getMockedControlGroupApi } from '../controls/mocks/control_mocks'; +import { ClearControlAction } from './clear_control_action'; + +import type { ViewMode } from '@kbn/presentation-publishing'; + +const dashboardApi = { + viewMode: new BehaviorSubject('view'), +}; +const controlGroupApi = getMockedControlGroupApi(dashboardApi, { + removePanel: jest.fn(), + replacePanel: jest.fn(), + addNewPanel: jest.fn(), + children$: new BehaviorSubject({}), +}); + +const clearControlAction = new ClearControlAction(); +const hasSelections$ = new BehaviorSubject(undefined); + +const controlApi = { + type: 'test', + uuid: '1', + parentApi: controlGroupApi, + hasSelections$, + clearSelections: jest.fn(), +}; +beforeEach(() => { + hasSelections$.next(false); +}); + +describe('ClearControlAction', () => { + test('should throw an error when called with an embeddable not in a parent', async () => { + const noParentApi = { ...controlApi, parentApi: undefined }; + + await expect(async () => { + await clearControlAction.execute({ embeddable: noParentApi }); + }).rejects.toThrow(Error); + }); + + test('should call onChange when isCompatible changes', () => { + const onChange = jest.fn(); + + hasSelections$.next(true); + clearControlAction.subscribeToCompatibilityChanges({ embeddable: controlApi }, onChange); + + expect(onChange).toHaveBeenCalledWith(true, clearControlAction); + }); + + describe('Clear control button compatibility', () => { + test('should be incompatible if there is no selection', async () => { + const nothingIsSelected = { ...controlApi, hasSelections$: false }; + + expect(await clearControlAction.isCompatible({ embeddable: nothingIsSelected })).toBe(false); + }); + + test('should be compatible if there is a selection', async () => { + const hasSelections = { ...controlApi, hasSelections$: true }; + + expect(await clearControlAction.isCompatible({ embeddable: hasSelections })).toBe(true); + }); + }); +}); diff --git a/src/plugins/controls/public/actions/clear_control_action.tsx b/src/plugins/controls/public/actions/clear_control_action.tsx index 02347ace2fd8d..06f9b26c04ddc 100644 --- a/src/plugins/controls/public/actions/clear_control_action.tsx +++ b/src/plugins/controls/public/actions/clear_control_action.tsx @@ -12,11 +12,18 @@ import React, { SyntheticEvent } from 'react'; import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { EmbeddableApiContext, HasUniqueId } from '@kbn/presentation-publishing'; -import { IncompatibleActionError, type Action } from '@kbn/ui-actions-plugin/public'; +import { + IncompatibleActionError, + FrequentCompatibilityChangeAction, + type Action, +} from '@kbn/ui-actions-plugin/public'; +import { isClearableControl } from '../types'; import { ACTION_CLEAR_CONTROL } from '.'; -export class ClearControlAction implements Action { +export class ClearControlAction + implements Action, FrequentCompatibilityChangeAction +{ public readonly type = ACTION_CLEAR_CONTROL; public readonly id = ACTION_CLEAR_CONTROL; public order = 1; @@ -50,6 +57,21 @@ export class ClearControlAction implements Action { return 'eraser'; } + public couldBecomeCompatible({ embeddable }: EmbeddableApiContext) { + return isClearableControl(embeddable); + } + + public subscribeToCompatibilityChanges( + { embeddable }: EmbeddableApiContext, + onChange: (isCompatible: boolean, action: ClearControlAction) => void + ) { + if (!isClearableControl(embeddable)) return; + + return embeddable.hasSelections$.subscribe((selection) => { + onChange(Boolean(selection), this); + }); + } + public async isCompatible({ embeddable }: EmbeddableApiContext) { const { isCompatible } = await import('./clear_control_action_compatibility_check'); return isCompatible(embeddable); diff --git a/src/plugins/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx b/src/plugins/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx index de4811f0220d6..5c990fae85d19 100644 --- a/src/plugins/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx +++ b/src/plugins/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx @@ -9,10 +9,18 @@ import fastIsEqual from 'fast-deep-equal'; import React, { useEffect } from 'react'; -import { BehaviorSubject, combineLatest, debounceTime, filter, map, skip } from 'rxjs'; +import { + BehaviorSubject, + combineLatest, + debounceTime, + distinctUntilChanged, + filter, + map, + skip, +} from 'rxjs'; import { buildExistsFilter, buildPhraseFilter, buildPhrasesFilter, Filter } from '@kbn/es-query'; -import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; +import { PublishingSubject, useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; import { OPTIONS_LIST_CONTROL } from '../../../../common'; import type { @@ -205,6 +213,22 @@ export const getOptionsListControlFactory = (): DataControlFactory< if (currentSelections.length > 1) selections.setSelectedOptions([currentSelections[0]]); }); + const hasSelections$ = new BehaviorSubject( + Boolean(initialState.selectedOptions?.length || initialState.existsSelected) + ); + const hasSelectionsSubscription = combineLatest([ + selections.selectedOptions$, + selections.existsSelected$, + ]) + .pipe( + map(([selectedOptions, existsSelected]) => { + return Boolean(selectedOptions?.length || existsSelected); + }), + distinctUntilChanged() + ) + .subscribe((hasSelections) => { + hasSelections$.next(hasSelections); + }); /** Output filters when selections change */ const outputFilterSubscription = combineLatest([ dataControl.api.dataViews, @@ -269,6 +293,7 @@ export const getOptionsListControlFactory = (): DataControlFactory< if (selections.existsSelected$.getValue()) selections.setExistsSelected(false); if (invalidSelections$.getValue().size) invalidSelections$.next(new Set([])); }, + hasSelections$: hasSelections$ as PublishingSubject, }, { ...dataControl.comparators, @@ -382,6 +407,7 @@ export const getOptionsListControlFactory = (): DataControlFactory< outputFilterSubscription.unsubscribe(); singleSelectSubscription.unsubscribe(); validSearchStringSubscription.unsubscribe(); + hasSelectionsSubscription.unsubscribe(); }; }, []); diff --git a/src/plugins/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.tsx b/src/plugins/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.tsx index a5fe4acfe004d..c691ef8181fbe 100644 --- a/src/plugins/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.tsx +++ b/src/plugins/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.tsx @@ -101,6 +101,7 @@ export const getRangesliderControlFactory = (): DataControlFactory< clearSelections: () => { selections.setValue(undefined); }, + hasSelections$: selections.hasRangeSelection$, }, { ...dataControl.comparators, diff --git a/src/plugins/controls/public/controls/data_controls/range_slider/range_control_selections.ts b/src/plugins/controls/public/controls/data_controls/range_slider/range_control_selections.ts index 94d7721e71440..29f01b9c86efc 100644 --- a/src/plugins/controls/public/controls/data_controls/range_slider/range_control_selections.ts +++ b/src/plugins/controls/public/controls/data_controls/range_slider/range_control_selections.ts @@ -16,9 +16,12 @@ export function initializeRangeControlSelections( onSelectionChange: () => void ) { const value$ = new BehaviorSubject(initialState.value); + const hasRangeSelection$ = new BehaviorSubject(Boolean(value$.getValue())); + function setValue(next: RangeValue | undefined) { if (value$.value !== next) { value$.next(next); + hasRangeSelection$.next(Boolean(next)); onSelectionChange(); } } @@ -29,6 +32,7 @@ export function initializeRangeControlSelections( } as StateComparators>, hasInitialSelections: initialState.value !== undefined, value$: value$ as PublishingSubject, + hasRangeSelection$: hasRangeSelection$ as PublishingSubject, setValue, }; } diff --git a/src/plugins/controls/public/controls/timeslider_control/get_timeslider_control_factory.tsx b/src/plugins/controls/public/controls/timeslider_control/get_timeslider_control_factory.tsx index a0c6c237ace00..59ad0a2a5076c 100644 --- a/src/plugins/controls/public/controls/timeslider_control/get_timeslider_control_factory.tsx +++ b/src/plugins/controls/public/controls/timeslider_control/get_timeslider_control_factory.tsx @@ -13,6 +13,7 @@ import { BehaviorSubject, debounceTime, first, map } from 'rxjs'; import { EuiInputPopover } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { + PublishingSubject, ViewMode, apiHasParentApi, apiPublishesDataLoading, @@ -57,6 +58,7 @@ export const getTimesliderControlFactory = (): ControlFactory< const timeslice$ = new BehaviorSubject<[number, number] | undefined>(undefined); const isAnchored$ = new BehaviorSubject(initialState.isAnchored); const isPopoverOpen$ = new BehaviorSubject(false); + const hasTimeSliceSelection$ = new BehaviorSubject(Boolean(timeslice$)); const timeRangePercentage = initTimeRangePercentage( initialState, @@ -102,6 +104,7 @@ export const getTimesliderControlFactory = (): ControlFactory< } function onChange(timeslice?: Timeslice) { + hasTimeSliceSelection$.next(Boolean(timeslice)); setTimeslice(timeslice); const nextSelectedRange = timeslice ? timeslice[TO_INDEX] - timeslice[FROM_INDEX] @@ -224,7 +227,9 @@ export const getTimesliderControlFactory = (): ControlFactory< }, clearSelections: () => { setTimeslice(undefined); + hasTimeSliceSelection$.next(false); }, + hasSelections$: hasTimeSliceSelection$ as PublishingSubject, CustomPrependComponent: () => { const [autoApplySelections, viewMode] = useBatchedPublishingSubjects( controlGroupApi.autoApplySelections$, diff --git a/src/plugins/controls/public/controls/types.ts b/src/plugins/controls/public/controls/types.ts index e79c20c99f150..dd5d38e96346b 100644 --- a/src/plugins/controls/public/controls/types.ts +++ b/src/plugins/controls/public/controls/types.ts @@ -59,7 +59,7 @@ export type ControlApiRegistration = Omit< ControlApiRegistration, - 'serializeState' | 'getTypeDisplayName' | 'clearSelections' + 'serializeState' | 'getTypeDisplayName' | keyof CanClearSelections >; export interface ControlFactory< diff --git a/src/plugins/controls/public/types.ts b/src/plugins/controls/public/types.ts index bed3260bb4401..f355aa6409c5f 100644 --- a/src/plugins/controls/public/types.ts +++ b/src/plugins/controls/public/types.ts @@ -11,13 +11,18 @@ import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { EmbeddableSetup } from '@kbn/embeddable-plugin/public'; import { UiActionsStart } from '@kbn/ui-actions-plugin/public'; +import { PublishingSubject } from '@kbn/presentation-publishing'; export interface CanClearSelections { clearSelections: () => void; + hasSelections$: PublishingSubject; } export const isClearableControl = (control: unknown): control is CanClearSelections => { - return typeof (control as CanClearSelections).clearSelections === 'function'; + return ( + typeof (control as CanClearSelections).clearSelections === 'function' && + Boolean((control as CanClearSelections).hasSelections$) + ); }; /** diff --git a/src/plugins/presentation_util/public/components/floating_actions/floating_actions.tsx b/src/plugins/presentation_util/public/components/floating_actions/floating_actions.tsx index 3c03e65714908..ade106ae11098 100644 --- a/src/plugins/presentation_util/public/components/floating_actions/floating_actions.tsx +++ b/src/plugins/presentation_util/public/components/floating_actions/floating_actions.tsx @@ -10,6 +10,7 @@ import classNames from 'classnames'; import React, { FC, ReactElement, useEffect, useState } from 'react'; import { v4 } from 'uuid'; +import { Subscription } from 'rxjs'; import { PANEL_HOVER_TRIGGER, @@ -19,7 +20,7 @@ import { } from '@kbn/embeddable-plugin/public'; import { apiHasUniqueId } from '@kbn/presentation-publishing'; import { Action } from '@kbn/ui-actions-plugin/public'; - +import { AnyApiAction } from '@kbn/presentation-panel-plugin/public/panel_actions/types'; import { uiActionsService } from '../../services/kibana_services'; import './floating_actions.scss'; @@ -33,6 +34,10 @@ export interface FloatingActionsProps { disabledActions?: EmbeddableInput['disabledActions']; } +export type FloatingActionItem = AnyApiAction & { + MenuItem: React.FC<{ context: unknown }>; +}; + export const FloatingActions: FC = ({ children, viewMode, @@ -41,59 +46,88 @@ export const FloatingActions: FC = ({ className = '', disabledActions, }) => { - const [floatingActions, setFloatingActions] = useState(undefined); + const [floatingActions, setFloatingActions] = useState([]); useEffect(() => { if (!api) return; - const getActions = async () => { - let mounted = true; - const context = { - embeddable: api, - trigger: panelHoverTrigger, - }; + let mounted = true; + const context = { + embeddable: api, + trigger: panelHoverTrigger, + }; + + const sortByOrder = (a: Action | FloatingActionItem, b: Action | FloatingActionItem) => { + return (a.order || 0) - (b.order || 0); + }; + + const getActions: () => Promise = async () => { const actions = ( await uiActionsService.getTriggerCompatibleActions(PANEL_HOVER_TRIGGER, context) ) - .filter((action): action is Action & { MenuItem: React.FC<{ context: unknown }> } => { + .filter((action) => { return action.MenuItem !== undefined && (disabledActions ?? []).indexOf(action.id) === -1; }) - .sort((a, b) => (a.order || 0) - (b.order || 0)); + .sort(sortByOrder); + return actions as FloatingActionItem[]; + }; + + const subscriptions = new Subscription(); + const handleActionCompatibilityChange = (isCompatible: boolean, action: Action) => { if (!mounted) return; - if (actions.length > 0) { - setFloatingActions( - <> - {actions.map((action) => - React.createElement(action.MenuItem, { - key: action.id, - context, - }) - )} - + setFloatingActions((currentActions) => { + const newActions: FloatingActionItem[] = currentActions + ?.filter((current) => current.id !== action.id) + .sort(sortByOrder) as FloatingActionItem[]; + if (isCompatible) { + return [action as FloatingActionItem, ...newActions]; + } + return newActions; + }); + }; + + (async () => { + const actions = await getActions(); + if (!mounted) return; + setFloatingActions(actions); + + const frequentlyChangingActions = uiActionsService.getFrequentlyChangingActionsForTrigger( + PANEL_HOVER_TRIGGER, + context + ); + + for (const action of frequentlyChangingActions) { + subscriptions.add( + action.subscribeToCompatibilityChanges(context, handleActionCompatibilityChange) ); - } else { - setFloatingActions(undefined); } - return () => { - mounted = false; - }; - }; + })(); - getActions(); + return () => { + mounted = false; + subscriptions.unsubscribe(); + }; }, [api, viewMode, disabledActions]); return (
{children} - {isEnabled && floatingActions && ( + {isEnabled && floatingActions.length > 0 && (
- {floatingActions} + <> + {floatingActions.map((action) => + React.createElement(action.MenuItem, { + key: action.id, + context: { embeddable: api }, + }) + )} +
)}
diff --git a/src/plugins/presentation_util/tsconfig.json b/src/plugins/presentation_util/tsconfig.json index 4e18f2e8bce2b..a794829d9ba52 100644 --- a/src/plugins/presentation_util/tsconfig.json +++ b/src/plugins/presentation_util/tsconfig.json @@ -37,6 +37,7 @@ "@kbn/field-utils", "@kbn/presentation-publishing", "@kbn/core-ui-settings-browser", + "@kbn/presentation-panel-plugin", ], "exclude": ["target/**/*"] } diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts index b70adbefa1850..8602064af92a6 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts @@ -81,6 +81,7 @@ import { FIELDS_BROWSER_BTN } from '../screens/rule_details'; import { openFilterGroupContextMenu } from './common/filter_group'; import { visitWithTimeRange } from './navigation'; import { GET_DATA_GRID_HEADER_ACTION_BUTTON } from '../screens/common/data_grid'; +import { getDataTestSubjectSelector } from '../helpers/common'; export const addExceptionFromFirstAlert = () => { expandFirstAlertActions(); @@ -195,9 +196,19 @@ export const closePageFilterPopover = (filterIndex: number) => { cy.get(OPTION_LIST_VALUES(filterIndex)).should('not.have.class', 'euiFilterButton-isSelected'); }; +export const hasSelection = (filterIndex: number) => { + return cy.get(OPTION_LIST_VALUES(filterIndex)).then(($el) => { + return $el.find(getDataTestSubjectSelector('optionsListSelections')).length > 0; + }); +}; + export const clearAllSelections = (filterIndex: number) => { - cy.get(OPTION_LIST_VALUES(filterIndex)).realHover(); - cy.get(OPTION_LIST_CLEAR_BTN).eq(filterIndex).click(); + hasSelection(filterIndex).then(($el) => { + if ($el) { + cy.get(OPTION_LIST_VALUES(filterIndex)).realHover(); + cy.get(OPTION_LIST_CLEAR_BTN).eq(filterIndex).click(); + } + }); }; export const selectPageFilterValue = (filterIndex: number, ...values: string[]) => {