[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:
Sergi Massaneda 2025-02-13 19:28:22 +01:00 committed by GitHub
parent 231733429c
commit 789986ce48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 427 additions and 102 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
/>
);

View file

@ -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 (
<>