From c448593d546f6200b0d2d35bce043bef521f41a6 Mon Sep 17 00:00:00 2001 From: Karen Grigoryan Date: Wed, 16 Oct 2024 01:18:50 +0200 Subject: [PATCH] [Security Solution][DQD] Add historical results tour guide (#196127) addresses #195971 This PR adds missing new historical results feature tour guide. ## Tour guide features: - ability to maintain visual presence while collapsing accordions in list-view - move from list-view to flyout view and back - seamlessly integrates with existing opening flyout and history tab functionality ## PR decisions with explanation: - data-tour-element has been introduced on select elements (like first actions of each first row) to avoid polluting every single element with data-test-subj. This way it's imho specific and semantically more clear what the elements are for. - early on I tried to control the anchoring with refs but some eui elements don't allow passing refs like EuiTab, so instead a more simpler and straightforward approach with dom selectors has been chosen - localStorage key name has been picked in accordance with other instances of usage `securitySolution.dataQualityDashboard.historicalResultsTour.v8.16.isActive` the name includes the full domain + the version when it's introduced. And since this tour step is a single step there is no need to stringify an object with `isTourActive` in and it's much simpler to just bake the activity state into the name and make the value just a boolean. ## UI Demo ### Anchor reposition demo (listview + flyout) https://github.com/user-attachments/assets/0f961c51-0e36-48ca-aab4-bef3b0d1269e ### List view tour guide try it + reload demo https://github.com/user-attachments/assets/ca1f5fda-ee02-4a48-827c-91df757a8ddf ### FlyOut Try It + reload demo https://github.com/user-attachments/assets/d0801ac3-1ed1-4e64-9d6b-3140b8402bdf ### Manual history tab selection path + reload demo https://github.com/user-attachments/assets/34dbb447-2fd6-4dc0-a4f5-682c9c65cc8b ### Manual open history view path + reload demo https://github.com/user-attachments/assets/945dd042-fc12-476e-8d23-f48c9ded9f65 ### Dismiss list view tour guide + reload demo https://github.com/user-attachments/assets/d20d1416-827f-46f2-9161-a3c0a8cbd932 ### Dismiss FlyOut tour guide + reload demo https://github.com/user-attachments/assets/8f085f59-20a9-49f0-b5b3-959c4719f5cb ### Serverless empty pattern handling + reposition demo https://github.com/user-attachments/assets/4af5939e-663c-4439-a3fc-deff2d4de7e4 --- .../indices_details/constants.ts | 9 + .../index.tsx | 28 + .../indices_details/index.test.tsx | 79 ++- .../indices_details/index.tsx | 48 +- .../indices_details/pattern/constants.ts | 2 + .../historical_results_tour/index.test.tsx | 105 ++++ .../pattern/historical_results_tour/index.tsx | 80 +++ .../historical_results_tour/translations.ts | 30 + .../indices_details/pattern/index.test.tsx | 549 +++++++++++++++++- .../indices_details/pattern/index.tsx | 85 ++- .../pattern/index_check_flyout/index.test.tsx | 185 +++++- .../pattern/index_check_flyout/index.tsx | 48 +- .../pattern/summary_table/index.tsx | 3 + .../summary_table/utils/columns.test.tsx | 54 ++ .../pattern/summary_table/utils/columns.tsx | 7 + .../mock_auditbeat_pattern_rollup.ts | 18 + 16 files changed, 1304 insertions(+), 26 deletions(-) create mode 100644 x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/constants.ts create mode 100644 x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/hooks/use_is_historical_results_tour_active/index.tsx create mode 100644 x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.test.tsx create mode 100644 x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.tsx create mode 100644 x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/translations.ts diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/constants.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/constants.ts new file mode 100644 index 0000000000000..68c373217a4b4 --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/constants.ts @@ -0,0 +1,9 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const HISTORICAL_RESULTS_TOUR_IS_DISMISSED_STORAGE_KEY = + 'securitySolution.dataQualityDashboard.historicalResultsTour.v8.16.isDismissed'; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/hooks/use_is_historical_results_tour_active/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/hooks/use_is_historical_results_tour_active/index.tsx new file mode 100644 index 0000000000000..572bf7023dada --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/hooks/use_is_historical_results_tour_active/index.tsx @@ -0,0 +1,28 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; + +import { HISTORICAL_RESULTS_TOUR_IS_DISMISSED_STORAGE_KEY } from '../../constants'; + +export const useIsHistoricalResultsTourActive = () => { + const [isTourDismissed, setIsTourDismissed] = useLocalStorage( + HISTORICAL_RESULTS_TOUR_IS_DISMISSED_STORAGE_KEY, + false + ); + + const isTourActive = !isTourDismissed; + const setIsTourActive = useCallback( + (active: boolean) => { + setIsTourDismissed(!active); + }, + [setIsTourDismissed] + ); + + return [isTourActive, setIsTourActive] as const; +}; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/index.test.tsx index d5aaa1eea19ae..b3d296c5a30db 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/index.test.tsx @@ -6,12 +6,15 @@ */ import numeral from '@elastic/numeral'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import React from 'react'; import { EMPTY_STAT } from '../../constants'; import { alertIndexWithAllResults } from '../../mock/pattern_rollup/mock_alerts_pattern_rollup'; -import { auditbeatWithAllResults } from '../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; +import { + auditbeatWithAllResults, + emptyAuditbeatPatternRollup, +} from '../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; import { packetbeatNoResults } from '../../mock/pattern_rollup/mock_packetbeat_pattern_rollup'; import { TestDataQualityProviders, @@ -19,6 +22,8 @@ import { } from '../../mock/test_providers/test_providers'; import { PatternRollup } from '../../types'; import { Props, IndicesDetails } from '.'; +import userEvent from '@testing-library/user-event'; +import { HISTORICAL_RESULTS_TOUR_IS_DISMISSED_STORAGE_KEY } from './constants'; const defaultBytesFormat = '0,0.[0]b'; const formatBytes = (value: number | undefined) => @@ -29,15 +34,22 @@ const formatNumber = (value: number | undefined) => value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; const ilmPhases = ['hot', 'warm', 'unmanaged']; -const patterns = ['.alerts-security.alerts-default', 'auditbeat-*', 'packetbeat-*']; +const patterns = [ + 'test-empty-pattern-*', + '.alerts-security.alerts-default', + 'auditbeat-*', + 'packetbeat-*', +]; const patternRollups: Record = { + 'test-empty-pattern-*': { ...emptyAuditbeatPatternRollup, pattern: 'test-empty-pattern-*' }, '.alerts-security.alerts-default': alertIndexWithAllResults, 'auditbeat-*': auditbeatWithAllResults, 'packetbeat-*': packetbeatNoResults, }; const patternIndexNames: Record = { + 'test-empty-pattern-*': [], 'auditbeat-*': [ '.ds-auditbeat-8.6.1-2023.02.07-000001', 'auditbeat-custom-empty-index-1', @@ -58,6 +70,7 @@ const defaultProps: Props = { describe('IndicesDetails', () => { beforeEach(async () => { jest.clearAllMocks(); + localStorage.removeItem(HISTORICAL_RESULTS_TOUR_IS_DISMISSED_STORAGE_KEY); render( @@ -74,10 +87,64 @@ describe('IndicesDetails', () => { }); describe('rendering patterns', () => { - patterns.forEach((pattern) => { - test(`it renders the ${pattern} pattern`, () => { - expect(screen.getByTestId(`${pattern}PatternPanel`)).toBeInTheDocument(); + test.each(patterns)('it renders the %s pattern', (pattern) => { + expect(screen.getByTestId(`${pattern}PatternPanel`)).toBeInTheDocument(); + }); + }); + + describe('tour', () => { + test('it renders the tour wrapping view history button of first row of first non-empty pattern', async () => { + const wrapper = await screen.findByTestId('historicalResultsTour'); + const button = within(wrapper).getByRole('button', { name: 'View history' }); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('data-tour-element', patterns[1]); + + expect( + screen.getByRole('dialog', { name: 'Introducing data quality history' }) + ).toBeInTheDocument(); + }); + + describe('when the tour is dismissed', () => { + test('it hides the tour and persists in localStorage', async () => { + const wrapper = await screen.findByRole('dialog', { + name: 'Introducing data quality history', + }); + + const button = within(wrapper).getByRole('button', { name: 'Close' }); + + await userEvent.click(button); + + await waitFor(() => expect(screen.queryByTestId('historicalResultsTour')).toBeNull()); + + expect(localStorage.getItem(HISTORICAL_RESULTS_TOUR_IS_DISMISSED_STORAGE_KEY)).toEqual( + 'true' + ); }); }); + + describe('when the first pattern is toggled', () => { + test('it renders the tour wrapping view history button of first row of second non-empty pattern', async () => { + const firstNonEmptyPatternAccordionWrapper = await screen.findByTestId( + `${patterns[1]}PatternPanel` + ); + const accordionToggle = within(firstNonEmptyPatternAccordionWrapper).getByRole('button', { + name: /Pass/, + }); + await userEvent.click(accordionToggle); + + const secondPatternAccordionWrapper = screen.getByTestId(`${patterns[2]}PatternPanel`); + const historicalResultsWrapper = await within(secondPatternAccordionWrapper).findByTestId( + 'historicalResultsTour' + ); + const button = within(historicalResultsWrapper).getByRole('button', { + name: 'View history', + }); + expect(button).toHaveAttribute('data-tour-element', patterns[2]); + + expect( + screen.getByRole('dialog', { name: 'Introducing data quality history' }) + ).toBeInTheDocument(); + }, 10000); + }); }); }); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/index.tsx index fd565d8fc7637..b3b708291a983 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/index.tsx @@ -6,13 +6,14 @@ */ import { EuiFlexItem } from '@elastic/eui'; -import React from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import styled from 'styled-components'; import { useResultsRollupContext } from '../../contexts/results_rollup_context'; import { Pattern } from './pattern'; import { SelectedIndex } from '../../types'; import { useDataQualityContext } from '../../data_quality_context'; +import { useIsHistoricalResultsTourActive } from './hooks/use_is_historical_results_tour_active'; const StyledPatternWrapperFlexItem = styled(EuiFlexItem)` margin-bottom: ${({ theme }) => theme.eui.euiSize}; @@ -34,6 +35,41 @@ const IndicesDetailsComponent: React.FC = ({ const { patternRollups, patternIndexNames } = useResultsRollupContext(); const { patterns } = useDataQualityContext(); + const [isTourActive, setIsTourActive] = useIsHistoricalResultsTourActive(); + + const handleDismissTour = useCallback(() => { + setIsTourActive(false); + }, [setIsTourActive]); + + const [openPatterns, setOpenPatterns] = useState< + Array<{ name: string; isOpen: boolean; isEmpty: boolean }> + >(() => { + return patterns.map((pattern) => ({ name: pattern, isOpen: true, isEmpty: false })); + }); + + const handleAccordionToggle = useCallback( + (patternName: string, isOpen: boolean, isEmpty: boolean) => { + setOpenPatterns((prevOpenPatterns) => { + return prevOpenPatterns.map((p) => + p.name === patternName ? { ...p, isOpen, isEmpty } : p + ); + }); + }, + [] + ); + + const firstOpenNonEmptyPattern = openPatterns.find((pattern) => { + return pattern.isOpen && !pattern.isEmpty; + })?.name; + + const [openPatternsUpdatedAt, setOpenPatternsUpdatedAt] = useState(Date.now()); + + useEffect(() => { + if (firstOpenNonEmptyPattern) { + setOpenPatternsUpdatedAt(Date.now()); + } + }, [openPatterns, firstOpenNonEmptyPattern]); + return (
{patterns.map((pattern) => ( @@ -44,6 +80,16 @@ const IndicesDetailsComponent: React.FC = ({ patternRollup={patternRollups[pattern]} chartSelectedIndex={chartSelectedIndex} setChartSelectedIndex={setChartSelectedIndex} + isTourActive={isTourActive} + isFirstOpenNonEmptyPattern={pattern === firstOpenNonEmptyPattern} + onAccordionToggle={handleAccordionToggle} + onDismissTour={handleDismissTour} + // TODO: remove this hack when EUI popover is fixed + // https://github.com/elastic/eui/issues/5226 + // + // this information is used to force the tour guide popover to reposition + // when surrounding accordions get toggled and affect the layout + {...(pattern === firstOpenNonEmptyPattern && { openPatternsUpdatedAt })} /> ))} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/constants.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/constants.ts index 4bab5938cf98b..a02eccb3e81a4 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/constants.ts +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/constants.ts @@ -9,3 +9,5 @@ export const MIN_PAGE_SIZE = 10; export const HISTORY_TAB_ID = 'history'; export const LATEST_CHECK_TAB_ID = 'latest_check'; + +export const HISTORICAL_RESULTS_TOUR_SELECTOR_KEY = 'data-tour-element'; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.test.tsx new file mode 100644 index 0000000000000..53f2e059072c8 --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.test.tsx @@ -0,0 +1,105 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { HISTORICAL_RESULTS_TOUR_SELECTOR_KEY } from '../constants'; +import { HistoricalResultsTour } from '.'; +import { INTRODUCING_DATA_QUALITY_HISTORY, VIEW_PAST_RESULTS } from './translations'; + +const anchorSelectorValue = 'test-anchor'; + +describe('HistoricalResultsTour', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('given no anchor element', () => { + it('does not render the tour step', () => { + render( + + ); + + expect(screen.queryByText(INTRODUCING_DATA_QUALITY_HISTORY)).not.toBeInTheDocument(); + }); + }); + + describe('given an anchor element', () => { + beforeEach(() => { + // eslint-disable-next-line no-unsanitized/property + document.body.innerHTML = `
`; + }); + + describe('when isOpen is true', () => { + const onTryIt = jest.fn(); + const onDismissTour = jest.fn(); + beforeEach(() => { + render( + + ); + }); + it('renders the tour step', async () => { + expect( + await screen.findByRole('dialog', { name: INTRODUCING_DATA_QUALITY_HISTORY }) + ).toBeInTheDocument(); + expect(screen.getByText(INTRODUCING_DATA_QUALITY_HISTORY)).toBeInTheDocument(); + expect(screen.getByText(VIEW_PAST_RESULTS)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Close/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Try It/i })).toBeInTheDocument(); + + const historicalResultsTour = screen.getByTestId('historicalResultsTour'); + expect(historicalResultsTour.querySelector('[data-tour-element]')).toHaveAttribute( + 'data-tour-element', + anchorSelectorValue + ); + }); + + describe('when the close button is clicked', () => { + it('calls dismissTour', async () => { + await userEvent.click(await screen.findByRole('button', { name: /Close/i })); + expect(onDismissTour).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the try it button is clicked', () => { + it('calls onTryIt', async () => { + await userEvent.click(await screen.findByRole('button', { name: /Try It/i })); + expect(onTryIt).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when isOpen is false', () => { + it('does not render the tour step', async () => { + render( + + ); + + await waitFor(() => + expect(screen.queryByText(INTRODUCING_DATA_QUALITY_HISTORY)).not.toBeInTheDocument() + ); + }); + }); + }); +}); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.tsx new file mode 100644 index 0000000000000..5e63379d17375 --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/index.tsx @@ -0,0 +1,80 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useEffect, useState } from 'react'; +import { EuiButton, EuiButtonEmpty, EuiText, EuiTourStep } from '@elastic/eui'; +import styled from 'styled-components'; + +import { HISTORICAL_RESULTS_TOUR_SELECTOR_KEY } from '../constants'; +import { CLOSE, INTRODUCING_DATA_QUALITY_HISTORY, TRY_IT, VIEW_PAST_RESULTS } from './translations'; + +export interface Props { + anchorSelectorValue: string; + isOpen: boolean; + onTryIt: () => void; + onDismissTour: () => void; + zIndex?: number; +} + +const StyledText = styled(EuiText)` + margin-block-start: -10px; +`; + +export const HistoricalResultsTour: FC = ({ + anchorSelectorValue, + onTryIt, + isOpen, + onDismissTour, + zIndex, +}) => { + const [anchorElement, setAnchorElement] = useState(); + + useEffect(() => { + const element = document.querySelector( + `[${HISTORICAL_RESULTS_TOUR_SELECTOR_KEY}="${anchorSelectorValue}"]` + ); + + if (!element) { + return; + } + + setAnchorElement(element); + }, [anchorSelectorValue]); + + if (!isOpen || !anchorElement) { + return null; + } + + return ( + +

{VIEW_PAST_RESULTS}

+ + } + data-test-subj="historicalResultsTour" + isStepOpen={isOpen} + minWidth={283} + onFinish={onDismissTour} + step={1} + stepsTotal={1} + title={INTRODUCING_DATA_QUALITY_HISTORY} + anchorPosition="rightUp" + repositionOnScroll + anchor={anchorElement} + zIndex={zIndex} + footerAction={[ + + {CLOSE} + , + + {TRY_IT} + , + ]} + /> + ); +}; diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/translations.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/translations.ts new file mode 100644 index 0000000000000..d8f81aa288baa --- /dev/null +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/historical_results_tour/translations.ts @@ -0,0 +1,30 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const CLOSE = i18n.translate('securitySolutionPackages.ecsDataQualityDashboard.close', { + defaultMessage: 'Close', +}); + +export const TRY_IT = i18n.translate('securitySolutionPackages.ecsDataQualityDashboard.tryIt', { + defaultMessage: 'Try it', +}); + +export const INTRODUCING_DATA_QUALITY_HISTORY = i18n.translate( + 'securitySolutionPackages.ecsDataQualityDashboard.introducingDataQualityHistory', + { + defaultMessage: 'Introducing data quality history', + } +); + +export const VIEW_PAST_RESULTS = i18n.translate( + 'securitySolutionPackages.ecsDataQualityDashboard.viewPastResults', + { + defaultMessage: 'View past results', + } +); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.test.tsx index a165378df80ed..eb6116c3276f9 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.test.tsx @@ -6,19 +6,23 @@ */ import React from 'react'; -import { act, render, screen, within } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import { TestDataQualityProviders, TestExternalProviders, } from '../../../mock/test_providers/test_providers'; import { Pattern } from '.'; -import { auditbeatWithAllResults } from '../../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; +import { + auditbeatWithAllResults, + emptyAuditbeatPatternRollup, +} from '../../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; import { useIlmExplain } from './hooks/use_ilm_explain'; import { useStats } from './hooks/use_stats'; import { ERROR_LOADING_METADATA_TITLE, LOADING_STATS } from './translations'; import { useHistoricalResults } from './hooks/use_historical_results'; import { getHistoricalResultStub } from '../../../stub/get_historical_result_stub'; +import userEvent from '@testing-library/user-event'; const pattern = 'auditbeat-*'; @@ -81,6 +85,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> @@ -95,6 +103,157 @@ describe('pattern', () => { expect(screen.getByTestId('summaryTable')).toBeInTheDocument(); }); + describe('onAccordionToggle', () => { + describe('by default', () => { + describe('when no summary table items are available', () => { + it('invokes the onAccordionToggle function with the pattern name, isOpen as true and isEmpty as true', async () => { + const onAccordionToggle = jest.fn(); + + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: null, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: null, + error: null, + loading: false, + }); + + render( + + + + + + ); + + const accordionToggle = await screen.findByRole('button', { + name: 'auditbeat-* Incompatible fields 0 Indices checked 0 Indices 0 Size 0B Docs 0', + }); + + expect(onAccordionToggle).toHaveBeenCalledTimes(1); + + await userEvent.click(accordionToggle); + + expect(onAccordionToggle).toHaveBeenCalledTimes(2); + expect(onAccordionToggle).toHaveBeenCalledWith(pattern, true, true); + }); + }); + + describe('when summary table items are available', () => { + it('invokes the onAccordionToggle function with the pattern name, isOpen as true and isEmpty as false', async () => { + const onAccordionToggle = jest.fn(); + + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + render( + + + + + + ); + + const accordionToggle = screen.getByRole('button', { + name: 'Fail auditbeat-* hot (1) unmanaged (2) Incompatible fields 4 Indices checked 3 Indices 3 Size 17.9MB Docs 19,127', + }); + + expect(onAccordionToggle).toHaveBeenCalledTimes(1); + + await userEvent.click(accordionToggle); + + expect(onAccordionToggle).toHaveBeenCalledTimes(2); + expect(onAccordionToggle).toHaveBeenCalledWith(pattern, true, false); + }); + }); + }); + + describe('when the accordion is toggled', () => { + it('calls the onAccordionToggle function with current open state and current empty state', async () => { + const onAccordionToggle = jest.fn(); + + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + render( + + + + + + ); + + const accordionToggle = screen.getByRole('button', { + name: 'Fail auditbeat-* hot (1) unmanaged (2) Incompatible fields 4 Indices checked 3 Indices 3 Size 17.9MB Docs 19,127', + }); + + expect(onAccordionToggle).toHaveBeenCalledTimes(1); + expect(onAccordionToggle).toHaveBeenCalledWith(pattern, true, false); + + await userEvent.click(accordionToggle); + + expect(onAccordionToggle).toHaveBeenCalledTimes(2); + expect(onAccordionToggle).toHaveBeenLastCalledWith(pattern, false, false); + + await userEvent.click(accordionToggle); + + expect(onAccordionToggle).toHaveBeenCalledTimes(3); + expect(onAccordionToggle).toHaveBeenCalledWith(pattern, true, false); + }); + }); + }); + describe('remote clusters callout', () => { describe('when the pattern includes a colon', () => { it('it renders the remote clusters callout', () => { @@ -107,6 +266,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={undefined} pattern={'remote:*'} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> @@ -127,6 +290,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={undefined} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> @@ -155,6 +322,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> @@ -182,6 +353,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> @@ -215,6 +390,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> @@ -248,6 +427,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> @@ -292,6 +475,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> @@ -306,7 +493,7 @@ describe('pattern', () => { name: 'Check now', }); - await act(async () => checkNowButton.click()); + await userEvent.click(checkNowButton); // assert expect(checkIndex).toHaveBeenCalledTimes(1); @@ -370,6 +557,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> @@ -384,7 +575,7 @@ describe('pattern', () => { name: 'View history', }); - await act(async () => viewHistoryButton.click()); + await userEvent.click(viewHistoryButton); // assert expect(fetchHistoricalResults).toHaveBeenCalledTimes(1); @@ -444,6 +635,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> @@ -458,11 +653,11 @@ describe('pattern', () => { name: 'View history', }); - await act(async () => viewHistoryButton.click()); + await userEvent.click(viewHistoryButton); const closeButton = screen.getByRole('button', { name: 'Close this dialog' }); - await act(async () => closeButton.click()); + await userEvent.click(closeButton); // assert expect(screen.queryByTestId('indexCheckFlyout')).not.toBeInTheDocument(); @@ -504,6 +699,10 @@ describe('pattern', () => { setChartSelectedIndex={jest.fn()} indexNames={Object.keys(auditbeatWithAllResults.stats!)} pattern={pattern} + isTourActive={false} + onDismissTour={jest.fn()} + isFirstOpenNonEmptyPattern={false} + onAccordionToggle={jest.fn()} /> @@ -533,4 +732,342 @@ describe('pattern', () => { }); }); }); + + describe('Tour', () => { + describe('when isTourActive and isFirstOpenNonEmptyPattern', () => { + it('renders the tour near the first row history view button', async () => { + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + render( + + + + + + ); + + const rows = screen.getAllByRole('row'); + // skipping the first row which is the header + const firstBodyRow = within(rows[1]); + + const tourWrapper = await firstBodyRow.findByTestId('historicalResultsTour'); + + expect( + within(tourWrapper).getByRole('button', { name: 'View history' }) + ).toBeInTheDocument(); + + expect( + screen.getByRole('dialog', { name: 'Introducing data quality history' }) + ).toBeInTheDocument(); + }); + + describe('when accordion is collapsed', () => { + it('hides the tour', async () => { + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + render( + + + + + + ); + + expect(await screen.findByTestId('historicalResultsTour')).toBeInTheDocument(); + + const accordionToggle = screen.getByRole('button', { + name: 'Fail auditbeat-* hot (1) unmanaged (2) Incompatible fields 4 Indices checked 3 Indices 3 Size 17.9MB Docs 19,127', + }); + + await userEvent.click(accordionToggle); + + expect(screen.queryByTestId('historicalResultsTour')).not.toBeInTheDocument(); + }, 10000); + }); + + describe('when the tour close button is clicked', () => { + it('invokes onDismissTour', async () => { + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + const onDismissTour = jest.fn(); + + render( + + + + + + ); + + const tourDialog = await screen.findByRole('dialog', { + name: 'Introducing data quality history', + }); + + const closeButton = within(tourDialog).getByRole('button', { name: 'Close' }); + + await userEvent.click(closeButton); + + expect(onDismissTour).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the tour tryIt action is clicked', () => { + it('opens the flyout with history tab and invokes onDismissTour', async () => { + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + const onDismissTour = jest.fn(); + + render( + + + + + + ); + + const tourDialog = await screen.findByRole('dialog', { + name: 'Introducing data quality history', + }); + + const tryItButton = within(tourDialog).getByRole('button', { name: 'Try it' }); + + await userEvent.click(tryItButton); + + expect(onDismissTour).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('indexCheckFlyout')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Latest Check' })).toHaveAttribute( + 'aria-selected', + 'false' + ); + expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + }); + + describe('when latest latest check flyout tab is opened', () => { + it('hides the tour in listview and shows in flyout', async () => { + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + const onDismissTour = jest.fn(); + + render( + + + + + + ); + + const rows = screen.getAllByRole('row'); + // skipping the first row which is the header + const firstBodyRow = within(rows[1]); + + expect(await firstBodyRow.findByTestId('historicalResultsTour')).toBeInTheDocument(); + expect( + screen.getByRole('dialog', { name: 'Introducing data quality history' }) + ).toBeInTheDocument(); + + const checkNowButton = firstBodyRow.getByRole('button', { + name: 'Check now', + }); + await userEvent.click(checkNowButton); + + expect(screen.getByTestId('indexCheckFlyout')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Latest Check' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute( + 'aria-selected', + 'false' + ); + + expect(firstBodyRow.queryByTestId('historicalResultsTour')).not.toBeInTheDocument(); + + const tabWrapper = await screen.findByRole('tab', { name: 'History' }); + await waitFor(() => + expect( + tabWrapper.closest('[data-test-subj="historicalResultsTour"]') + ).toBeInTheDocument() + ); + + expect(onDismissTour).not.toHaveBeenCalled(); + }, 10000); + }); + }); + + describe('when not isFirstOpenNonEmptyPattern', () => { + it('does not render the tour', async () => { + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + render( + + + + + + ); + + expect(screen.queryByTestId('historicalResultsTour')).not.toBeInTheDocument(); + }); + }); + + describe('when not isTourActive', () => { + it('does not render the tour', async () => { + (useIlmExplain as jest.Mock).mockReturnValue({ + error: null, + ilmExplain: auditbeatWithAllResults.ilmExplain, + loading: false, + }); + + (useStats as jest.Mock).mockReturnValue({ + stats: auditbeatWithAllResults.stats, + error: null, + loading: false, + }); + + render( + + + + + + ); + + expect(screen.queryByTestId('historicalResultsTour')).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.tsx index 30c4aa8755a9c..a51f521eca169 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index.tsx @@ -35,6 +35,7 @@ import { getPageIndex } from './utils/get_page_index'; import { useAbortControllerRef } from '../../../hooks/use_abort_controller_ref'; import { useHistoricalResults } from './hooks/use_historical_results'; import { HistoricalResultsContext } from './contexts/historical_results_context'; +import { HistoricalResultsTour } from './historical_results_tour'; const EMPTY_INDEX_NAMES: string[] = []; @@ -44,6 +45,11 @@ interface Props { patternRollup: PatternRollup | undefined; chartSelectedIndex: SelectedIndex | null; setChartSelectedIndex: (selectedIndex: SelectedIndex | null) => void; + isTourActive: boolean; + isFirstOpenNonEmptyPattern: boolean; + onAccordionToggle: (patternName: string, isOpen: boolean, isEmpty: boolean) => void; + onDismissTour: () => void; + openPatternsUpdatedAt?: number; } const PatternComponent: React.FC = ({ @@ -52,6 +58,11 @@ const PatternComponent: React.FC = ({ patternRollup, chartSelectedIndex, setChartSelectedIndex, + isTourActive, + isFirstOpenNonEmptyPattern, + onAccordionToggle, + onDismissTour, + openPatternsUpdatedAt, }) => { const { historicalResultsState, fetchHistoricalResults } = useHistoricalResults(); const historicalResultsContextValue = useMemo( @@ -124,6 +135,35 @@ const PatternComponent: React.FC = ({ ] ); + const [isAccordionOpen, setIsAccordionOpen] = useState(true); + + const isAccordionOpenRef = useRef(isAccordionOpen); + useEffect(() => { + isAccordionOpenRef.current = isAccordionOpen; + }, [isAccordionOpen]); + + useEffect(() => { + // this use effect syncs isEmpty state with the parent component + // + // we do not add isAccordionOpen to the dependency array because + // it is already handled by handleAccordionToggle + // so we don't want to additionally trigger this useEffect when isAccordionOpen changes + // because it's confusing and unnecessary + // that's why we use ref here to keep separation of concerns + onAccordionToggle(pattern, isAccordionOpenRef.current, items.length === 0); + }, [items.length, onAccordionToggle, pattern]); + + const handleAccordionToggle = useCallback( + (isOpen: boolean) => { + const isEmpty = items.length === 0; + setIsAccordionOpen(isOpen); + onAccordionToggle(pattern, isOpen, isEmpty); + }, + [items.length, onAccordionToggle, pattern] + ); + + const firstRow = items[0]; + const handleFlyoutClose = useCallback(() => { setExpandedIndexName(null); }, []); @@ -153,6 +193,9 @@ const PatternComponent: React.FC = ({ const handleFlyoutViewCheckHistoryAction = useCallback( (indexName: string) => { + if (isTourActive) { + onDismissTour(); + } fetchHistoricalResults({ abortController: flyoutViewCheckHistoryAbortControllerRef.current, indexName, @@ -160,9 +203,16 @@ const PatternComponent: React.FC = ({ setExpandedIndexName(indexName); setInitialFlyoutTabId(HISTORY_TAB_ID); }, - [fetchHistoricalResults, flyoutViewCheckHistoryAbortControllerRef] + [fetchHistoricalResults, flyoutViewCheckHistoryAbortControllerRef, isTourActive, onDismissTour] ); + const handleOpenFlyoutHistoryTab = useCallback(() => { + const firstItemIndexName = firstRow?.indexName; + if (firstItemIndexName) { + handleFlyoutViewCheckHistoryAction(firstItemIndexName); + } + }, [firstRow?.indexName, handleFlyoutViewCheckHistoryAction]); + useEffect(() => { const newIndexNames = getIndexNames({ stats, ilmExplain, ilmPhases, isILMAvailable }); const newDocsCount = getPatternDocsCount({ indexNames: newIndexNames, stats }); @@ -270,7 +320,8 @@ const PatternComponent: React.FC = ({ = ({ {!loading && error == null && (
+ = ({ ilmExplain={ilmExplain} stats={stats} onClose={handleFlyoutClose} + onDismissTour={onDismissTour} + isTourActive={isTourActive} /> ) : null} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.test.tsx index 7b63f712a99da..e73fd4c2d610d 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IndexCheckFlyout } from '.'; @@ -41,6 +41,8 @@ describe('IndexCheckFlyout', () => { pattern="auditbeat-*" patternRollup={auditbeatWithAllResults} stats={mockStats} + onDismissTour={jest.fn()} + isTourActive={false} /> @@ -97,6 +99,8 @@ describe('IndexCheckFlyout', () => { patternRollup={auditbeatWithAllResults} stats={mockStats} initialSelectedTabId="latest_check" + isTourActive={false} + onDismissTour={jest.fn()} /> @@ -129,6 +133,8 @@ describe('IndexCheckFlyout', () => { patternRollup={auditbeatWithAllResults} stats={mockStats} initialSelectedTabId="latest_check" + isTourActive={false} + onDismissTour={jest.fn()} /> @@ -175,6 +181,8 @@ describe('IndexCheckFlyout', () => { patternRollup={auditbeatWithAllResults} stats={mockStats} initialSelectedTabId="latest_check" + onDismissTour={jest.fn()} + isTourActive={false} /> @@ -207,4 +215,179 @@ describe('IndexCheckFlyout', () => { expect(screen.getByTestId('historicalResults')).toBeInTheDocument(); }); }); + + describe('Tour guide', () => { + describe('when in Latest Check tab and isTourActive', () => { + it('should render the tour guide near history tab with proper data-tour-element attribute', async () => { + const pattern = 'auditbeat-*'; + render( + + + + + + + + ); + + const historyTab = screen.getByRole('tab', { name: 'History' }); + const latestCheckTab = screen.getByRole('tab', { name: 'Latest Check' }); + + expect(historyTab).toHaveAttribute('data-tour-element', `${pattern}-history-tab`); + expect(latestCheckTab).not.toHaveAttribute('data-tour-element', `${pattern}-history-tab`); + await waitFor(() => + expect(historyTab.closest('[data-test-subj="historicalResultsTour"]')).toBeInTheDocument() + ); + expect( + screen.getByRole('dialog', { name: 'Introducing data quality history' }) + ).toBeInTheDocument(); + }); + + describe('when the tour close button is clicked', () => { + it('should invoke the dismiss tour callback', async () => { + const onDismissTour = jest.fn(); + render( + + + + + + + + ); + + const dialogWrapper = await screen.findByRole('dialog', { + name: 'Introducing data quality history', + }); + + const closeButton = within(dialogWrapper).getByRole('button', { name: 'Close' }); + await userEvent.click(closeButton); + + expect(onDismissTour).toHaveBeenCalled(); + }); + }); + + describe('when the tour TryIt button is clicked', () => { + it('should switch to history tab and invoke onDismissTour', async () => { + const onDismissTour = jest.fn(); + render( + + + + + + + + ); + + const dialogWrapper = await screen.findByRole('dialog', { + name: 'Introducing data quality history', + }); + + const tryItButton = within(dialogWrapper).getByRole('button', { name: 'Try it' }); + await userEvent.click(tryItButton); + + expect(onDismissTour).toHaveBeenCalled(); + expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + + expect(onDismissTour).toHaveBeenCalled(); + }); + }); + + describe('when manually switching to history tab', () => { + it('should invoke onDismissTour', async () => { + const onDismissTour = jest.fn(); + render( + + + + + + + + ); + + const historyTab = screen.getByRole('tab', { name: 'History' }); + await userEvent.click(historyTab); + + expect(onDismissTour).toHaveBeenCalled(); + }); + }); + }); + + describe('when not isTourActive', () => { + it('should not render the tour guide', async () => { + render( + + + + + + + + ); + + await waitFor(() => + expect(screen.queryByTestId('historicalResultsTour')).not.toBeInTheDocument() + ); + + expect( + screen.queryByRole('dialog', { name: 'Introducing data quality history' }) + ).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.tsx index f298af704307d..b6dcf850d15b0 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/index_check_flyout/index.tsx @@ -36,8 +36,13 @@ import { HistoricalResults } from './historical_results'; import { useHistoricalResultsContext } from '../contexts/historical_results_context'; import { getFormattedCheckTime } from './utils/get_formatted_check_time'; import { CHECK_NOW } from '../translations'; -import { HISTORY_TAB_ID, LATEST_CHECK_TAB_ID } from '../constants'; +import { + HISTORICAL_RESULTS_TOUR_SELECTOR_KEY, + HISTORY_TAB_ID, + LATEST_CHECK_TAB_ID, +} from '../constants'; import { IndexCheckFlyoutTabId } from './types'; +import { HistoricalResultsTour } from '../historical_results_tour'; export interface Props { ilmExplain: Record | null; @@ -47,6 +52,8 @@ export interface Props { stats: Record | null; onClose: () => void; initialSelectedTabId: IndexCheckFlyoutTabId; + onDismissTour: () => void; + isTourActive: boolean; } const tabs = [ @@ -68,6 +75,8 @@ export const IndexCheckFlyoutComponent: React.FC = ({ patternRollup, stats, onClose, + onDismissTour, + isTourActive, }) => { const didSwitchToLatestTabOnceRef = useRef(false); const { fetchHistoricalResults } = useHistoricalResultsContext(); @@ -90,12 +99,15 @@ export const IndexCheckFlyoutComponent: React.FC = ({ const handleTabClick = useCallback( (tabId: IndexCheckFlyoutTabId) => { + setSelectedTabId(tabId); if (tabId === HISTORY_TAB_ID) { + if (isTourActive) { + onDismissTour(); + } fetchHistoricalResults({ abortController: fetchHistoricalResultsAbortControllerRef.current, indexName, }); - setSelectedTabId(tabId); } if (tabId === LATEST_CHECK_TAB_ID) { @@ -110,7 +122,6 @@ export const IndexCheckFlyoutComponent: React.FC = ({ formatNumber, }); } - setSelectedTabId(tabId); } }, [ @@ -122,6 +133,8 @@ export const IndexCheckFlyoutComponent: React.FC = ({ formatNumber, httpFetch, indexName, + isTourActive, + onDismissTour, pattern, ] ); @@ -149,6 +162,10 @@ export const IndexCheckFlyoutComponent: React.FC = ({ selectedTabId, ]); + const handleSelectHistoryTab = useCallback(() => { + handleTabClick(HISTORY_TAB_ID); + }, [handleTabClick]); + const renderTabs = useMemo( () => tabs.map((tab, index) => { @@ -157,12 +174,15 @@ export const IndexCheckFlyoutComponent: React.FC = ({ onClick={() => handleTabClick(tab.id)} isSelected={tab.id === selectedTabId} key={index} + {...(tab.id === HISTORY_TAB_ID && { + [HISTORICAL_RESULTS_TOUR_SELECTOR_KEY]: `${pattern}-history-tab`, + })} > {tab.name} ); }), - [handleTabClick, selectedTabId] + [handleTabClick, pattern, selectedTabId] ); return ( @@ -195,12 +215,20 @@ export const IndexCheckFlyoutComponent: React.FC = ({ {selectedTabId === LATEST_CHECK_TAB_ID ? ( - + <> + + + ) : ( )} diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/index.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/index.tsx index fa574362e7d9b..02298a5b7dd94 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/index.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/index.tsx @@ -30,6 +30,7 @@ export interface Props { pattern: string; onCheckNowAction: (indexName: string) => void; onViewHistoryAction: (indexName: string) => void; + firstIndexName?: string; }) => Array>; items: IndexSummaryTableItem[]; pageIndex: number; @@ -66,6 +67,7 @@ const SummaryTableComponent: React.FC = ({ pattern, onCheckNowAction, onViewHistoryAction, + firstIndexName: items[0]?.indexName, }), [ getTableColumns, @@ -75,6 +77,7 @@ const SummaryTableComponent: React.FC = ({ pattern, onCheckNowAction, onViewHistoryAction, + items, ] ); const getItemId = useCallback((item: IndexSummaryTableItem) => item.indexName, []); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.test.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.test.tsx index eda93c45f3b4f..bffd0c7fb91de 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.test.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.test.tsx @@ -197,6 +197,60 @@ describe('helpers', () => { expect(onViewHistoryAction).toBeCalledWith(indexSummaryTableItem.indexName); }); + + test('adds data-tour-element attribute to the first view history button', () => { + const pattern = 'auditbeat-*'; + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + isILMAvailable, + pattern, + onCheckNowAction: jest.fn(), + onViewHistoryAction: jest.fn(), + firstIndexName: indexName, + }); + + const expandActionRender = ( + (columns[0] as EuiTableActionsColumnType) + .actions[1] as CustomItemAction + ).render; + + render( + + {expandActionRender != null && expandActionRender(indexSummaryTableItem, true)} + + ); + + const button = screen.getByLabelText(VIEW_HISTORY); + expect(button).toHaveAttribute('data-tour-element', pattern); + }); + + test('doesn`t add data-tour-element attribute to non-first view history buttons', () => { + const pattern = 'auditbeat-*'; + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + isILMAvailable, + pattern, + onCheckNowAction: jest.fn(), + onViewHistoryAction: jest.fn(), + firstIndexName: 'another-index', + }); + + const expandActionRender = ( + (columns[0] as EuiTableActionsColumnType) + .actions[1] as CustomItemAction + ).render; + + render( + + {expandActionRender != null && expandActionRender(indexSummaryTableItem, true)} + + ); + + const button = screen.getByLabelText(VIEW_HISTORY); + expect(button).not.toHaveAttribute('data-tour-element'); + }); }); describe('incompatible render()', () => { diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.tsx index c930d47babc2e..832ba71d26af8 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/data_quality_details/indices_details/pattern/summary_table/utils/columns.tsx @@ -37,6 +37,7 @@ import { IndexResultBadge } from '../../index_result_badge'; import { Stat } from '../../../../../stat'; import { getIndexResultToolTip } from '../../utils/get_index_result_tooltip'; import { CHECK_NOW } from '../../translations'; +import { HISTORICAL_RESULTS_TOUR_SELECTOR_KEY } from '../../constants'; const ProgressContainer = styled.div` width: 150px; @@ -102,6 +103,7 @@ export const getSummaryTableColumns = ({ pattern, onCheckNowAction, onViewHistoryAction, + firstIndexName, }: { formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; @@ -109,6 +111,7 @@ export const getSummaryTableColumns = ({ pattern: string; onCheckNowAction: (indexName: string) => void; onViewHistoryAction: (indexName: string) => void; + firstIndexName?: string; }): Array> => [ { name: i18n.ACTIONS, @@ -132,12 +135,16 @@ export const getSummaryTableColumns = ({ { name: i18n.VIEW_HISTORY, render: (item) => { + const isFirstIndexName = firstIndexName === item.indexName; return ( onViewHistoryAction(item.indexName)} + {...(isFirstIndexName && { + [HISTORICAL_RESULTS_TOUR_SELECTOR_KEY]: pattern, + })} /> ); diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/pattern_rollup/mock_auditbeat_pattern_rollup.ts b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/pattern_rollup/mock_auditbeat_pattern_rollup.ts index 6f3c7b008a5af..9d0e09ef57d96 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/pattern_rollup/mock_auditbeat_pattern_rollup.ts +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality_panel/mock/pattern_rollup/mock_auditbeat_pattern_rollup.ts @@ -166,3 +166,21 @@ export const auditbeatWithAllResults: PatternRollup = { }, }, }; + +export const emptyAuditbeatPatternRollup: PatternRollup = { + docsCount: 0, + error: null, + ilmExplain: {}, + ilmExplainPhaseCounts: { + hot: 0, + warm: 0, + cold: 0, + frozen: 0, + unmanaged: 0, + }, + indices: 0, + pattern: 'auditbeat-*', + results: {}, + sizeInBytes: 0, + stats: {}, +};