Skip to content

Commit

Permalink
[Security Solution][DQD] Add historical results tour guide (#196127)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
kapral18 authored Oct 15, 2024
1 parent 40bfd12 commit c448593
Show file tree
Hide file tree
Showing 16 changed files with 1,304 additions and 26 deletions.
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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<boolean>(
HISTORICAL_RESULTS_TOUR_IS_DISMISSED_STORAGE_KEY,
false
);

const isTourActive = !isTourDismissed;
const setIsTourActive = useCallback(
(active: boolean) => {
setIsTourDismissed(!active);
},
[setIsTourDismissed]
);

return [isTourActive, setIsTourActive] as const;
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,24 @@
*/

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,
TestExternalProviders,
} 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) =>
Expand All @@ -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<string, PatternRollup> = {
'test-empty-pattern-*': { ...emptyAuditbeatPatternRollup, pattern: 'test-empty-pattern-*' },
'.alerts-security.alerts-default': alertIndexWithAllResults,
'auditbeat-*': auditbeatWithAllResults,
'packetbeat-*': packetbeatNoResults,
};

const patternIndexNames: Record<string, string[]> = {
'test-empty-pattern-*': [],
'auditbeat-*': [
'.ds-auditbeat-8.6.1-2023.02.07-000001',
'auditbeat-custom-empty-index-1',
Expand All @@ -58,6 +70,7 @@ const defaultProps: Props = {
describe('IndicesDetails', () => {
beforeEach(async () => {
jest.clearAllMocks();
localStorage.removeItem(HISTORICAL_RESULTS_TOUR_IS_DISMISSED_STORAGE_KEY);

render(
<TestExternalProviders>
Expand All @@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -34,6 +35,41 @@ const IndicesDetailsComponent: React.FC<Props> = ({
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<number>(Date.now());

useEffect(() => {
if (firstOpenNonEmptyPattern) {
setOpenPatternsUpdatedAt(Date.now());
}
}, [openPatterns, firstOpenNonEmptyPattern]);

return (
<div data-test-subj="indicesDetails">
{patterns.map((pattern) => (
Expand All @@ -44,6 +80,16 @@ const IndicesDetailsComponent: React.FC<Props> = ({
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 })}
/>
</StyledPatternWrapperFlexItem>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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(
<HistoricalResultsTour
anchorSelectorValue={anchorSelectorValue}
onTryIt={jest.fn()}
isOpen={true}
onDismissTour={jest.fn()}
/>
);

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 = `<div ${HISTORICAL_RESULTS_TOUR_SELECTOR_KEY}="${anchorSelectorValue}"></div>`;
});

describe('when isOpen is true', () => {
const onTryIt = jest.fn();
const onDismissTour = jest.fn();
beforeEach(() => {
render(
<HistoricalResultsTour
anchorSelectorValue={anchorSelectorValue}
onTryIt={onTryIt}
isOpen={true}
onDismissTour={onDismissTour}
/>
);
});
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(
<HistoricalResultsTour
anchorSelectorValue={anchorSelectorValue}
onTryIt={jest.fn()}
isOpen={false}
onDismissTour={jest.fn()}
/>
);

await waitFor(() =>
expect(screen.queryByText(INTRODUCING_DATA_QUALITY_HISTORY)).not.toBeInTheDocument()
);
});
});
});
});
Loading

0 comments on commit c448593

Please sign in to comment.