Skip to content

Commit

Permalink
[Observability Onboarding] Add link back to onboarding from integrati…
Browse files Browse the repository at this point in the history
…ons (elastic#181195)

# Summary

Resolves elastic#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
<img width="1160" alt="image"
src="https://github.com/elastic/kibana/assets/18429259/b289fe19-2673-4e10-a5a5-01d805d29a7a">

#### Only looks like this when the custom CSS is applied
<img width="828" alt="image"
src="https://github.com/elastic/kibana/assets/18429259/eae5b78e-00a2-4fe1-93e7-0cdf73bc88f6">



## 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 <[email protected]>
  • Loading branch information
justinkambic and flash1293 authored Apr 23, 2024
1 parent a902bac commit 6cdb0ea
Show file tree
Hide file tree
Showing 11 changed files with 267 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
EuiSpacer,
EuiToolTip,
} from '@elastic/eui';
import { css } from '@emotion/react';

import { TrackApplicationView } from '@kbn/usage-collection-plugin/public';

Expand Down Expand Up @@ -175,6 +176,18 @@ export function PackageCard({
>
<TrackApplicationView viewId={testid}>
<Card
// EUI TODO: Custom component CSS
css={css`
[class*='euiCard__content'] {
display: flex;
flex-direction: column;
block-size: 100%;
}
[class*='euiCard__description'] {
flex-grow: 1;
}
`}
data-test-subj={testid}
isquickstart={isQuickstart}
betaBadgeProps={quickstartBadge(isQuickstart)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';

import { BackLink } from './back_link';

describe('BackLink', () => {
it('renders back to selection link', () => {
const expectedUrl = '/app/experimental-onboarding';
const queryParams = new URLSearchParams();
queryParams.set('observabilityOnboardingLink', expectedUrl);
const { getByText, getByRole } = render(
<BackLink queryParams={queryParams} href="/app/integrations" />
);
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(
<BackLink queryParams={queryParams} href="/app/integrations" />
);
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(
<BackLink queryParams={queryParams} href="/app/integrations" />
);
expect(getByText('Back to integrations')).toBeInTheDocument();
expect(getByRole('link').getAttribute('href')).toBe('/app/integrations');
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<EuiButtonEmpty iconType="arrowLeft" size="xs" flush="left" href={href}>
{message}
</EuiButtonEmpty>
);
}

const BACK_TO_INTEGRATIONS = (
<FormattedMessage
id="xpack.fleet.epm.browseAllButtonText"
defaultMessage="Back to integrations"
/>
);

const BACK_TO_SELECTION = (
<FormattedMessage
id="xpack.fleet.epm.returnToObservabilityOnboarding"
defaultMessage="Back to selection"
/>
);
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { Routes, Route } from '@kbn/shared-ux-router';
import styled from 'styled-components';
import {
EuiBadge,
EuiButtonEmpty,
EuiCallOut,
EuiDescriptionList,
EuiDescriptionListDescription,
Expand Down Expand Up @@ -71,6 +70,7 @@ import { DeferredAssetsWarning } from './assets/deferred_assets_warning';
import { useIsFirstTimeAgentUserQuery } from './hooks';
import { getInstallPkgRouteOptions } from './utils';
import {
BackLink,
IntegrationAgentPolicyCount,
UpdateIcon,
IconPanel,
Expand Down Expand Up @@ -314,12 +314,7 @@ export function Detail() {
<EuiFlexItem>
{/* Allows button to break out of full width */}
<div>
<EuiButtonEmpty iconType="arrowLeft" size="xs" flush="left" href={href}>
<FormattedMessage
id="xpack.fleet.epm.browseAllButtonText"
defaultMessage="Back to integrations"
/>
</EuiButtonEmpty>
<BackLink queryParams={queryParams} href={href} />
</div>
</EuiFlexItem>
<EuiFlexItem>
Expand Down Expand Up @@ -366,7 +361,7 @@ export function Detail() {
</EuiFlexItem>
</EuiFlexGroup>
),
[integrationInfo, isLoading, packageInfo, href]
[integrationInfo, isLoading, packageInfo, href, queryParams]
);

const handleAddIntegrationPolicyClick = useCallback<ReactEventHandler>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -81,8 +81,21 @@ export const OnboardingFlowForm: FunctionComponent = () => {
const radioGroupId = useGeneratedHtmlId({ prefix: 'onboardingCategory' });

const [searchParams, setSearchParams] = useSearchParams();

const packageListRef = React.useRef<HTMLDivElement | null>(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<string, string> = 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) => () => {
Expand All @@ -97,7 +110,7 @@ export const OnboardingFlowForm: FunctionComponent = () => {
);
}
},
[setIntegrationSearch]
[]
);

const customCards = useCustomCardsForCategory(
Expand Down Expand Up @@ -153,7 +166,13 @@ export const OnboardingFlowForm: FunctionComponent = () => {
/>
<EuiSpacer size="m" />

{Array.isArray(customCards) && <OnboardingFlowPackageList customCards={customCards} />}
{Array.isArray(customCards) && (
<OnboardingFlowPackageList
customCards={customCards}
flowSearch={integrationSearch}
flowCategory={searchParams.get('category')}
/>
)}

<EuiText css={customMargin} size="s" color="subdued">
<FormattedMessage
Expand All @@ -164,9 +183,13 @@ export const OnboardingFlowForm: FunctionComponent = () => {
<OnboardingFlowPackageList
showSearchBar={true}
searchQuery={integrationSearch}
flowSearch={integrationSearch}
setSearchQuery={setIntegrationSearch}
flowCategory={searchParams.get('category')}
ref={packageListRef}
customCards={customCards?.filter(({ name, type }) => type === 'generated')}
customCards={customCards?.filter(
(card) => card.type === 'generated' && !card.isCollectionCard
)}
joinCardLists
/>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ interface Props {
packageListRef?: React.Ref<HTMLDivElement>;
searchQuery?: string;
setSearchQuery?: React.Dispatch<React.SetStateAction<string>>;
flowCategory?: string | null;
flowSearch?: string;
/**
* When enabled, custom and integration cards are joined into a single list.
*/
Expand All @@ -52,6 +54,8 @@ const PackageListGridWrapper = ({
searchQuery,
setSearchQuery,
customCards,
flowCategory,
flowSearch,
joinCardLists = false,
}: WrapperProps) => {
const customMargin = useCustomMargin();
Expand All @@ -63,6 +67,8 @@ const PackageListGridWrapper = ({
filteredCards,
selectedCategory,
customCards,
flowCategory,
flowSearch,
joinCardLists
);

Expand Down
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
});
Loading

0 comments on commit 6cdb0ea

Please sign in to comment.