Skip to content

Commit

Permalink
[8.x] [Security Solution][DQD] Add historical results tour guide (#19…
Browse files Browse the repository at this point in the history
…6127) (#196456)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Security Solution][DQD] Add historical results tour guide
(#196127)](#196127)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Karen
Grigoryan","email":"[email protected]"},"sourceCommit":{"committedDate":"2024-10-15T23:18:50Z","message":"[Security
Solution][DQD] Add historical results tour guide (#196127)\n\naddresses
#195971\r\n\r\nThis PR adds missing new historical results feature tour
guide.\r\n\r\n## Tour guide features:\r\n- ability to maintain visual
presence while collapsing accordions in\r\nlist-view\r\n- move from
list-view to flyout view and back\r\n- seamlessly integrates with
existing opening flyout and history tab\r\nfunctionality\r\n\r\n## PR
decisions with explanation:\r\n- data-tour-element has been introduced
on select elements (like first\r\nactions of each first row) to avoid
polluting every single element with\r\ndata-test-subj. This way it's
imho specific and semantically more clear\r\nwhat the elements are
for.\r\n- early on I tried to control the anchoring with refs but some
eui\r\nelements don't allow passing refs like EuiTab, so instead a more
simpler\r\nand straightforward approach with dom selectors has been
chosen\r\n- localStorage key name has been picked in accordance with
other\r\ninstances of
usage\r\n`securitySolution.dataQualityDashboard.historicalResultsTour.v8.16.isActive`\r\nthe
name includes the full domain + the version when it's introduced.\r\nAnd
since this tour step is a single step there is no need to
stringify\r\nan object with `isTourActive` in and it's much simpler to
just bake the\r\nactivity state into the name and make the value just a
boolean.\r\n\r\n## UI Demo\r\n\r\n### Anchor reposition demo (listview +
flyout)\r\n\r\nhttps://github.com/user-attachments/assets/0f961c51-0e36-48ca-aab4-bef3b0d1269e\r\n\r\n###
List view tour guide try it + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/ca1f5fda-ee02-4a48-827c-91df757a8ddf\r\n\r\n###
FlyOut Try It + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/d0801ac3-1ed1-4e64-9d6b-3140b8402bdf\r\n\r\n###
Manual history tab selection path + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/34dbb447-2fd6-4dc0-a4f5-682c9c65cc8b\r\n\r\n###
Manual open history view path + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/945dd042-fc12-476e-8d23-f48c9ded9f65\r\n\r\n###
Dismiss list view tour guide + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/d20d1416-827f-46f2-9161-a3c0a8cbd932\r\n\r\n###
Dismiss FlyOut tour guide + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/8f085f59-20a9-49f0-b5b3-959c4719f5cb\r\n\r\n###
Serverless empty pattern handling + reposition
demo\r\n\r\nhttps://github.com/user-attachments/assets/4af5939e-663c-4439-a3fc-deff2d4de7e4","sha":"c448593d546f6200b0d2d35bce043bef521f41a6","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["v9.0.0","Team:Threat
Hunting","release_note:feature","Team:Threat
Hunting:Explore","backport:prev-minor"],"title":"[Security
Solution][DQD] Add historical results tour
guide","number":196127,"url":"https://github.com/elastic/kibana/pull/196127","mergeCommit":{"message":"[Security
Solution][DQD] Add historical results tour guide (#196127)\n\naddresses
#195971\r\n\r\nThis PR adds missing new historical results feature tour
guide.\r\n\r\n## Tour guide features:\r\n- ability to maintain visual
presence while collapsing accordions in\r\nlist-view\r\n- move from
list-view to flyout view and back\r\n- seamlessly integrates with
existing opening flyout and history tab\r\nfunctionality\r\n\r\n## PR
decisions with explanation:\r\n- data-tour-element has been introduced
on select elements (like first\r\nactions of each first row) to avoid
polluting every single element with\r\ndata-test-subj. This way it's
imho specific and semantically more clear\r\nwhat the elements are
for.\r\n- early on I tried to control the anchoring with refs but some
eui\r\nelements don't allow passing refs like EuiTab, so instead a more
simpler\r\nand straightforward approach with dom selectors has been
chosen\r\n- localStorage key name has been picked in accordance with
other\r\ninstances of
usage\r\n`securitySolution.dataQualityDashboard.historicalResultsTour.v8.16.isActive`\r\nthe
name includes the full domain + the version when it's introduced.\r\nAnd
since this tour step is a single step there is no need to
stringify\r\nan object with `isTourActive` in and it's much simpler to
just bake the\r\nactivity state into the name and make the value just a
boolean.\r\n\r\n## UI Demo\r\n\r\n### Anchor reposition demo (listview +
flyout)\r\n\r\nhttps://github.com/user-attachments/assets/0f961c51-0e36-48ca-aab4-bef3b0d1269e\r\n\r\n###
List view tour guide try it + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/ca1f5fda-ee02-4a48-827c-91df757a8ddf\r\n\r\n###
FlyOut Try It + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/d0801ac3-1ed1-4e64-9d6b-3140b8402bdf\r\n\r\n###
Manual history tab selection path + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/34dbb447-2fd6-4dc0-a4f5-682c9c65cc8b\r\n\r\n###
Manual open history view path + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/945dd042-fc12-476e-8d23-f48c9ded9f65\r\n\r\n###
Dismiss list view tour guide + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/d20d1416-827f-46f2-9161-a3c0a8cbd932\r\n\r\n###
Dismiss FlyOut tour guide + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/8f085f59-20a9-49f0-b5b3-959c4719f5cb\r\n\r\n###
Serverless empty pattern handling + reposition
demo\r\n\r\nhttps://github.com/user-attachments/assets/4af5939e-663c-4439-a3fc-deff2d4de7e4","sha":"c448593d546f6200b0d2d35bce043bef521f41a6"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/196127","number":196127,"mergeCommit":{"message":"[Security
Solution][DQD] Add historical results tour guide (#196127)\n\naddresses
#195971\r\n\r\nThis PR adds missing new historical results feature tour
guide.\r\n\r\n## Tour guide features:\r\n- ability to maintain visual
presence while collapsing accordions in\r\nlist-view\r\n- move from
list-view to flyout view and back\r\n- seamlessly integrates with
existing opening flyout and history tab\r\nfunctionality\r\n\r\n## PR
decisions with explanation:\r\n- data-tour-element has been introduced
on select elements (like first\r\nactions of each first row) to avoid
polluting every single element with\r\ndata-test-subj. This way it's
imho specific and semantically more clear\r\nwhat the elements are
for.\r\n- early on I tried to control the anchoring with refs but some
eui\r\nelements don't allow passing refs like EuiTab, so instead a more
simpler\r\nand straightforward approach with dom selectors has been
chosen\r\n- localStorage key name has been picked in accordance with
other\r\ninstances of
usage\r\n`securitySolution.dataQualityDashboard.historicalResultsTour.v8.16.isActive`\r\nthe
name includes the full domain + the version when it's introduced.\r\nAnd
since this tour step is a single step there is no need to
stringify\r\nan object with `isTourActive` in and it's much simpler to
just bake the\r\nactivity state into the name and make the value just a
boolean.\r\n\r\n## UI Demo\r\n\r\n### Anchor reposition demo (listview +
flyout)\r\n\r\nhttps://github.com/user-attachments/assets/0f961c51-0e36-48ca-aab4-bef3b0d1269e\r\n\r\n###
List view tour guide try it + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/ca1f5fda-ee02-4a48-827c-91df757a8ddf\r\n\r\n###
FlyOut Try It + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/d0801ac3-1ed1-4e64-9d6b-3140b8402bdf\r\n\r\n###
Manual history tab selection path + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/34dbb447-2fd6-4dc0-a4f5-682c9c65cc8b\r\n\r\n###
Manual open history view path + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/945dd042-fc12-476e-8d23-f48c9ded9f65\r\n\r\n###
Dismiss list view tour guide + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/d20d1416-827f-46f2-9161-a3c0a8cbd932\r\n\r\n###
Dismiss FlyOut tour guide + reload
demo\r\n\r\nhttps://github.com/user-attachments/assets/8f085f59-20a9-49f0-b5b3-959c4719f5cb\r\n\r\n###
Serverless empty pattern handling + reposition
demo\r\n\r\nhttps://github.com/user-attachments/assets/4af5939e-663c-4439-a3fc-deff2d4de7e4","sha":"c448593d546f6200b0d2d35bce043bef521f41a6"}}]}]
BACKPORT-->

Co-authored-by: Karen Grigoryan <[email protected]>
  • Loading branch information
kibanamachine and kapral18 authored Oct 16, 2024
1 parent b8fcdcc commit 1549d38
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 1549d38

Please sign in to comment.