mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Security Solution][SIEM migrations] Use cloud onboarding config (#210921)
## Summary This PR retrieves the Cloud onboarding data to pre-select the SIEM migration topic in the onboarding hub when necessary. The Cloud logic was implemented [here](https://github.com/elastic/kibana/pull/204129) <img width="1364" alt="Captura de pantalla 2025-02-13 a les 11 50 31" src="https://github.com/user-attachments/assets/28707314-da9b-439a-baa9-f6fb53c170fa" />
This commit is contained in:
parent
231733429c
commit
789986ce48
13 changed files with 427 additions and 102 deletions
|
@ -8,3 +8,20 @@
|
|||
export interface ElasticsearchConfigType {
|
||||
elasticsearch_url?: string;
|
||||
}
|
||||
|
||||
export type SolutionType = 'search' | 'elasticsearch' | 'observability' | 'security';
|
||||
export interface CloudDataAttributes {
|
||||
onboardingData: {
|
||||
solutionType?: SolutionType;
|
||||
token: string;
|
||||
security?: CloudSecurityAnswer;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CloudSecurityAnswer {
|
||||
useCase: 'siem' | 'cloud' | 'edr' | 'other';
|
||||
migration?: {
|
||||
value: boolean;
|
||||
type?: 'splunk' | 'other';
|
||||
};
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import { isDeepEqual } from 'react-use/lib/util';
|
||||
|
||||
import { Logger, SavedObjectsClientContract, SavedObjectsErrorHelpers } from '@kbn/core/server';
|
||||
import { CloudDataAttributes, CloudSecurityAnswer, SolutionType } from '../routes/types';
|
||||
import { CloudDataAttributes, CloudSecurityAnswer, SolutionType } from '../../common/types';
|
||||
import { CLOUD_DATA_SAVED_OBJECT_TYPE } from '../saved_objects';
|
||||
import { CLOUD_DATA_SAVED_OBJECT_ID } from '../routes/constants';
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import { RouteOptions } from '.';
|
||||
import { CLOUD_DATA_SAVED_OBJECT_ID } from './constants';
|
||||
import { CLOUD_DATA_SAVED_OBJECT_TYPE } from '../saved_objects';
|
||||
import { CloudDataAttributes } from './types';
|
||||
import { CloudDataAttributes } from '../../common/types';
|
||||
|
||||
export const setGetCloudSolutionDataRoute = ({ router }: RouteOptions) => {
|
||||
router.versioned
|
||||
|
|
|
@ -11,19 +11,3 @@ import { CustomRequestHandlerContext } from '@kbn/core/server';
|
|||
* @internal
|
||||
*/
|
||||
export type CloudRequestHandlerContext = CustomRequestHandlerContext<{}>;
|
||||
export type SolutionType = 'search' | 'elasticsearch' | 'observability' | 'security';
|
||||
export interface CloudDataAttributes {
|
||||
onboardingData: {
|
||||
solutionType?: SolutionType;
|
||||
token: string;
|
||||
security?: CloudSecurityAnswer;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CloudSecurityAnswer {
|
||||
useCase: 'siem' | 'cloud' | 'edr' | 'other';
|
||||
migration?: {
|
||||
value: boolean;
|
||||
type?: 'splunk' | 'other';
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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, useState } from 'react';
|
||||
import type { CloudDataAttributes } from '@kbn/cloud-plugin/common/types';
|
||||
import { useKibana } from '../../../common/lib/kibana/kibana_react';
|
||||
import { OnboardingTopicId } from '../../constants';
|
||||
|
||||
const URL = '/internal/cloud/solution';
|
||||
|
||||
interface UseCloudTopicIdParams {
|
||||
onComplete: (topicId: OnboardingTopicId | null) => void;
|
||||
}
|
||||
|
||||
export const useCloudTopicId = ({ onComplete }: UseCloudTopicIdParams) => {
|
||||
const { http } = useKibana().services;
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await http.get<CloudDataAttributes>(URL, { version: '1' });
|
||||
if (isSiemMigrationsCloudOnboarding(data)) {
|
||||
onComplete(OnboardingTopicId.siemMigrations);
|
||||
} else {
|
||||
onComplete(null);
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore the error, we will just show the default topic
|
||||
onComplete(null);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [onComplete, http]);
|
||||
|
||||
return { start, isLoading };
|
||||
};
|
||||
|
||||
const isSiemMigrationsCloudOnboarding = (data: CloudDataAttributes) => {
|
||||
const { security } = data.onboardingData ?? {};
|
||||
return (
|
||||
security?.useCase === 'siem' &&
|
||||
security?.migration?.value &&
|
||||
security?.migration?.type === 'splunk'
|
||||
);
|
||||
};
|
|
@ -46,7 +46,7 @@ export const useStoredCompletedCardIds = (spaceId: string) =>
|
|||
* Stores the selected topic ID per space
|
||||
*/
|
||||
export const useStoredUrlDetails = (spaceId: string) =>
|
||||
useDefinedLocalStorage<string | null>(`${LocalStorageKey.urlDetails}.${spaceId}`, null);
|
||||
useLocalStorage<string | null | undefined>(`${LocalStorageKey.urlDetails}.${spaceId}`);
|
||||
|
||||
/**
|
||||
* Stores the selected selectable card ID per space
|
||||
|
|
|
@ -5,11 +5,9 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { OnboardingTopicId } from '../../constants';
|
||||
import type { OnboardingRouteParams } from '../../types';
|
||||
import { useUrlDetail } from './use_url_detail';
|
||||
|
||||
/**
|
||||
* Hook that returns the topic id from the URL, or the default topic id if none is present
|
||||
|
@ -19,17 +17,3 @@ export const useTopicId = (): OnboardingTopicId => {
|
|||
const { topicId = OnboardingTopicId.default } = useParams<OnboardingRouteParams>();
|
||||
return topicId;
|
||||
};
|
||||
|
||||
export const useTopic = (): [OnboardingTopicId, (topicId: OnboardingTopicId) => void] => {
|
||||
const topicId = useTopicId();
|
||||
const { setTopicDetail } = useUrlDetail();
|
||||
|
||||
const setTopicId = useCallback(
|
||||
(newTopicId: OnboardingTopicId) => {
|
||||
setTopicDetail(newTopicId);
|
||||
},
|
||||
[setTopicDetail]
|
||||
);
|
||||
|
||||
return [topicId, setTopicId];
|
||||
};
|
||||
|
|
|
@ -0,0 +1,255 @@
|
|||
/*
|
||||
* 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 { renderHook, act } from '@testing-library/react';
|
||||
import { useUrlDetail, useSyncUrlDetails, getCardIdFromHash } from './use_url_detail';
|
||||
|
||||
// --- Mocks for dependencies ---
|
||||
jest.mock('@kbn/security-solution-navigation', () => ({
|
||||
...jest.requireActual('@kbn/security-solution-navigation'),
|
||||
useNavigateTo: jest.fn(),
|
||||
SecurityPageName: { landing: 'landing' },
|
||||
}));
|
||||
|
||||
jest.mock('./use_stored_state', () => ({
|
||||
...jest.requireActual('./use_stored_state'),
|
||||
useStoredUrlDetails: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./use_topic_id', () => ({
|
||||
...jest.requireActual('./use_topic_id'),
|
||||
useTopicId: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./use_cloud_topic_id', () => ({
|
||||
...jest.requireActual('./use_cloud_topic_id'),
|
||||
useCloudTopicId: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../onboarding_context', () => ({
|
||||
...jest.requireActual('../onboarding_context'),
|
||||
useOnboardingContext: jest.fn(),
|
||||
}));
|
||||
|
||||
// Import the mocked modules for type-checking and setting implementations
|
||||
import { useStoredUrlDetails } from './use_stored_state';
|
||||
import { useTopicId } from './use_topic_id';
|
||||
import { useCloudTopicId } from './use_cloud_topic_id';
|
||||
import { useNavigateTo, SecurityPageName } from '@kbn/security-solution-navigation';
|
||||
import { useOnboardingContext } from '../onboarding_context';
|
||||
import type { OnboardingCardId } from '../../constants';
|
||||
import { OnboardingTopicId } from '../../constants';
|
||||
|
||||
// --- Tests for useUrlDetail ---
|
||||
describe('useUrlDetail', () => {
|
||||
let mockSetStoredUrlDetail: jest.Mock;
|
||||
let mockNavigateTo: jest.Mock;
|
||||
let mockReportCardOpen: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSetStoredUrlDetail = jest.fn();
|
||||
mockNavigateTo = jest.fn();
|
||||
mockReportCardOpen = jest.fn();
|
||||
|
||||
// By default, no stored detail
|
||||
(useStoredUrlDetails as jest.Mock).mockReturnValue([null, mockSetStoredUrlDetail]);
|
||||
(useNavigateTo as jest.Mock).mockReturnValue({ navigateTo: mockNavigateTo });
|
||||
(useTopicId as jest.Mock).mockReturnValue(OnboardingTopicId.default);
|
||||
(useOnboardingContext as jest.Mock).mockReturnValue({
|
||||
spaceId: 'test-space',
|
||||
telemetry: { reportCardOpen: mockReportCardOpen },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns the expected initial values', () => {
|
||||
const { result } = renderHook(() => useUrlDetail());
|
||||
expect(result.current.topicId).toBe(OnboardingTopicId.default);
|
||||
expect(typeof result.current.setTopic).toBe('function');
|
||||
expect(typeof result.current.setCard).toBe('function');
|
||||
expect(typeof result.current.navigateToDetail).toBe('function');
|
||||
expect(result.current.storedUrlDetail).toBe(null);
|
||||
});
|
||||
|
||||
it('setTopic updates stored detail and navigates (default topic)', () => {
|
||||
const { result } = renderHook(() => useUrlDetail());
|
||||
|
||||
act(() => {
|
||||
result.current.setTopic(OnboardingTopicId.default);
|
||||
});
|
||||
|
||||
// When topic is "default", the detail is null
|
||||
expect(mockSetStoredUrlDetail).toHaveBeenCalledWith(null);
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith({
|
||||
deepLinkId: SecurityPageName.landing,
|
||||
path: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('setTopic updates stored detail and navigates (non-default topic)', () => {
|
||||
const { result } = renderHook(() => useUrlDetail());
|
||||
|
||||
act(() => {
|
||||
result.current.setTopic('customTopic' as OnboardingTopicId);
|
||||
});
|
||||
|
||||
expect(mockSetStoredUrlDetail).toHaveBeenCalledWith('customTopic');
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith({
|
||||
deepLinkId: SecurityPageName.landing,
|
||||
path: 'customTopic',
|
||||
});
|
||||
});
|
||||
|
||||
it('setCard updates the URL hash, stored detail and reports telemetry when a cardId is provided', () => {
|
||||
// Spy on history.replaceState (used in setHash)
|
||||
const replaceStateSpy = jest.spyOn(history, 'replaceState').mockImplementation(() => {});
|
||||
(useTopicId as jest.Mock).mockReturnValue(OnboardingTopicId.default);
|
||||
const { result } = renderHook(() => useUrlDetail());
|
||||
const cardId = 'card1';
|
||||
|
||||
act(() => {
|
||||
result.current.setCard(cardId as OnboardingCardId);
|
||||
});
|
||||
|
||||
// Expect the URL hash to be updated to "#card1"
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', `#${cardId}`);
|
||||
// For topic "default", getUrlDetail produces `#card1`
|
||||
expect(mockSetStoredUrlDetail).toHaveBeenCalledWith(`#${cardId}`);
|
||||
expect(mockReportCardOpen).toHaveBeenCalledWith(cardId);
|
||||
replaceStateSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('setCard updates the URL hash and stored detail without reporting telemetry when cardId is null', () => {
|
||||
const replaceStateSpy = jest.spyOn(history, 'replaceState').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useUrlDetail());
|
||||
|
||||
act(() => {
|
||||
result.current.setCard(null);
|
||||
});
|
||||
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', ' ');
|
||||
// For a null cardId, getUrlDetail returns an empty string (falsy) so stored detail becomes null
|
||||
expect(mockSetStoredUrlDetail).toHaveBeenCalledWith(null);
|
||||
expect(mockReportCardOpen).not.toHaveBeenCalled();
|
||||
replaceStateSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('navigateToDetail calls navigateTo with the correct parameters', () => {
|
||||
const { result } = renderHook(() => useUrlDetail());
|
||||
|
||||
act(() => {
|
||||
result.current.navigateToDetail('detail-path');
|
||||
});
|
||||
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith({
|
||||
deepLinkId: SecurityPageName.landing,
|
||||
path: 'detail-path',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Tests for getCardIdFromHash ---
|
||||
describe('getCardIdFromHash', () => {
|
||||
it('extracts the card id from a hash with query parameters', () => {
|
||||
const cardId = getCardIdFromHash('#card1?foo=bar');
|
||||
expect(cardId).toBe('card1');
|
||||
});
|
||||
|
||||
it('returns null if no card id is present', () => {
|
||||
const cardId = getCardIdFromHash('#?foo=bar');
|
||||
expect(cardId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Tests for useSyncUrlDetails ---
|
||||
describe('useSyncUrlDetails', () => {
|
||||
let mockSetStoredUrlDetail: jest.Mock;
|
||||
let mockNavigateTo: jest.Mock;
|
||||
let mockReportCardOpen: jest.Mock;
|
||||
let mockStartGetCloudTopicId: jest.Mock;
|
||||
let mockConfigHas: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSetStoredUrlDetail = jest.fn();
|
||||
mockNavigateTo = jest.fn();
|
||||
mockReportCardOpen = jest.fn();
|
||||
mockStartGetCloudTopicId = jest.fn();
|
||||
mockConfigHas = jest.fn().mockReturnValue(true);
|
||||
|
||||
// Provide default values for the dependencies used inside useUrlDetail
|
||||
(useStoredUrlDetails as jest.Mock).mockReturnValue([null, mockSetStoredUrlDetail]);
|
||||
(useNavigateTo as jest.Mock).mockReturnValue({ navigateTo: mockNavigateTo });
|
||||
(useTopicId as jest.Mock).mockReturnValue(OnboardingTopicId.default);
|
||||
(useCloudTopicId as jest.Mock).mockReturnValue({
|
||||
start: mockStartGetCloudTopicId,
|
||||
isLoading: false,
|
||||
});
|
||||
(useOnboardingContext as jest.Mock).mockReturnValue({
|
||||
config: { has: mockConfigHas },
|
||||
telemetry: { reportCardOpen: mockReportCardOpen },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('updates stored detail and reports telemetry when URL detail differs from stored detail', () => {
|
||||
const pathTopicId = 'customTopic' as OnboardingTopicId;
|
||||
const hashCardId = 'card1' as OnboardingCardId;
|
||||
const expectedUrlDetail = `${pathTopicId}#${hashCardId}`;
|
||||
|
||||
// Render the hook with URL detail (via path and hash)
|
||||
renderHook(() => useSyncUrlDetails({ pathTopicId, hashCardId }));
|
||||
|
||||
// useEffect should run immediately after mount:
|
||||
expect(mockReportCardOpen).toHaveBeenCalledWith(hashCardId, { auto: true });
|
||||
expect(mockSetStoredUrlDetail).toHaveBeenCalledWith(expectedUrlDetail);
|
||||
});
|
||||
|
||||
it('navigates to the stored detail when URL is empty and a stored detail exists', () => {
|
||||
// Simulate that a stored detail already exists
|
||||
(useStoredUrlDetails as jest.Mock).mockReturnValue([
|
||||
'customTopic#card1',
|
||||
mockSetStoredUrlDetail,
|
||||
]);
|
||||
|
||||
renderHook(() => useSyncUrlDetails({ pathTopicId: null, hashCardId: null }));
|
||||
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith({
|
||||
deepLinkId: SecurityPageName.landing,
|
||||
path: 'customTopic#card1',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls startGetCloudTopicId when URL is empty and stored detail is undefined', () => {
|
||||
// Simulate no stored detail (undefined) – e.g. first time onboarding
|
||||
(useStoredUrlDetails as jest.Mock).mockReturnValue([undefined, mockSetStoredUrlDetail]);
|
||||
|
||||
renderHook(() => useSyncUrlDetails({ pathTopicId: null, hashCardId: null }));
|
||||
|
||||
expect(mockStartGetCloudTopicId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears stored detail if the stored topic is invalid', () => {
|
||||
// Simulate a stored detail with an invalid topic
|
||||
(useStoredUrlDetails as jest.Mock).mockReturnValue([
|
||||
'invalidTopic#card1',
|
||||
mockSetStoredUrlDetail,
|
||||
]);
|
||||
// Simulate config.has returning false for an invalid topic
|
||||
mockConfigHas.mockReturnValue(false);
|
||||
|
||||
renderHook(() => useSyncUrlDetails({ pathTopicId: null, hashCardId: null }));
|
||||
|
||||
expect(mockSetStoredUrlDetail).toHaveBeenCalledWith(null);
|
||||
// In this case, navigation should not be triggered
|
||||
expect(mockNavigateTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -5,12 +5,13 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { SecurityPageName, useNavigateTo } from '@kbn/security-solution-navigation';
|
||||
import { useStoredUrlDetails } from './use_stored_state';
|
||||
import { OnboardingTopicId, type OnboardingCardId } from '../../constants';
|
||||
import { useOnboardingContext } from '../onboarding_context';
|
||||
import { useTopicId } from './use_topic_id';
|
||||
import { useCloudTopicId } from './use_cloud_topic_id';
|
||||
|
||||
export const getCardIdFromHash = (hash: string): OnboardingCardId | null =>
|
||||
(hash.split('?')[0].replace('#', '') as OnboardingCardId) || null;
|
||||
|
@ -19,34 +20,41 @@ const setHash = (cardId: OnboardingCardId | null) => {
|
|||
history.replaceState(null, '', cardId == null ? ' ' : `#${cardId}`);
|
||||
};
|
||||
|
||||
const getTopicPath = (topicId: OnboardingTopicId) =>
|
||||
topicId !== OnboardingTopicId.default ? topicId : '';
|
||||
|
||||
const getCardHash = (cardId: OnboardingCardId | null) => (cardId ? `#${cardId}` : '');
|
||||
const getUrlDetail = (topicId: OnboardingTopicId, cardId: OnboardingCardId | null): string => {
|
||||
return `${topicId !== OnboardingTopicId.default ? topicId : ''}${cardId ? `#${cardId}` : ''}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* This hook manages the expanded card id state in the LocalStorage and the hash in the URL.
|
||||
* The "urlDetail" is the combination of the topicId as the path fragment followed cardId in the hash (#) parameter, in the URL
|
||||
*/
|
||||
export const useUrlDetail = () => {
|
||||
const { config, spaceId, telemetry } = useOnboardingContext();
|
||||
const { spaceId, telemetry } = useOnboardingContext();
|
||||
const topicId = useTopicId();
|
||||
const [storedUrlDetail, setStoredUrlDetail] = useStoredUrlDetails(spaceId);
|
||||
|
||||
const { navigateTo } = useNavigateTo();
|
||||
|
||||
const setTopicDetail = useCallback(
|
||||
(newTopicId: OnboardingTopicId) => {
|
||||
const path = newTopicId === OnboardingTopicId.default ? undefined : newTopicId;
|
||||
setStoredUrlDetail(path ?? null);
|
||||
navigateTo({ deepLinkId: SecurityPageName.landing, path });
|
||||
const navigateToDetail = useCallback(
|
||||
(detail?: string | null) => {
|
||||
navigateTo({ deepLinkId: SecurityPageName.landing, path: detail || undefined });
|
||||
},
|
||||
[setStoredUrlDetail, navigateTo]
|
||||
[navigateTo]
|
||||
);
|
||||
|
||||
const setCardDetail = useCallback(
|
||||
const setTopic = useCallback(
|
||||
(newTopicId: OnboardingTopicId) => {
|
||||
const detail = newTopicId === OnboardingTopicId.default ? null : newTopicId;
|
||||
setStoredUrlDetail(detail);
|
||||
navigateToDetail(detail);
|
||||
},
|
||||
[setStoredUrlDetail, navigateToDetail]
|
||||
);
|
||||
|
||||
const setCard = useCallback(
|
||||
(newCardId: OnboardingCardId | null) => {
|
||||
setHash(newCardId);
|
||||
setStoredUrlDetail(`${getTopicPath(topicId)}${getCardHash(newCardId)}` || null);
|
||||
setStoredUrlDetail(getUrlDetail(topicId, newCardId) || null);
|
||||
if (newCardId != null) {
|
||||
telemetry.reportCardOpen(newCardId);
|
||||
}
|
||||
|
@ -54,29 +62,59 @@ export const useUrlDetail = () => {
|
|||
[setStoredUrlDetail, topicId, telemetry]
|
||||
);
|
||||
|
||||
const syncUrlDetails = useCallback(
|
||||
(pathTopicId: OnboardingTopicId | null, hashCardId: OnboardingCardId | null) => {
|
||||
if (storedUrlDetail) {
|
||||
// If the stored topic is not valid, clear it
|
||||
const [storedTopicId] = storedUrlDetail.split('#');
|
||||
if (storedTopicId && !config.has(storedTopicId as OnboardingTopicId)) {
|
||||
setStoredUrlDetail(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const urlDetail = `${pathTopicId || ''}${hashCardId ? `#${hashCardId}` : ''}`;
|
||||
if (urlDetail && urlDetail !== storedUrlDetail) {
|
||||
if (hashCardId) {
|
||||
telemetry.reportCardOpen(hashCardId, { auto: true });
|
||||
}
|
||||
setStoredUrlDetail(urlDetail);
|
||||
}
|
||||
if (!urlDetail && storedUrlDetail) {
|
||||
navigateTo({ deepLinkId: SecurityPageName.landing, path: storedUrlDetail });
|
||||
}
|
||||
},
|
||||
[config, navigateTo, setStoredUrlDetail, storedUrlDetail, telemetry]
|
||||
);
|
||||
|
||||
return { setTopicDetail, setCardDetail, syncUrlDetails };
|
||||
return { topicId, setTopic, setCard, navigateToDetail, storedUrlDetail, setStoredUrlDetail };
|
||||
};
|
||||
|
||||
interface UseSyncUrlDetailsParams {
|
||||
pathTopicId: OnboardingTopicId | null;
|
||||
hashCardId: OnboardingCardId | null;
|
||||
}
|
||||
/**
|
||||
* This hook manages the expanded card id state in the LocalStorage and the hash in the URL.
|
||||
*/
|
||||
export const useSyncUrlDetails = ({ pathTopicId, hashCardId }: UseSyncUrlDetailsParams) => {
|
||||
const { config, telemetry } = useOnboardingContext();
|
||||
const { storedUrlDetail, setStoredUrlDetail, navigateToDetail, setTopic } = useUrlDetail();
|
||||
|
||||
const onComplete = useCallback((cloudTopicId: OnboardingTopicId | null) => {
|
||||
if (cloudTopicId && config.has(cloudTopicId)) {
|
||||
setTopic(cloudTopicId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const { start: startGetCloudTopicId, isLoading } = useCloudTopicId({ onComplete });
|
||||
|
||||
useEffect(() => {
|
||||
// Create the URL detail
|
||||
const urlDetail = `${pathTopicId || ''}${hashCardId ? `#${hashCardId}` : ''}`;
|
||||
|
||||
// If the URL has a topic it has prevalence, we need to set it to the local storage
|
||||
if (urlDetail && urlDetail !== storedUrlDetail) {
|
||||
if (hashCardId) {
|
||||
telemetry.reportCardOpen(hashCardId, { auto: true });
|
||||
}
|
||||
setStoredUrlDetail(urlDetail);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the URL has no topic, but the local storage has a topic, we need to navigate to the topic
|
||||
if (!urlDetail && storedUrlDetail) {
|
||||
// Check if the stored topic is not valid, if so clear it to prevent inconsistencies
|
||||
const [storedTopicId] = storedUrlDetail.split('#');
|
||||
if (storedTopicId && !config.has(storedTopicId as OnboardingTopicId)) {
|
||||
setStoredUrlDetail(null);
|
||||
return;
|
||||
}
|
||||
navigateToDetail(storedUrlDetail);
|
||||
}
|
||||
|
||||
// If nothing is stored and nothing is in the URL, let's see if we have a cloud topic (first time onboarding)
|
||||
if (!urlDetail && storedUrlDetail === undefined) {
|
||||
startGetCloudTopicId();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return { isLoading };
|
||||
};
|
||||
|
|
|
@ -9,10 +9,10 @@ import { useExpandedCard } from './use_expanded_card';
|
|||
import type { OnboardingCardId } from '../../../constants';
|
||||
import { waitFor, renderHook, act } from '@testing-library/react';
|
||||
|
||||
const mockSetCardDetail = jest.fn();
|
||||
const mockSetCard = jest.fn();
|
||||
jest.mock('../../hooks/use_url_detail', () => ({
|
||||
...jest.requireActual('../../hooks/use_url_detail'),
|
||||
useUrlDetail: () => ({ setCardDetail: mockSetCardDetail }),
|
||||
useUrlDetail: () => ({ setCard: mockSetCard }),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
|
@ -67,7 +67,7 @@ describe('useExpandedCard Hook', () => {
|
|||
});
|
||||
|
||||
it('should set the expanded card id', () => {
|
||||
expect(mockSetCardDetail).toHaveBeenCalledWith(mockCardId);
|
||||
expect(mockSetCard).toHaveBeenCalledWith(mockCardId);
|
||||
});
|
||||
|
||||
it('should not scroll', async () => {
|
||||
|
@ -88,7 +88,7 @@ describe('useExpandedCard Hook', () => {
|
|||
});
|
||||
|
||||
it('should set the expanded card id', () => {
|
||||
expect(mockSetCardDetail).toHaveBeenCalledWith(mockCardId);
|
||||
expect(mockSetCard).toHaveBeenCalledWith(mockCardId);
|
||||
});
|
||||
|
||||
it('should scroll', async () => {
|
||||
|
|
|
@ -28,16 +28,16 @@ const scrollToCard = (cardId: OnboardingCardId) => {
|
|||
* This hook manages the expanded card id state in the LocalStorage and the hash in the URL.
|
||||
*/
|
||||
export const useExpandedCard = () => {
|
||||
const { setCardDetail } = useUrlDetail();
|
||||
const { setCard } = useUrlDetail();
|
||||
const { hash } = useLocation();
|
||||
const cardIdFromHash = useMemo(() => getCardIdFromHash(hash), [hash]);
|
||||
|
||||
const [cardId, setCardId] = useState<OnboardingCardId | null>(null);
|
||||
const [expandedCardId, _setExpandedCardId] = useState<OnboardingCardId | null>(null);
|
||||
|
||||
// This effect implements auto-scroll in the initial render.
|
||||
useEffect(() => {
|
||||
if (cardIdFromHash) {
|
||||
setCardId(cardIdFromHash);
|
||||
_setExpandedCardId(cardIdFromHash);
|
||||
scrollToCard(cardIdFromHash);
|
||||
}
|
||||
// cardIdFromHash is only defined once on page load
|
||||
|
@ -46,14 +46,14 @@ export const useExpandedCard = () => {
|
|||
|
||||
const setExpandedCardId = useCallback<SetExpandedCardId>(
|
||||
(newCardId, options) => {
|
||||
setCardId(newCardId);
|
||||
setCardDetail(newCardId);
|
||||
_setExpandedCardId(newCardId);
|
||||
setCard(newCardId);
|
||||
if (newCardId != null && options?.scroll) {
|
||||
scrollToCard(newCardId);
|
||||
}
|
||||
},
|
||||
[setCardDetail]
|
||||
[setCard]
|
||||
);
|
||||
|
||||
return { expandedCardId: cardId, setExpandedCardId };
|
||||
return { expandedCardId, setExpandedCardId };
|
||||
};
|
||||
|
|
|
@ -9,9 +9,9 @@ import React, { useMemo } from 'react';
|
|||
import { EuiButtonGroup } from '@elastic/eui';
|
||||
import { OnboardingTopicId } from '../../constants';
|
||||
import { useOnboardingContext } from '../onboarding_context';
|
||||
import { useTopic } from '../hooks/use_topic_id';
|
||||
import type { TopicConfig } from '../../types';
|
||||
import { SiemMigrationSetupTour } from '../../../siem_migrations/rules/components/tours/setup_guide';
|
||||
import { useUrlDetail } from '../hooks/use_url_detail';
|
||||
|
||||
const getLabel = (topicConfig: TopicConfig) => {
|
||||
if (topicConfig.id === OnboardingTopicId.siemMigrations) {
|
||||
|
@ -26,7 +26,7 @@ const getLabel = (topicConfig: TopicConfig) => {
|
|||
|
||||
export const OnboardingHeaderTopicSelector = React.memo(() => {
|
||||
const { config } = useOnboardingContext();
|
||||
const [topicId, setTopicId] = useTopic();
|
||||
const { topicId, setTopic } = useUrlDetail();
|
||||
|
||||
const selectorOptions = useMemo(
|
||||
() =>
|
||||
|
@ -49,7 +49,7 @@ export const OnboardingHeaderTopicSelector = React.memo(() => {
|
|||
legend="Topic selector"
|
||||
options={selectorOptions}
|
||||
idSelected={topicId}
|
||||
onChange={(id) => setTopicId(id as OnboardingTopicId)}
|
||||
onChange={(id) => setTopic(id as OnboardingTopicId)}
|
||||
isFullWidth
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import type { RouteComponentProps } from 'react-router-dom';
|
||||
import { Routes, Route } from '@kbn/shared-ux-router';
|
||||
|
@ -13,10 +13,11 @@ import { Redirect } from 'react-router-dom';
|
|||
import { ONBOARDING_PATH } from '../../../common/constants';
|
||||
import type { OnboardingRouteParams } from '../types';
|
||||
import { OnboardingTopicId } from '../constants';
|
||||
import { getCardIdFromHash, useUrlDetail } from './hooks/use_url_detail';
|
||||
import { getCardIdFromHash, useSyncUrlDetails } from './hooks/use_url_detail';
|
||||
import { useOnboardingContext } from './onboarding_context';
|
||||
import { OnboardingHeader } from './onboarding_header';
|
||||
import { OnboardingBody } from './onboarding_body';
|
||||
import { CenteredLoadingSpinner } from '../../common/components/centered_loading_spinner';
|
||||
|
||||
export const OnboardingRouter = React.memo(() => {
|
||||
const { config } = useOnboardingContext();
|
||||
|
@ -24,7 +25,7 @@ export const OnboardingRouter = React.memo(() => {
|
|||
const topicPathParam = useMemo(() => {
|
||||
const availableTopics = [...config.values()]
|
||||
.map(({ id }) => id) // available topic ids
|
||||
.filter((val) => val !== OnboardingTopicId.default) // except "default"
|
||||
.filter((id) => id !== OnboardingTopicId.default) // except "default"
|
||||
.join('|');
|
||||
if (availableTopics) {
|
||||
return `/:topicId(${availableTopics})?`; // optional parameter}
|
||||
|
@ -43,18 +44,15 @@ OnboardingRouter.displayName = 'OnboardingRouter';
|
|||
|
||||
type OnboardingRouteProps = RouteComponentProps<OnboardingRouteParams>;
|
||||
|
||||
const OnboardingRoute = React.memo<OnboardingRouteProps>(({ match, location }) => {
|
||||
const { syncUrlDetails } = useUrlDetail();
|
||||
const OnboardingRoute = React.memo<OnboardingRouteProps>(({ match: { params }, location }) => {
|
||||
const { isLoading } = useSyncUrlDetails({
|
||||
pathTopicId: params.topicId || null,
|
||||
hashCardId: getCardIdFromHash(location.hash),
|
||||
});
|
||||
|
||||
/**
|
||||
* This effect syncs the URL details with the stored state, it only needs to be executed once per page load.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const pathTopicId = match.params.topicId || null;
|
||||
const hashCardId = getCardIdFromHash(location.hash);
|
||||
syncUrlDetails(pathTopicId, hashCardId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
if (isLoading) {
|
||||
return <CenteredLoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue