From 6cdb0ea62b5ddd869260f31ee7aa38fbfa964677 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Tue, 23 Apr 2024 02:50:17 -0400 Subject: [PATCH] [Observability Onboarding] Add link back to onboarding from integrations (#181195) # Summary Resolves #180824. ## Fleet Changes It was noted that, when linking to the Integrations page from Onboarding, the `Back to integrations` link will navigate the user to the main Integrations search page within the Fleet UI. In cases where we direct the user to an integration's page from Observability Onboarding, this completely breaks the flow. The goal of this PR is to introduce, as transparently as possible, some special functionality to pick up a link as a query param and replace the standard back link with the custom one when it is present. This also includes a copy change, per the linked issue. ### Card CSS change As a side note, this adds some custom CSS to the `PackageCard` component. This is because we added this notion of `Collections` to the cards, but the `footer` prop is not available when the cards are in `horizontal` mode. I spoke to EUI about this and it is possible this will become a standard convention in the future. My original intent was to include this custom CSS conditionally, but because ReactWindow is somewhat rigid with conditionally-applied styles it seemed to only work when the CSS was applied to all items. #### Looks ok when card content is uniform image #### Only looks like this when the custom CSS is applied image ## Onboarding Changes There's a new query param, `search`, that will update with the changes the user makes to the search query for the complete integrations list. This and the `category` param are included in the link when they're defined, so when the user navigates to the integration's page, if they click the link back the original state of the page will repopulate. ## Testing The original functionality of using integrations from the Fleet UI should remain completely unchanged. Things to check: 1. Integration cards render in the exact same way as they did before, or with acceptable differences WRT the flex usage. In my testing, I didn't notice any perceptible difference, but I likely did not cover all cases of card rendering 1. Links back to the integrations UI continue to work the same as before 1. Links from Onboarding to Integrations will preserve state and cause the back link to say "Back to selection" instead of "Back to integrations" ## Demo GIFs ### Onboarding Flow ![20240418135239](https://github.com/elastic/kibana/assets/18429259/4e8a37c8-b5d4-43d0-8602-751658de71a7) ### Integrations Flow ![20240418135536](https://github.com/elastic/kibana/assets/18429259/0dac4cc3-6c5f-435d-83d3-4111763ee075) --------- Co-authored-by: Joe Reuter --- .../sections/epm/components/package_card.tsx | 13 ++++ .../detail/components/back_link.test.tsx | 44 +++++++++++++ .../screens/detail/components/back_link.tsx | 45 +++++++++++++ .../epm/screens/detail/components/index.tsx | 1 + .../sections/epm/screens/detail/index.tsx | 11 +--- .../onboarding_flow_form.tsx | 33 ++++++++-- .../application/packages_list/index.tsx | 6 ++ .../use_integration_card_list.test.ts | 65 +++++++++++++++++++ .../use_integration_card_list.ts | 56 ++++++++++++++-- .../observability_onboarding/public/common.ts | 8 +++ .../observability_onboarding/public/plugin.ts | 3 +- 11 files changed, 267 insertions(+), 18 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.test.tsx create mode 100644 x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.tsx create mode 100644 x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/use_integration_card_list.test.ts create mode 100644 x-pack/plugins/observability_solution/observability_onboarding/public/common.ts diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx index bf03648f442c9..474ffe2e4db70 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_card.tsx @@ -16,6 +16,7 @@ import { EuiSpacer, EuiToolTip, } from '@elastic/eui'; +import { css } from '@emotion/react'; import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; @@ -175,6 +176,18 @@ export function PackageCard({ > { + it('renders back to selection link', () => { + const expectedUrl = '/app/experimental-onboarding'; + const queryParams = new URLSearchParams(); + queryParams.set('observabilityOnboardingLink', expectedUrl); + const { getByText, getByRole } = render( + + ); + expect(getByText('Back to selection')).toBeInTheDocument(); + expect(getByRole('link').getAttribute('href')).toBe(expectedUrl); + }); + + it('renders back to selection link with params', () => { + const expectedUrl = '/app/experimental-onboarding&search=aws&category=infra'; + const queryParams = new URLSearchParams(); + queryParams.set('observabilityOnboardingLink', expectedUrl); + const { getByText, getByRole } = render( + + ); + expect(getByText('Back to selection')).toBeInTheDocument(); + expect(getByRole('link').getAttribute('href')).toBe(expectedUrl); + }); + + it('renders back to integrations link', () => { + const queryParams = new URLSearchParams(); + const { getByText, getByRole } = render( + + ); + expect(getByText('Back to integrations')).toBeInTheDocument(); + expect(getByRole('link').getAttribute('href')).toBe('/app/integrations'); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.tsx new file mode 100644 index 0000000000000..081b78de8ec51 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/back_link.tsx @@ -0,0 +1,45 @@ +/* + * 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 { EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useMemo } from 'react'; + +interface Props { + queryParams: URLSearchParams; + href: string; +} + +export function BackLink({ queryParams, href: integrationsHref }: Props) { + const { onboardingLink } = useMemo(() => { + return { + onboardingLink: queryParams.get('observabilityOnboardingLink'), + }; + }, [queryParams]); + const href = onboardingLink ?? integrationsHref; + const message = onboardingLink ? BACK_TO_SELECTION : BACK_TO_INTEGRATIONS; + + return ( + + {message} + + ); +} + +const BACK_TO_INTEGRATIONS = ( + +); + +const BACK_TO_SELECTION = ( + +); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/index.tsx index c0438bf6dfe8d..4aa1a543897c9 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/index.tsx @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +export { BackLink } from './back_link'; export { AddIntegrationButton } from './add_integration_button'; export { UpdateIcon } from './update_icon'; export { IntegrationAgentPolicyCount } from './integration_agent_policy_count'; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index 54ca058865fdf..0a2fc69a69366 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -12,7 +12,6 @@ import { Routes, Route } from '@kbn/shared-ux-router'; import styled from 'styled-components'; import { EuiBadge, - EuiButtonEmpty, EuiCallOut, EuiDescriptionList, EuiDescriptionListDescription, @@ -71,6 +70,7 @@ import { DeferredAssetsWarning } from './assets/deferred_assets_warning'; import { useIsFirstTimeAgentUserQuery } from './hooks'; import { getInstallPkgRouteOptions } from './utils'; import { + BackLink, IntegrationAgentPolicyCount, UpdateIcon, IconPanel, @@ -314,12 +314,7 @@ export function Detail() { {/* Allows button to break out of full width */}
- - - +
@@ -366,7 +361,7 @@ export function Detail() { ), - [integrationInfo, isLoading, packageInfo, href] + [integrationInfo, isLoading, packageInfo, href, queryParams] ); const handleAddIntegrationPolicyClick = useCallback( diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/onboarding_flow_form.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/onboarding_flow_form.tsx index 6a1a062e5a7e1..e5977ff0172c7 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/onboarding_flow_form.tsx +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/onboarding_flow_form.tsx @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import type { FunctionComponent } from 'react'; import { @@ -81,8 +81,21 @@ export const OnboardingFlowForm: FunctionComponent = () => { const radioGroupId = useGeneratedHtmlId({ prefix: 'onboardingCategory' }); const [searchParams, setSearchParams] = useSearchParams(); + const packageListRef = React.useRef(null); - const [integrationSearch, setIntegrationSearch] = useState(''); + const [integrationSearch, setIntegrationSearch] = useState(searchParams.get('search') ?? ''); + + useEffect(() => { + const searchParam = searchParams.get('search') ?? ''; + if (integrationSearch === searchParam) return; + const entries: Record = Object.fromEntries(searchParams.entries()); + if (integrationSearch) { + entries.search = integrationSearch; + } else { + delete entries.search; + } + setSearchParams(entries, { replace: true }); + }, [integrationSearch, searchParams, setSearchParams]); const createCollectionCardHandler = useCallback( (query: string) => () => { @@ -97,7 +110,7 @@ export const OnboardingFlowForm: FunctionComponent = () => { ); } }, - [setIntegrationSearch] + [] ); const customCards = useCustomCardsForCategory( @@ -153,7 +166,13 @@ export const OnboardingFlowForm: FunctionComponent = () => { /> - {Array.isArray(customCards) && } + {Array.isArray(customCards) && ( + + )} { type === 'generated')} + customCards={customCards?.filter( + (card) => card.type === 'generated' && !card.isCollectionCard + )} joinCardLists /> diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/index.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/index.tsx index 85627f9411bf3..135e4650ccc09 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/index.tsx +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/index.tsx @@ -32,6 +32,8 @@ interface Props { packageListRef?: React.Ref; searchQuery?: string; setSearchQuery?: React.Dispatch>; + flowCategory?: string | null; + flowSearch?: string; /** * When enabled, custom and integration cards are joined into a single list. */ @@ -52,6 +54,8 @@ const PackageListGridWrapper = ({ searchQuery, setSearchQuery, customCards, + flowCategory, + flowSearch, joinCardLists = false, }: WrapperProps) => { const customMargin = useCustomMargin(); @@ -63,6 +67,8 @@ const PackageListGridWrapper = ({ filteredCards, selectedCategory, customCards, + flowCategory, + flowSearch, joinCardLists ); diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/use_integration_card_list.test.ts b/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/use_integration_card_list.test.ts new file mode 100644 index 0000000000000..076f4585e2667 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/use_integration_card_list.test.ts @@ -0,0 +1,65 @@ +/* + * 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 { addPathParamToUrl, toOnboardingPath } from './use_integration_card_list'; + +describe('useIntegratrionCardList', () => { + describe('toOnboardingPath', () => { + it('returns null if no `basePath` is defined', () => { + expect(toOnboardingPath({})).toBeNull(); + }); + it('returns just the `basePath` if no category or search is defined', () => { + expect(toOnboardingPath({ basePath: '' })).toBe('/app/experimental-onboarding'); + expect(toOnboardingPath({ basePath: '/s/custom_space_name' })).toBe( + '/s/custom_space_name/app/experimental-onboarding' + ); + }); + it('includes category in the URL', () => { + expect(toOnboardingPath({ basePath: '/s/custom_space_name', category: 'logs' })).toBe( + '/s/custom_space_name/app/experimental-onboarding?category=logs' + ); + expect(toOnboardingPath({ basePath: '', category: 'infra' })).toBe( + '/app/experimental-onboarding?category=infra' + ); + }); + it('includes search in the URL', () => { + expect(toOnboardingPath({ basePath: '/s/custom_space_name', search: 'search' })).toBe( + '/s/custom_space_name/app/experimental-onboarding?search=search' + ); + }); + it('includes category and search in the URL', () => { + expect( + toOnboardingPath({ basePath: '/s/custom_space_name', category: 'logs', search: 'search' }) + ).toBe('/s/custom_space_name/app/experimental-onboarding?category=logs&search=search'); + expect(toOnboardingPath({ basePath: '', category: 'infra', search: 'search' })).toBe( + '/app/experimental-onboarding?category=infra&search=search' + ); + }); + }); + describe('addPathParamToUrl', () => { + it('adds the onboarding link to url with existing params', () => { + expect( + addPathParamToUrl( + '/app/integrations?query-1', + '/app/experimental-onboarding?search=aws&category=infra' + ) + ).toBe( + '/app/integrations?query-1&observabilityOnboardingLink=%2Fapp%2Fexperimental-onboarding%3Fsearch%3Daws%26category%3Dinfra' + ); + }); + it('adds the onboarding link to url without existing params', () => { + expect( + addPathParamToUrl( + '/app/integrations', + '/app/experimental-onboarding?search=aws&category=infra' + ) + ).toBe( + '/app/integrations?observabilityOnboardingLink=%2Fapp%2Fexperimental-onboarding%3Fsearch%3Daws%26category%3Dinfra' + ); + }); + }); +}); diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/use_integration_card_list.ts b/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/use_integration_card_list.ts index 8d5a275a4523b..4d52856ce5551 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/use_integration_card_list.ts +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/use_integration_card_list.ts @@ -7,9 +7,50 @@ import { useMemo } from 'react'; import { IntegrationCardItem } from '@kbn/fleet-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; import { CustomCard } from './types'; +import { EXPERIMENTAL_ONBOARDING_APP_ROUTE } from '../../common'; import { toCustomCard } from './utils'; +export function toOnboardingPath({ + basePath, + category, + search, +}: { + basePath?: string; + category?: string | null; + search?: string; +}): string | null { + if (typeof basePath !== 'string' && !basePath) return null; + const path = `${basePath}${EXPERIMENTAL_ONBOARDING_APP_ROUTE}`; + if (!category && !search) return path; + const params = new URLSearchParams(); + if (category) params.append('category', category); + if (search) params.append('search', search); + return `${path}?${params.toString()}`; +} + +export function addPathParamToUrl(url: string, onboardingLink: string) { + const encoded = encodeURIComponent(onboardingLink); + if (url.indexOf('?') >= 0) { + return `${url}&observabilityOnboardingLink=${encoded}`; + } + return `${url}?observabilityOnboardingLink=${encoded}`; +} + +function useCardUrlRewrite(props: { category?: string | null; search?: string }) { + const kibana = useKibana(); + const basePath = kibana.services.http?.basePath.get(); + const onboardingLink = useMemo(() => toOnboardingPath({ basePath, ...props }), [basePath, props]); + return (card: IntegrationCardItem) => ({ + ...card, + url: + card.url.indexOf('/app/integrations') >= 0 && onboardingLink + ? addPathParamToUrl(card.url, onboardingLink) + : card.url, + }); +} + function extractFeaturedCards(filteredCards: IntegrationCardItem[], featuredCardNames?: string[]) { const featuredCards: Record = {}; filteredCards.forEach((card) => { @@ -21,21 +62,23 @@ function extractFeaturedCards(filteredCards: IntegrationCardItem[], featuredCard } function formatCustomCards( + rewriteUrl: (card: IntegrationCardItem) => IntegrationCardItem, customCards: CustomCard[], featuredCards: Record ) { const cards: IntegrationCardItem[] = []; for (const card of customCards) { if (card.type === 'featured' && !!featuredCards[card.name]) { - cards.push(toCustomCard(featuredCards[card.name]!)); + cards.push(toCustomCard(rewriteUrl(featuredCards[card.name]!))); } else if (card.type === 'generated') { - cards.push(toCustomCard(card)); + cards.push(toCustomCard(rewriteUrl(card))); } } return cards; } function useFilteredCards( + rewriteUrl: (card: IntegrationCardItem) => IntegrationCardItem, integrationsList: IntegrationCardItem[], selectedCategory: string, customCards?: CustomCard[] @@ -43,6 +86,7 @@ function useFilteredCards( return useMemo(() => { const integrationCards = integrationsList .filter((card) => card.categories.includes(selectedCategory)) + .map(rewriteUrl) .map(toCustomCard); if (!customCards) { @@ -56,7 +100,7 @@ function useFilteredCards( ), integrationCards, }; - }, [integrationsList, customCards, selectedCategory]); + }, [integrationsList, customCards, selectedCategory, rewriteUrl]); } /** @@ -71,16 +115,20 @@ export function useIntegrationCardList( integrationsList: IntegrationCardItem[], selectedCategory = 'observability', customCards?: CustomCard[], + flowCategory?: string | null, + flowSearch?: string, fullList = false ): IntegrationCardItem[] { + const rewriteUrl = useCardUrlRewrite({ category: flowCategory, search: flowSearch }); const { featuredCards, integrationCards } = useFilteredCards( + rewriteUrl, integrationsList, selectedCategory, customCards ); if (customCards && customCards.length > 0) { - const formattedCustomCards = formatCustomCards(customCards, featuredCards); + const formattedCustomCards = formatCustomCards(rewriteUrl, customCards, featuredCards); if (fullList) { return [...formattedCustomCards, ...integrationCards] as IntegrationCardItem[]; } diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/common.ts b/x-pack/plugins/observability_solution/observability_onboarding/public/common.ts new file mode 100644 index 0000000000000..110aeeebcd1c6 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/common.ts @@ -0,0 +1,8 @@ +/* + * 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 EXPERIMENTAL_ONBOARDING_APP_ROUTE = '/app/experimental-onboarding'; diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/plugin.ts b/x-pack/plugins/observability_solution/observability_onboarding/public/plugin.ts index c83c26e3bf486..fd17f18085331 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/plugin.ts +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/plugin.ts @@ -27,6 +27,7 @@ import { ObservabilityOnboardingLocatorDefinition } from './locators/onboarding_ import { ObservabilityOnboardingPluginLocators } from './locators'; import { ConfigSchema } from '.'; import { OBSERVABILITY_ONBOARDING_TELEMETRY_EVENT } from '../common/telemetry_events'; +import { EXPERIMENTAL_ONBOARDING_APP_ROUTE } from './common'; export type ObservabilityOnboardingPluginSetup = void; export type ObservabilityOnboardingPluginStart = void; @@ -115,7 +116,7 @@ export class ObservabilityOnboardingPlugin core.application.register({ id: `${PLUGIN_ID}_EXPERIMENTAL`, title: 'Observability Onboarding (Beta)', - appRoute: '/app/experimental-onboarding', + appRoute: EXPERIMENTAL_ONBOARDING_APP_ROUTE, order: 8500, euiIconType: 'logoObservability', category: DEFAULT_APP_CATEGORIES.observability,