[8.x] [Security Solution][DQD] Add historical results tour guide (#196127) (#196456)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Security Solution][DQD] Add historical results tour guide
(#196127)](https://github.com/elastic/kibana/pull/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":"karen.grigoryan@elastic.co"},"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 <karen.grigoryan@elastic.co>
This commit is contained in:
Kibana Machine 2024-10-16 12:01:40 +11:00 committed by GitHub
parent b8fcdcc933
commit 1549d38d02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1304 additions and 26 deletions

View file

@ -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';

View file

@ -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;
};

View file

@ -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<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',
@ -58,6 +70,7 @@ const defaultProps: Props = {
describe('IndicesDetails', () => {
beforeEach(async () => {
jest.clearAllMocks();
localStorage.removeItem(HISTORICAL_RESULTS_TOUR_IS_DISMISSED_STORAGE_KEY);
render(
<TestExternalProviders>
@ -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);
});
});
});

View file

@ -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<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) => (
@ -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>
))}

View file

@ -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';

View file

@ -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()
);
});
});
});
});

View file

@ -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<Props> = ({
anchorSelectorValue,
onTryIt,
isOpen,
onDismissTour,
zIndex,
}) => {
const [anchorElement, setAnchorElement] = useState<HTMLElement>();
useEffect(() => {
const element = document.querySelector<HTMLElement>(
`[${HISTORICAL_RESULTS_TOUR_SELECTOR_KEY}="${anchorSelectorValue}"]`
);
if (!element) {
return;
}
setAnchorElement(element);
}, [anchorSelectorValue]);
if (!isOpen || !anchorElement) {
return null;
}
return (
<EuiTourStep
content={
<StyledText size="s">
<p>{VIEW_PAST_RESULTS}</p>
</StyledText>
}
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={[
<EuiButtonEmpty size="xs" color="text" onClick={onDismissTour}>
{CLOSE}
</EuiButtonEmpty>,
<EuiButton color="success" size="s" onClick={onTryIt}>
{TRY_IT}
</EuiButton>,
]}
/>
);
};

View file

@ -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',
}
);

View file

