[fleet] Add Integration Preference selector (#114432)

This commit is contained in:
Clint Andrew Hall 2021-10-13 08:17:49 -05:00 committed by GitHub
parent 45e07af1fa
commit 8068eecbde
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 536 additions and 6327 deletions

View file

@ -14,7 +14,7 @@ export const searchIdField = 'id';
export const fieldsToSearch = ['name', 'title', 'description'];
export function useLocalSearch(packageList: IntegrationCardItem[]) {
const localSearchRef = useRef<LocalSearch | null>(null);
const localSearchRef = useRef<LocalSearch>(new LocalSearch(searchIdField));
useEffect(() => {
const localSearch = new LocalSearch(searchIdField);

View file

@ -0,0 +1,36 @@
/*
* 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 type { Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { IntegrationPreference as Component } from './integration_preference';
export default {
title: 'Sections/EPM/Integration Preference',
description: '',
decorators: [
(storyFn, { globals }) => (
<div
style={{
padding: 40,
backgroundColor:
globals.euiTheme === 'v8.dark' || globals.euiTheme === 'v7.dark' ? '#1D1E24' : '#FFF',
width: 280,
}}
>
{storyFn()}
</div>
),
],
} as Meta;
export const IntegrationPreference = () => {
return <Component initialType="recommended" onChange={action('onChange')} />;
};

View file

@ -0,0 +1,120 @@
/*
* 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 styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiPanel,
EuiLink,
EuiText,
EuiForm,
EuiRadioGroup,
EuiSpacer,
EuiIconTip,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
export type IntegrationPreferenceType = 'recommended' | 'beats' | 'agent';
interface Option {
type: IntegrationPreferenceType;
label: React.ReactNode;
}
export interface Props {
initialType: IntegrationPreferenceType;
onChange: (type: IntegrationPreferenceType) => void;
}
const link = (
<EuiLink href="#">
<FormattedMessage
id="xpack.fleet.epm.integrationPreference.titleLink"
defaultMessage="Elastic Agent and Beats"
/>
</EuiLink>
);
const title = (
<FormattedMessage
id="xpack.fleet.epm.integrationPreference.title"
defaultMessage="When an integration is available for {link}, show:"
values={{ link }}
/>
);
const recommendedTooltip = (
<FormattedMessage
id="xpack.fleet.epm.integrationPreference.recommendedTooltip"
defaultMessage="Generally available (GA) integrations are recommended over beta and experimental."
/>
);
const Item = styled(EuiFlexItem)`
padding-left: ${(props) => props.theme.eui.euiSizeXS};
`;
const options: Option[] = [
{
type: 'recommended',
label: (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
{i18n.translate('xpack.fleet.epm.integrationPreference.recommendedLabel', {
defaultMessage: 'Recommended',
})}
</EuiFlexItem>
<Item>
<EuiIconTip content={recommendedTooltip} />
</Item>
</EuiFlexGroup>
),
},
{
type: 'agent',
label: i18n.translate('xpack.fleet.epm.integrationPreference.elasticAgentLabel', {
defaultMessage: 'Elastic Agent only',
}),
},
{
type: 'beats',
label: i18n.translate('xpack.fleet.epm.integrationPreference.beatsLabel', {
defaultMessage: 'Beats only',
}),
},
];
export const IntegrationPreference = ({ initialType, onChange }: Props) => {
const [idSelected, setIdSelected] = React.useState<IntegrationPreferenceType>(initialType);
const radios = options.map((option) => ({
id: option.type,
value: option.type,
label: option.label,
}));
return (
<EuiPanel hasShadow={false} paddingSize="none">
<EuiText size="s">{title}</EuiText>
<EuiSpacer size="m" />
<EuiForm>
<EuiRadioGroup
options={radios}
idSelected={idSelected}
onChange={(id, value) => {
setIdSelected(id as IntegrationPreferenceType);
onChange(value as IntegrationPreferenceType);
}}
name="preference"
/>
</EuiForm>
</EuiPanel>
);
};

View file

@ -30,7 +30,7 @@ import { PackageCard } from './package_card';
export interface Props {
isLoading?: boolean;
controls?: ReactNode;
controls?: ReactNode | ReactNode[];
title: string;
list: IntegrationCardItem[];
initialSearch?: string;

View file

@ -5,10 +5,11 @@
* 2.0.
*/
import React, { memo, useMemo } from 'react';
import React, { memo, useMemo, useState } from 'react';
import { useLocation, useHistory, useParams } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import _ from 'lodash';
import { EuiHorizontalRule } from '@elastic/eui';
import { pagePathGetters } from '../../../../constants';
import {
@ -31,6 +32,9 @@ import type { IntegrationCardItem } from '../../../../../../../common/types/mode
import { useMergeEprPackagesWithReplacements } from '../../../../hooks/use_merge_epr_with_replacements';
import type { IntegrationPreferenceType } from '../../components/integration_preference';
import { IntegrationPreference } from '../../components/integration_preference';
import { mergeCategoriesAndCount } from './util';
import { ALL_CATEGORY, CategoryFacets } from './category_facets';
import type { CategoryFacet } from './category_facets';
@ -96,11 +100,14 @@ const title = i18n.translate('xpack.fleet.epmList.allTitle', {
// TODO: clintandrewhall - this component is hard to test due to the hooks, particularly those that use `http`
// or `location` to load data. Ideally, we'll split this into "connected" and "pure" components.
export const AvailablePackages: React.FC = memo(() => {
const [preference, setPreference] = useState<IntegrationPreferenceType>('recommended');
useBreadcrumbs('integrations_all');
const { selectedCategory, searchParam } = getParams(
useParams<CategoryParams>(),
useLocation().search
);
const history = useHistory();
const { getHref, getAbsolutePath } = useLink();
@ -111,6 +118,7 @@ export const AvailablePackages: React.FC = memo(() => {
})[1];
history.push(url);
}
function setSearchTerm(search: string) {
// Use .replace so the browser's back button is not tied to single keystroke
history.replace(
@ -121,25 +129,32 @@ export const AvailablePackages: React.FC = memo(() => {
const { data: eprPackages, isLoading: isLoadingAllPackages } = useGetPackages({
category: '',
});
const eprIntegrationList = useMemo(
() => packageListToIntegrationsList(eprPackages?.response || []),
[eprPackages]
);
const { value: replacementCustomIntegrations } = useGetReplacementCustomIntegrations();
const mergedEprPackages: Array<PackageListItem | CustomIntegration> =
useMergeEprPackagesWithReplacements(
eprIntegrationList || [],
replacementCustomIntegrations || []
preference === 'beats' ? [] : eprIntegrationList,
preference === 'agent' ? [] : replacementCustomIntegrations || []
);
const { loading: isLoadingAppendCustomIntegrations, value: appendCustomIntegrations } =
useGetAppendCustomIntegrations();
const eprAndCustomPackages: Array<CustomIntegration | PackageListItem> = [
...mergedEprPackages,
...(appendCustomIntegrations || []),
];
const cards: IntegrationCardItem[] = eprAndCustomPackages.map((item) => {
return mapToCard(getAbsolutePath, getHref, item);
});
cards.sort((a, b) => {
return a.title.localeCompare(b.title);
});
@ -147,6 +162,7 @@ export const AvailablePackages: React.FC = memo(() => {
const { data: eprCategories, isLoading: isLoadingCategories } = useGetCategories({
include_policy_templates: true,
});
const categories = useMemo(() => {
const eprAndCustomCategories: CategoryFacet[] =
isLoadingCategories || !eprCategories
@ -169,16 +185,24 @@ export const AvailablePackages: React.FC = memo(() => {
return null;
}
const controls = categories ? (
<CategoryFacets
isLoading={isLoadingCategories || isLoadingAllPackages || isLoadingAppendCustomIntegrations}
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={({ id }: CategoryFacet) => {
setSelectedCategory(id);
}}
/>
) : null;
let controls = [
<EuiHorizontalRule />,
<IntegrationPreference initialType={preference} onChange={setPreference} />,
];
if (categories) {
controls = [
<CategoryFacets
isLoading={isLoadingCategories || isLoadingAllPackages || isLoadingAppendCustomIntegrations}
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={({ id }) => {
setSelectedCategory(id);
}}
/>,
...controls,
];
}
const filteredCards = cards.filter((c) => {
if (selectedCategory === '') {

View file

@ -14,7 +14,6 @@ import { INTEGRATIONS_ROUTING_PATHS } from '../../../../constants';
import { EPMHomePage as Component } from '.';
export default {
component: Component,
title: 'Sections/EPM/Home',
};

View file

@ -12,6 +12,7 @@ import type { DynamicPage, DynamicPagePathValues, StaticPage } from '../../../..
import { INTEGRATIONS_ROUTING_PATHS, INTEGRATIONS_SEARCH_QUERYPARAM } from '../../../../constants';
import { DefaultLayout } from '../../../../layouts';
import type { IntegrationCategory } from '../../../../../../../../../../src/plugins/custom_integrations/common';
import type { CustomIntegration } from '../../../../../../../../../../src/plugins/custom_integrations/common';
import type { PackageListItem } from '../../../../types';
@ -31,7 +32,10 @@ export const getParams = (params: CategoryParams, search: string) => {
const selectedCategory = category || '';
const queryParams = new URLSearchParams(search);
const searchParam = queryParams.get(INTEGRATIONS_SEARCH_QUERYPARAM) || '';
return { selectedCategory, searchParam };
return { selectedCategory, searchParam } as {
selectedCategory: IntegrationCategory & '';
searchParam: string;
};
};
export const categoryExists = (category: string, categories: CategoryFacet[]) => {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,62 @@
/*
* 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 type { CustomIntegration } from '../../../../../../src/plugins/custom_integrations/common';
export const integrations: CustomIntegration[] = [
{
id: 'b.ga_beats',
categories: ['azure', 'cloud', 'config_management'],
description: 'Beats for a GA package that is not installed',
isBeta: false,
shipper: 'beats',
icons: [
{
type: 'eui',
src: 'logoBeats',
},
],
title: 'b. GA, has Beats',
type: 'ui_link',
uiInternalPath: '/',
eprOverlap: 'ga_beats',
},
{
id: 'f.beta_beats',
categories: ['azure', 'cloud', 'config_management'],
description: 'Beats for a beta package that is not installed',
isBeta: false,
shipper: 'beats',
icons: [
{
type: 'eui',
src: 'logoBeats',
},
],
title: 'f. Beta, has Beats',
type: 'ui_link',
uiInternalPath: '/',
eprOverlap: 'beta_beats',
},
{
id: 'j.exp_beats',
categories: ['azure', 'cloud', 'config_management'],
description: 'Beats for an experimental package that is not installed',
isBeta: false,
shipper: 'beats',
icons: [
{
type: 'eui',
src: 'logoBeats',
},
],
title: 'j. Experimental, has Beats',
type: 'ui_link',
uiInternalPath: '/',
eprOverlap: 'exp_beats',
},
];

View file

@ -27,6 +27,7 @@ export const getHttp = (basepath = BASE_PATH) => {
serverBasePath: basepath,
},
get: (async (path: string, options: HttpFetchOptions) => {
action('get')(path, options);
// TODO: all of this needs revision, as it's far too clunky... but it works for now,
// with the few paths we're supporting.
if (path === '/api/fleet/agents/setup') {
@ -74,6 +75,7 @@ export const getHttp = (basepath = BASE_PATH) => {
return await import('./fixtures/integration.okta');
}
action(path)('KP: UNSUPPORTED ROUTE');
return {};
}) as HttpHandler,
} as unknown as HttpStart;

View file

@ -66,7 +66,10 @@ export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({
setHttpClient(startServices.http);
setCustomIntegrations({
getAppendCustomIntegrations: async () => [],
getReplacementCustomIntegrations: async () => [],
getReplacementCustomIntegrations: async () => {
const { integrations } = await import('./fixtures/replacement_integrations');
return integrations;
},
});
const config = {