@ -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()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
@ -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(
<TestExternalProviders>
<TestDataQualityProviders>
<Pattern
patternRollup={emptyAuditbeatPatternRollup}
chartSelectedIndex={null}
setChartSelectedIndex={jest.fn()}
indexNames={[]}
pattern={pattern}
isTourActive={false}
onDismissTour={jest.fn()}
isFirstOpenNonEmptyPattern={false}
onAccordionToggle={onAccordionToggle}
/>
</TestDataQualityProviders>
</TestExternalProviders>
);
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(
<TestExternalProviders>
<TestDataQualityProviders>
<Pattern
patternRollup={auditbeatWithAllResults}
chartSelectedIndex={null}
setChartSelectedIndex={jest.fn()}
indexNames={Object.keys(auditbeatWithAllResults.stats!)}
pattern={pattern}
isTourActive={false}
onDismissTour={jest.fn()}
isFirstOpenNonEmptyPattern={false}
onAccordionToggle={onAccordionToggle}
/>
</TestDataQualityProviders>
</TestExternalProviders>
);
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(
<TestExternalProviders>
<TestDataQualityProviders>
<Pattern
patternRollup={auditbeatWithAllResults}
chartSelectedIndex={null}
setChartSelectedIndex={jest.fn()}
indexNames={Object.keys(auditbeatWithAllResults.stats!)}
pattern={pattern}
isTourActive={false}
onDismissTour={jest.fn()}
isFirstOpenNonEmptyPattern={false}
onAccordionToggle={onAccordionToggle}
/>
</TestDataQualityProviders>
</TestExternalProviders>
);
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()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
@ -127,6 +290,10 @@ describe('pattern', () => {
setChartSelectedIndex={jest.fn()}
indexNames={undefined}
pattern={pattern}
isTourActive={false}
onDismissTour={jest.fn()}
isFirstOpenNonEmptyPattern={false}
onAccordionToggle={jest.fn()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
@ -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()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
@ -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()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
@ -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()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
@ -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()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
@ -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()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
@ -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()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
@ -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()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
@ -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()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
@ -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(
<TestExternalProviders>
<TestDataQualityProviders>
<Pattern
patternRollup={auditbeatWithAllResults}
chartSelectedIndex={null}
setChartSelectedIndex={jest.fn()}
indexNames={Object.keys(auditbeatWithAllResults.stats!)}
pattern={pattern}
isTourActive={true}
onDismissTour={jest.fn()}
isFirstOpenNonEmptyPattern={true}
onAccordionToggle={jest.fn()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
);
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(
<TestExternalProviders>
<TestDataQualityProviders>
<Pattern
patternRollup={auditbeatWithAllResults}
chartSelectedIndex={null}
setChartSelectedIndex={jest.fn()}
indexNames={Object.keys(auditbeatWithAllResults.stats!)}
pattern={pattern}
isTourActive={true}
onDismissTour={jest.fn()}
isFirstOpenNonEmptyPattern={true}
onAccordionToggle={jest.fn()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
);
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(
<TestExternalProviders>
<TestDataQualityProviders>
<Pattern
patternRollup={auditbeatWithAllResults}
chartSelectedIndex={null}
setChartSelectedIndex={jest.fn()}
indexNames={Object.keys(auditbeatWithAllResults.stats!)}
pattern={pattern}
isTourActive={true}
onDismissTour={onDismissTour}
isFirstOpenNonEmptyPattern={true}
onAccordionToggle={jest.fn()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
);
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(
<TestExternalProviders>
<TestDataQualityProviders>
<Pattern
patternRollup={auditbeatWithAllResults}
chartSelectedIndex={null}
setChartSelectedIndex={jest.fn()}
indexNames={Object.keys(auditbeatWithAllResults.stats!)}
pattern={pattern}
isTourActive={true}
onDismissTour={onDismissTour}
isFirstOpenNonEmptyPattern={true}
onAccordionToggle={jest.fn()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
);
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(
<TestExternalProviders>
<TestDataQualityProviders>
<Pattern
patternRollup={auditbeatWithAllResults}
chartSelectedIndex={null}
setChartSelectedIndex={jest.fn()}
indexNames={Object.keys(auditbeatWithAllResults.stats!)}
pattern={pattern}
isTourActive={true}
onDismissTour={onDismissTour}
isFirstOpenNonEmptyPattern={true}
onAccordionToggle={jest.fn()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
);
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(
<TestExternalProviders>
<TestDataQualityProviders>
<Pattern
patternRollup={auditbeatWithAllResults}
chartSelectedIndex={null}
setChartSelectedIndex={jest.fn()}
indexNames={Object.keys(auditbeatWithAllResults.stats!)}
pattern={pattern}
isTourActive={true}
onDismissTour={jest.fn()}
isFirstOpenNonEmptyPattern={false}
onAccordionToggle={jest.fn()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
);
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(
<TestExternalProviders>
<TestDataQualityProviders>
<Pattern
patternRollup={auditbeatWithAllResults}
chartSelectedIndex={null}
setChartSelectedIndex={jest.fn()}
indexNames={Object.keys(auditbeatWithAllResults.stats!)}
pattern={pattern}
isTourActive={false}
onDismissTour={jest.fn()}
isFirstOpenNonEmptyPattern={true}
onAccordionToggle={jest.fn()}
/>
</TestDataQualityProviders>
</TestExternalProviders>
);
expect(screen.queryByTestId('historicalResultsTour')).not.toBeInTheDocument();
});
});
});
});

View file

@ -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<Props> = ({
@ -52,6 +58,11 @@ const PatternComponent: React.FC<Props> = ({
patternRollup,
chartSelectedIndex,
setChartSelectedIndex,
isTourActive,
isFirstOpenNonEmptyPattern,
onAccordionToggle,
onDismissTour,
openPatternsUpdatedAt,
}) => {
const { historicalResultsState, fetchHistoricalResults } = useHistoricalResults();
const historicalResultsContextValue = useMemo(
@ -124,6 +135,35 @@ const PatternComponent: React.FC<Props> = ({
]
);
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<Props> = ({
const handleFlyoutViewCheckHistoryAction = useCallback(
(indexName: string) => {
if (isTourActive) {
onDismissTour();
}
fetchHistoricalResults({
abortController: flyoutViewCheckHistoryAbortControllerRef.current,
indexName,
@ -160,9 +203,16 @@ const PatternComponent: React.FC<Props> = ({
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<Props> = ({
<HistoricalResultsContext.Provider value={historicalResultsContextValue}>
<PatternAccordion
id={patternComponentAccordionId}
initialIsOpen={true}
forceState={isAccordionOpen ? 'open' : 'closed'}
onToggle={handleAccordionToggle}
buttonElement="div"
buttonContent={
<PatternSummary
@ -308,6 +359,34 @@ const PatternComponent: React.FC<Props> = ({
{!loading && error == null && (
<div ref={containerRef}>
<HistoricalResultsTour
// this is a hack to force popover anchor position recalculation
// when the first open non-empty pattern layout changes due to other
// patterns being opened/closed
// It's a bug on Eui side
//
// TODO: remove this hack when EUI popover is fixed
// https://github.com/elastic/eui/issues/5226
{...(isFirstOpenNonEmptyPattern && { key: openPatternsUpdatedAt })}
anchorSelectorValue={pattern}
onTryIt={handleOpenFlyoutHistoryTab}
isOpen={
isTourActive &&
!isFlyoutVisible &&
isFirstOpenNonEmptyPattern &&
isAccordionOpen
}
onDismissTour={onDismissTour}
// Only set zIndex when the tour is in list view (not in flyout)
//
// 1 less than the z-index of the left navigation
// 5 less than the z-index of the timeline
//
//
// TODO this hack should be removed when we properly set z-indexes
// in the timeline and left navigation
zIndex={998}
/>
<SummaryTable
getTableColumns={getSummaryTableColumns}
items={items}
@ -334,6 +413,8 @@ const PatternComponent: React.FC<Props> = ({
ilmExplain={ilmExplain}
stats={stats}
onClose={handleFlyoutClose}
onDismissTour={onDismissTour}
isTourActive={isTourActive}
/>
) : null}
</HistoricalResultsContext.Provider>

View file

@ -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}
/>
</TestHistoricalResultsProvider>
</TestDataQualityProviders>
@ -97,6 +99,8 @@ describe('IndexCheckFlyout', () => {
patternRollup={auditbeatWithAllResults}
stats={mockStats}
initialSelectedTabId="latest_check"
isTourActive={false}
onDismissTour={jest.fn()}
/>
</TestHistoricalResultsProvider>
</TestDataQualityProviders>
@ -129,6 +133,8 @@ describe('IndexCheckFlyout', () => {
patternRollup={auditbeatWithAllResults}
stats={mockStats}
initialSelectedTabId="latest_check"
isTourActive={false}
onDismissTour={jest.fn()}
/>
</TestHistoricalResultsProvider>
</TestDataQualityProviders>
@ -175,6 +181,8 @@ describe('IndexCheckFlyout', () => {
patternRollup={auditbeatWithAllResults}
stats={mockStats}
initialSelectedTabId="latest_check"
onDismissTour={jest.fn()}
isTourActive={false}
/>
</TestHistoricalResultsProvider>
</TestDataQualityProviders>
@ -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(
<TestExternalProviders>
<TestDataQualityProviders>
<TestHistoricalResultsProvider>
<IndexCheckFlyout
ilmExplain={mockIlmExplain}
indexName="auditbeat-custom-index-1"
onClose={jest.fn()}
pattern={pattern}
patternRollup={auditbeatWithAllResults}
stats={mockStats}
initialSelectedTabId="latest_check"
onDismissTour={jest.fn()}
isTourActive={true}
/>
</TestHistoricalResultsProvider>
</TestDataQualityProviders>
</TestExternalProviders>
);
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(
<TestExternalProviders>
<TestDataQualityProviders>
<TestHistoricalResultsProvider>
<IndexCheckFlyout
ilmExplain={mockIlmExplain}
indexName="auditbeat-custom-index-1"
onClose={jest.fn()}
pattern="auditbeat-*"
patternRollup={auditbeatWithAllResults}
stats={mockStats}
initialSelectedTabId="latest_check"
onDismissTour={onDismissTour}
isTourActive={true}
/>
</TestHistoricalResultsProvider>
</TestDataQualityProviders>
</TestExternalProviders>
);
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(
<TestExternalProviders>
<TestDataQualityProviders>
<TestHistoricalResultsProvider>
<IndexCheckFlyout
ilmExplain={mockIlmExplain}
indexName="auditbeat-custom-index-1"
onClose={jest.fn()}
pattern="auditbeat-*"
patternRollup={auditbeatWithAllResults}
stats={mockStats}
initialSelectedTabId="latest_check"
onDismissTour={onDismissTour}
isTourActive={true}
/>
</TestHistoricalResultsProvider>
</TestDataQualityProviders>
</TestExternalProviders>
);
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(
<TestExternalProviders>
<TestDataQualityProviders>
<TestHistoricalResultsProvider>
<IndexCheckFlyout
ilmExplain={mockIlmExplain}
indexName="auditbeat-custom-index-1"
onClose={jest.fn()}
pattern="auditbeat-*"
patternRollup={auditbeatWithAllResults}
stats={mockStats}
initialSelectedTabId="latest_check"
onDismissTour={onDismissTour}
isTourActive={true}
/>
</TestHistoricalResultsProvider>
</TestDataQualityProviders>
</TestExternalProviders>
);
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(
<TestExternalProviders>
<TestDataQualityProviders>
<TestHistoricalResultsProvider>
<IndexCheckFlyout
ilmExplain={mockIlmExplain}
indexName="auditbeat-custom-index-1"
onClose={jest.fn()}
pattern="auditbeat-*"
patternRollup={auditbeatWithAllResults}
stats={mockStats}
initialSelectedTabId="latest_check"
onDismissTour={jest.fn()}
isTourActive={false}
/>
</TestHistoricalResultsProvider>
</TestDataQualityProviders>
</TestExternalProviders>
);
await waitFor(() =>
expect(screen.queryByTestId('historicalResultsTour')).not.toBeInTheDocument()
);
expect(
screen.queryByRole('dialog', { name: 'Introducing data quality history' })
).not.toBeInTheDocument();
});
});
});
});

View file

@ -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<string, IlmExplainLifecycleLifecycleExplain> | null;
@ -47,6 +52,8 @@ export interface Props {
stats: Record<string, MeteringStatsIndex> | null;
onClose: () => void;
initialSelectedTabId: IndexCheckFlyoutTabId;
onDismissTour: () => void;
isTourActive: boolean;
}
const tabs = [
@ -68,6 +75,8 @@ export const IndexCheckFlyoutComponent: React.FC<Props> = ({
patternRollup,
stats,
onClose,
onDismissTour,
isTourActive,
}) => {
const didSwitchToLatestTabOnceRef = useRef(false);
const { fetchHistoricalResults } = useHistoricalResultsContext();
@ -90,12 +99,15 @@ export const IndexCheckFlyoutComponent: React.FC<Props> = ({
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<Props> = ({
formatNumber,
});
}
setSelectedTabId(tabId);
}
},
[
@ -122,6 +133,8 @@ export const IndexCheckFlyoutComponent: React.FC<Props> = ({
formatNumber,
httpFetch,
indexName,
isTourActive,
onDismissTour,
pattern,
]
);
@ -149,6 +162,10 @@ export const IndexCheckFlyoutComponent: React.FC<Props> = ({
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<Props> = ({
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}
</EuiTab>
);
}),
[handleTabClick, selectedTabId]
[handleTabClick, pattern, selectedTabId]
);
return (
@ -195,12 +215,20 @@ export const IndexCheckFlyoutComponent: React.FC<Props> = ({
</EuiFlyoutHeader>
<EuiFlyoutBody>
{selectedTabId === LATEST_CHECK_TAB_ID ? (
<LatestResults
indexName={indexName}
stats={stats}
ilmExplain={ilmExplain}
patternRollup={patternRollup}
/>
<>
<LatestResults
indexName={indexName}
stats={stats}
ilmExplain={ilmExplain}
patternRollup={patternRollup}
/>
<HistoricalResultsTour
anchorSelectorValue={`${pattern}-history-tab`}
onTryIt={handleSelectHistoryTab}
isOpen={isTourActive}
onDismissTour={onDismissTour}
/>
</>
) : (
<HistoricalResults indexName={indexName} />
)}

View file

@ -30,6 +30,7 @@ export interface Props {
pattern: string;
onCheckNowAction: (indexName: string) => void;
onViewHistoryAction: (indexName: string) => void;
firstIndexName?: string;
}) => Array<EuiBasicTableColumn<IndexSummaryTableItem>>;
items: IndexSummaryTableItem[];
pageIndex: number;
@ -66,6 +67,7 @@ const SummaryTableComponent: React.FC<Props> = ({
pattern,
onCheckNowAction,
onViewHistoryAction,
firstIndexName: items[0]?.indexName,
}),
[
getTableColumns,
@ -75,6 +77,7 @@ const SummaryTableComponent: React.FC<Props> = ({
pattern,
onCheckNowAction,
onViewHistoryAction,
items,
]
);
const getItemId = useCallback((item: IndexSummaryTableItem) => item.indexName, []);

View file

@ -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<IndexSummaryTableItem>)
.actions[1] as CustomItemAction<IndexSummaryTableItem>
).render;
render(
<TestExternalProviders>
{expandActionRender != null && expandActionRender(indexSummaryTableItem, true)}
</TestExternalProviders>
);
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<IndexSummaryTableItem>)
.actions[1] as CustomItemAction<IndexSummaryTableItem>
).render;
render(
<TestExternalProviders>
{expandActionRender != null && expandActionRender(indexSummaryTableItem, true)}
</TestExternalProviders>
);
const button = screen.getByLabelText(VIEW_HISTORY);
expect(button).not.toHaveAttribute('data-tour-element');
});
});
describe('incompatible render()', () => {

View file

@ -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<EuiBasicTableColumn<IndexSummaryTableItem>> => [
{
name: i18n.ACTIONS,
@ -132,12 +135,16 @@ export const getSummaryTableColumns = ({
{
name: i18n.VIEW_HISTORY,
render: (item) => {
const isFirstIndexName = firstIndexName === item.indexName;
return (
<EuiToolTip content={i18n.VIEW_HISTORY}>
<EuiButtonIcon
iconType="clockCounter"
aria-label={i18n.VIEW_HISTORY}
onClick={() => onViewHistoryAction(item.indexName)}
{...(isFirstIndexName && {
[HISTORICAL_RESULTS_TOUR_SELECTOR_KEY]: pattern,
})}
/>
</EuiToolTip>
);

View file

@ -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: {},
};