[Fleet] Implement subcategories in integrations UI (#148894)

## Summary
Closes https://github.com/elastic/kibana/issues/147125
Closes https://github.com/elastic/kibana/issues/148654

- Implement subcategories in integrations UI.

- I also refactored the `AvailablePackages`, `InstalledPackages` and
`PackageListGrid` components to make easier to do changes. I extracted
the logic from `AvailablePackages` in a hook `useAvailablePackages` and
updated it.
- Sneaked a small bugfix into it. It's in commit
7b2946fe3b:
removed the js logic that handled the sticky sidemenu, that would cause
flickering in some cases and implemented it with some css instead.

## Repro steps

### Display actual subcategories coming from EPR
- Enable `showIntegrationsSubcategories` feature flag;
- Navigate to Integrations page. `Security` and `infrastructure`
categories have sub categories.
- When clicking on a subcategory, the page URL gets updated. For
example, if clicking on `Infrastructure` > `Kubernetes`, the url will be
`/integrations/browse/infrastructure/kubernetes`. It works with search
too, if searching `abc` in the subcategory the url will update to
`/integrations/browse/infrastructure/kubernetes?q=abc`

### Display mock data
Same repro steps as before, but I added some mock subcategories:

Added the following subcategories file
<details>
<p>

```

export const mockSubcategories = [
  {
    id: 'kubernetes',
    title: 'Kubernetes',
    count: 18,
    parent_id: 'infrastructure',
    parent_title: 'Infrastructure',
  },

  {
    id: 'message_queue',
    title: 'Message Broker',
    count: 7,
    parent_id: 'infrastructure',
    parent_title: 'Infrastructure',
  },

  {
    id: 'monitoring',
    title: 'Monitoring',
    count: 16,
    parent_id: 'observability',
    parent_title: 'Observability',
  },

  {
    id: 'threat_intel',
    title: 'Threat Intelligence',
    count: 10,
    parent_id: 'security',
    parent_title: 'Security',
  },

  {
    id: 'web',
    title: 'Web Server',
    count: 36,
    parent_id: 'infrastructure',
    parent_title: 'Infrastructure',
  },

  {
    id: 'security_mock_1',
    title: 'Security mock 1',
    count: 10,
    parent_id: 'security',
    parent_title: 'Security',
  },

  {
    id: 'infrastructure_mock_1',
    title: 'Subcategory 1',
    count: 12,
    parent_id: 'infrastructure',
    parent_title: 'Infrastructure',
  },

  {
    id: 'infrastructure_mock_2',
    title: 'Subcategory 2',
    count: 12,
    parent_id: 'infrastructure',
    parent_title: 'Infrastructure',
  },
  {
    id: 'infrastructure_mock_3',
    title: 'Subcategory 3',
    count: 12,
    parent_id: 'infrastructure',
    parent_title: 'Infrastructure',
  },
  {
    id: 'infrastructure_mock_4',
    title: 'Subcategory 4',
    count: 12,
    parent_id: 'infrastructure',
    parent_title: 'Infrastructure',
  },
  {
    id: 'infrastructure_mock_5',
    title: 'Subcategory 5',
    count: 12,
    parent_id: 'infrastructure',
    parent_title: 'Infrastructure',
  },
  {
    id: 'infrastructure_mock_6',
    title: 'Subcategory 6',
    count: 12,
    parent_id: 'infrastructure',
    parent_title: 'Infrastructure',
  },
];
```
</p>
</details>
I then imported it and replaced the subcategories here:
7b2946fe3b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/hooks/use_available_packages.tsx (L226).

It should provide more subcategories under `infrastructure`.

### Screenshots

If subcategories are up to 6, they get rendered on top of the
integrations page:

<img width="1679" alt="Screenshot 2023-01-17 at 16 11 01"
src="https://user-images.githubusercontent.com/16084106/212963949-6e1fdccc-872d-4c40-a594-905e34218494.png">

If more the 6 subcategories are present, the remaining ones get hidden
under an option button:

<img width="1593" alt="Screenshot 2023-01-17 at 16 07 27"
src="https://user-images.githubusercontent.com/16084106/212963863-0be8a97f-75ba-4bfb-a28c-bb9bbeb4c8b3.png">

<img width="1693" alt="Screenshot 2023-01-17 at 16 07 41"
src="https://user-images.githubusercontent.com/16084106/212963899-2846b292-45c2-4fc2-91ca-e947653ec961.png">



### Checklist

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Cristina Amico 2023-01-19 10:35:00 +01:00 committed by GitHub
parent f83a60c953
commit fabfb43740
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 661 additions and 373 deletions

View file

@ -17,6 +17,7 @@ export const allowedExperimentalValues = Object.freeze({
showDevtoolsRequest: true,
diagnosticFileUploadEnabled: false,
experimentalDataStreamSettings: false,
showIntegrationsSubcategories: false,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

View file

@ -289,6 +289,8 @@ export interface CategorySummaryItem {
id: CategoryId;
title: string;
count: number;
parent_id?: string;
parent_title?: string;
}
export type RequirementsByServiceName = PackageSpecManifest['conditions'];

View file

@ -172,7 +172,8 @@ describe('Add Integration - Real API', () => {
it('should filter integrations by category', () => {
setupIntegrations();
cy.getBySel(getIntegrationCategories('aws')).click();
cy.getBySel(getIntegrationCategories('aws')).click({ scrollBehavior: false });
cy.getBySel(INTEGRATIONS_SEARCHBAR.BADGE).contains('AWS').should('exist');
cy.getBySel(INTEGRATION_LIST).find('.euiCard').should('have.length.greaterThan', 29);

View file

@ -45,10 +45,13 @@ const categories = [
export const EmptyList = (props: Args) => (
<PackageListGrid
list={[]}
onSearchChange={action('onSearchChange')}
setSelectedCategory={action('setSelectedCategory')}
searchTerm=""
setSearchTerm={action('setSearchTerm')}
setCategory={action('setCategory')}
categories={categories}
selectedCategory=""
setUrlandReplaceHistory={action('setUrlandReplaceHistory')}
setUrlandPushHistory={action('setUrlandPushHistory')}
{...props}
/>
);
@ -129,10 +132,13 @@ export const List = (props: Args) => (
categories: ['category_two'],
},
]}
onSearchChange={action('onSearchChange')}
setSelectedCategory={action('setSelectedCategory')}
searchTerm=""
setSearchTerm={action('setSearchTerm')}
setCategory={action('setCategory')}
categories={categories}
selectedCategory=""
setUrlandReplaceHistory={action('setUrlandReplaceHistory')}
setUrlandPushHistory={action('setUrlandPushHistory')}
{...props}
/>
);

View file

@ -7,7 +7,7 @@
import type { ReactNode, FunctionComponent } from 'react';
import { useMemo } from 'react';
import React, { useCallback, useState, useRef, useEffect } from 'react';
import React, { useCallback, useState } from 'react';
import { css } from '@emotion/react';
import {
@ -22,6 +22,11 @@ import {
useEuiTheme,
EuiIcon,
EuiScreenReaderOnly,
EuiButton,
EuiButtonIcon,
EuiPopover,
EuiContextMenuPanel,
EuiContextMenuItem,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -35,6 +40,10 @@ import type { IntegrationCardItem } from '../../../../../../common/types/models'
import type { ExtendedIntegrationCategory, CategoryFacet } from '../screens/home/category_facets';
import type { IntegrationsURLParameters } from '../screens/home/hooks/use_available_packages';
import { ExperimentalFeaturesService } from '../../../services';
import { promoteFeaturedIntegrations } from './utils';
import { PackageCard } from './package_card';
@ -42,16 +51,22 @@ import { PackageCard } from './package_card';
export interface Props {
isLoading?: boolean;
controls?: ReactNode | ReactNode[];
title?: string;
list: IntegrationCardItem[];
initialSearch?: string;
searchTerm: string;
setSearchTerm: (search: string) => void;
selectedCategory: ExtendedIntegrationCategory;
setSelectedCategory: (category: string) => void;
setCategory: (category: ExtendedIntegrationCategory) => void;
categories: CategoryFacet[];
onSearchChange: (search: string) => void;
showMissingIntegrationMessage?: boolean;
setUrlandReplaceHistory: (params: IntegrationsURLParameters) => void;
setUrlandPushHistory: (params: IntegrationsURLParameters) => void;
callout?: JSX.Element | null;
// Props used only in AvailablePackages component:
showCardLabels?: boolean;
title?: string;
availableSubCategories?: CategoryFacet[];
selectedSubCategory?: string;
setSelectedSubCategory?: (c: string | undefined) => void;
showMissingIntegrationMessage?: boolean;
}
export const PackageListGrid: FunctionComponent<Props> = ({
@ -59,43 +74,63 @@ export const PackageListGrid: FunctionComponent<Props> = ({
controls,
title,
list,
initialSearch,
onSearchChange,
searchTerm,
setSearchTerm,
selectedCategory,
setSelectedCategory,
setCategory,
categories,
availableSubCategories,
setSelectedSubCategory,
selectedSubCategory,
setUrlandReplaceHistory,
setUrlandPushHistory,
showMissingIntegrationMessage = false,
callout,
showCardLabels = true,
}) => {
const [searchTerm, setSearchTerm] = useState(initialSearch || '');
const localSearchRef = useLocalSearch(list);
const menuRef = useRef<HTMLDivElement>(null);
const [isSticky, setIsSticky] = useState(false);
const [windowScrollY] = useState(window.scrollY);
const { euiTheme } = useEuiTheme();
useEffect(() => {
const menuRefCurrent = menuRef.current;
const onScroll = () => {
if (menuRefCurrent) {
setIsSticky(menuRefCurrent?.getBoundingClientRect().top < 110);
}
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, [windowScrollY, isSticky]);
const [isPopoverOpen, setPopover] = useState(false);
const MAX_SUBCATEGORIES_NUMBER = 6;
const { showIntegrationsSubcategories } = ExperimentalFeaturesService.get();
const onButtonClick = () => {
setPopover(!isPopoverOpen);
};
const closePopover = () => {
setPopover(false);
};
const onQueryChange = (e: any) => {
const queryText = e.target.value;
setSearchTerm(queryText);
onSearchChange(queryText);
setUrlandReplaceHistory({
searchString: queryText,
categoryId: selectedCategory,
subCategoryId: selectedSubCategory,
});
};
const resetQuery = () => {
setSearchTerm('');
setUrlandReplaceHistory({ searchString: '', categoryId: '', subCategoryId: '' });
};
const onSubCategoryClick = useCallback(
(subCategory: string) => {
if (setSelectedSubCategory) setSelectedSubCategory(subCategory);
setUrlandPushHistory({
categoryId: selectedCategory,
subCategoryId: subCategory,
});
},
[selectedCategory, setSelectedSubCategory, setUrlandPushHistory]
);
const selectedCategoryTitle = selectedCategory
? categories.find((category) => category.id === selectedCategory)?.title
: undefined;
@ -113,116 +148,199 @@ export const PackageListGrid: FunctionComponent<Props> = ({
return promoteFeaturedIntegrations(filteredList, selectedCategory);
}, [isLoading, list, localSearchRef, searchTerm, selectedCategory]);
const controlsContent = <ControlsColumn title={title} controls={controls} sticky={isSticky} />;
let gridContent: JSX.Element;
const splitSubcategories = (
subcategories: CategoryFacet[] | undefined
): { visibleSubCategories?: CategoryFacet[]; hiddenSubCategories?: CategoryFacet[] } => {
if (!subcategories) return {};
else if (subcategories && subcategories?.length < MAX_SUBCATEGORIES_NUMBER) {
return { visibleSubCategories: subcategories, hiddenSubCategories: [] };
} else if (subcategories && subcategories?.length >= MAX_SUBCATEGORIES_NUMBER) {
return {
visibleSubCategories: subcategories.slice(0, MAX_SUBCATEGORIES_NUMBER),
hiddenSubCategories: subcategories.slice(MAX_SUBCATEGORIES_NUMBER),
};
}
return {};
};
if (isLoading || !localSearchRef.current) {
gridContent = <Loading />;
} else {
gridContent = (
<GridColumn
list={filteredPromotedList}
showMissingIntegrationMessage={showMissingIntegrationMessage}
showCardLabels={showCardLabels}
/>
);
}
const splitSubcat = splitSubcategories(availableSubCategories);
const { visibleSubCategories } = splitSubcat;
const hiddenSubCategoriesItems = useMemo(() => {
return splitSubcat?.hiddenSubCategories?.map((subCategory) => {
return (
<EuiContextMenuItem
key={subCategory.id}
onClick={() => {
onSubCategoryClick(subCategory.id);
closePopover();
}}
>
{subCategory.title}
</EuiContextMenuItem>
);
});
}, [onSubCategoryClick, splitSubcat.hiddenSubCategories]);
return (
<>
<div ref={menuRef}>
<EuiFlexGroup
alignItems="flexStart"
gutterSize="xl"
data-test-subj="epmList.integrationCards"
>
<EuiFlexItem grow={1} className={isSticky ? 'kbnStickyMenu' : ''}>
{controlsContent}
</EuiFlexItem>
<EuiFlexItem grow={5}>
<EuiFieldSearch
data-test-subj="epmList.searchBar"
placeholder={i18n.translate('xpack.fleet.epmList.searchPackagesPlaceholder', {
defaultMessage: 'Search for integrations',
})}
value={searchTerm}
onChange={(e) => onQueryChange(e)}
isClearable={true}
incremental={true}
fullWidth={true}
prepend={
selectedCategoryTitle ? (
<EuiText
data-test-subj="epmList.categoryBadge"
size="xs"
<EuiFlexGroup alignItems="flexStart" gutterSize="xl" data-test-subj="epmList.integrationCards">
<EuiFlexItem
data-test-subj="epmList.controlsSideColumn"
grow={1}
style={{
position: 'sticky',
top: 0,
zIndex: 100,
}}
>
<ControlsColumn controls={controls} title={title} />
</EuiFlexItem>
<EuiFlexItem grow={5} data-test-subj="epmList.mainColumn">
<EuiFieldSearch
data-test-subj="epmList.searchBar"
placeholder={i18n.translate('xpack.fleet.epmList.searchPackagesPlaceholder', {
defaultMessage: 'Search for integrations',
})}
value={searchTerm}
onChange={(e) => onQueryChange(e)}
isClearable={true}
incremental={true}
fullWidth={true}
prepend={
selectedCategoryTitle ? (
<EuiText
data-test-subj="epmList.categoryBadge"
size="xs"
style={{
display: 'flex',
alignItems: 'center',
fontWeight: euiTheme.font.weight.bold,
backgroundColor: euiTheme.colors.lightestShade,
}}
>
<EuiScreenReaderOnly>
<span>Searching category: </span>
</EuiScreenReaderOnly>
{selectedCategoryTitle}
<button
data-test-subj="epmList.categoryBadge.closeBtn"
onClick={() => {
setCategory('');
if (setSelectedSubCategory) setSelectedSubCategory(undefined);
setUrlandReplaceHistory({
searchString: '',
categoryId: '',
subCategoryId: '',
});
}}
aria-label="Remove filter"
style={{
padding: euiTheme.size.xs,
paddingTop: '2px',
}}
>
<EuiIcon
type="cross"
color="text"
size="s"
style={{
display: 'flex',
alignItems: 'center',
fontWeight: euiTheme.font.weight.bold,
width: 'auto',
padding: 0,
backgroundColor: euiTheme.colors.lightestShade,
}}
>
<EuiScreenReaderOnly>
<span>Searching category: </span>
</EuiScreenReaderOnly>
{selectedCategoryTitle}
<button
data-test-subj="epmList.categoryBadge.closeBtn"
onClick={() => {
setSelectedCategory('');
}}
aria-label="Remove filter"
style={{
padding: euiTheme.size.xs,
paddingTop: '2px',
}}
>
<EuiIcon
type="cross"
color="text"
size="s"
style={{
width: 'auto',
padding: 0,
backgroundColor: euiTheme.colors.lightestShade,
}}
/>
</button>
</EuiText>
) : undefined
}
/>
{callout ? (
<>
<EuiSpacer />
{callout}
</>
/>
</button>
</EuiText>
) : undefined
}
/>
{showIntegrationsSubcategories && availableSubCategories?.length ? <EuiSpacer /> : null}
{showIntegrationsSubcategories ? (
<EuiFlexGroup
data-test-subj="epmList.subcategoriesRow"
justifyContent="flexStart"
direction="row"
gutterSize="s"
style={{
maxWidth: 943,
}}
>
{visibleSubCategories?.map((subCategory) => (
<EuiFlexItem grow={false} key={subCategory.id}>
<EuiButton
color="text"
aria-label={subCategory?.title}
onClick={() => onSubCategoryClick(subCategory.id)}
>
<FormattedMessage
id="xpack.fleet.epmList.subcategoriesButton"
defaultMessage="{subcategory}"
values={{
subcategory: subCategory.title,
}}
/>
</EuiButton>
</EuiFlexItem>
))}
{hiddenSubCategoriesItems?.length ? (
<EuiFlexItem grow={false}>
<EuiPopover
data-test-subj="epmList.showMoreSubCategoriesButton"
id="moreSubCategories"
button={
<EuiButtonIcon
display="base"
onClick={onButtonClick}
iconType="boxesHorizontal"
aria-label="Show more subcategories"
size="m"
/>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel size="s" items={hiddenSubCategoriesItems} />
</EuiPopover>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
) : null}
{callout ? (
<>
<EuiSpacer />
{gridContent}
{showMissingIntegrationMessage && (
<>
<EuiSpacer />
<MissingIntegrationContent
resetQuery={resetQuery}
setSelectedCategory={setSelectedCategory}
/>
</>
)}
</EuiFlexItem>
</EuiFlexGroup>
</div>
</>
{callout}
</>
) : null}
<EuiSpacer />
<GridColumn
isLoading={isLoading || !localSearchRef.current}
list={filteredPromotedList}
showMissingIntegrationMessage={showMissingIntegrationMessage}
showCardLabels={showCardLabels}
/>
{showMissingIntegrationMessage && (
<>
<EuiSpacer />
<MissingIntegrationContent
setUrlandPushHistory={setUrlandPushHistory}
resetQuery={resetQuery}
setSelectedCategory={setCategory}
/>
<EuiSpacer />
</>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
};
interface ControlsColumnProps {
controls: ReactNode;
title: string | undefined;
sticky: boolean;
}
function ControlsColumn({ controls, title, sticky }: ControlsColumnProps) {
const ControlsColumn = ({ controls, title }: ControlsColumnProps) => {
let titleContent;
if (title) {
titleContent = (
@ -235,24 +353,28 @@ function ControlsColumn({ controls, title, sticky }: ControlsColumnProps) {
);
}
return (
<EuiFlexGroup direction="column" className={sticky ? 'kbnStickyMenu' : ''} gutterSize="none">
<EuiFlexGroup direction="column" gutterSize="none">
{titleContent}
{controls}
</EuiFlexGroup>
);
}
};
interface GridColumnProps {
list: IntegrationCardItem[];
isLoading: boolean;
showMissingIntegrationMessage?: boolean;
showCardLabels?: boolean;
}
function GridColumn({
const GridColumn = ({
list,
showMissingIntegrationMessage = false,
showCardLabels = false,
}: GridColumnProps) {
isLoading,
}: GridColumnProps) => {
if (isLoading) return <Loading />;
return (
<EuiFlexGrid gutterSize="l" columns={3}>
{list.length ? (
@ -294,21 +416,27 @@ function GridColumn({
)}
</EuiFlexGrid>
);
}
};
interface MissingIntegrationContentProps {
resetQuery: () => void;
setSelectedCategory: (category: string) => void;
setSelectedCategory: (category: ExtendedIntegrationCategory) => void;
setUrlandPushHistory: (params: IntegrationsURLParameters) => void;
}
function MissingIntegrationContent({
const MissingIntegrationContent = ({
resetQuery,
setSelectedCategory,
}: MissingIntegrationContentProps) {
setUrlandPushHistory,
}: MissingIntegrationContentProps) => {
const handleCustomInputsLinkClick = useCallback(() => {
resetQuery();
setSelectedCategory('custom');
}, [resetQuery, setSelectedCategory]);
setUrlandPushHistory({
categoryId: 'custom',
subCategoryId: '',
});
}, [resetQuery, setSelectedCategory, setUrlandPushHistory]);
return (
<EuiText size="s" color="subdued">
@ -338,4 +466,4 @@ function MissingIntegrationContent({
</p>
</EuiText>
);
}
};

View file

@ -6,48 +6,26 @@
*/
import type { FunctionComponent } from 'react';
import React, { useMemo, useState } from 'react';
import { useLocation, useHistory, useParams } from 'react-router-dom';
import _ from 'lodash';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiHorizontalRule, EuiFlexItem, EuiCallOut, EuiLink } from '@elastic/eui';
import type { CustomIntegration } from '@kbn/custom-integrations-plugin/common';
import { useStartServices } from '../../../../hooks';
import {
isInputOnlyPolicyTemplate,
isIntegrationPolicyTemplate,
} from '../../../../../../../common/services';
import { useBreadcrumbs } from '../../../../hooks';
import { useCategories, usePackages, useStartServices } from '../../../../hooks';
import { pagePathGetters } from '../../../../constants';
import {
useBreadcrumbs,
useGetAppendCustomIntegrations,
useGetReplacementCustomIntegrations,
useLink,
} from '../../../../hooks';
import { doesPackageHaveIntegrations } from '../../../../services';
import type { PackageList } from '../../../../types';
import { PackageListGrid } from '../../components/package_list_grid';
import type { PackageListItem } from '../../../../types';
import type { IntegrationCardItem } from '../../../../../../../common/types/models';
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, ExtendedIntegrationCategory } from './category_facets';
import { CategoryFacets } from './category_facets';
import type { CategoryParams } from '.';
import { getParams, categoryExists, mapToCard } from '.';
import { categoryExists } from '.';
import { useAvailablePackages } from './hooks/use_available_packages';
import type { ExtendedIntegrationCategory } from './category_facets';
const NoEprCallout: FunctionComponent<{ statusCode?: number }> = ({
statusCode,
@ -119,180 +97,35 @@ function OnPremLink() {
);
}
function getAllCategoriesFromIntegrations(pkg: PackageListItem) {
if (!doesPackageHaveIntegrations(pkg)) {
return pkg.categories;
}
const allCategories = pkg.policy_templates?.reduce((accumulator, policyTemplate) => {
if (isInputOnlyPolicyTemplate(policyTemplate)) {
// input only policy templates do not have categories
return accumulator;
}
return [...accumulator, ...(policyTemplate.categories || [])];
}, pkg.categories || []);
return _.uniq(allCategories);
}
// Packages can export multiple integrations, aka `policy_templates`
// In the case where packages ship >1 `policy_templates`, we flatten out the
// list of packages by bringing all integrations to top-level so that
// each integration is displayed as its own tile
const packageListToIntegrationsList = (packages: PackageList): PackageList => {
return packages.reduce((acc: PackageList, pkg) => {
const {
policy_templates: policyTemplates = [],
categories: topCategories = [],
...restOfPackage
} = pkg;
const topPackage = {
...restOfPackage,
categories: getAllCategoriesFromIntegrations(pkg),
};
return [
...acc,
topPackage,
...(doesPackageHaveIntegrations(pkg)
? policyTemplates.map((policyTemplate) => {
const { name, title, description, icons } = policyTemplate;
const categories =
isIntegrationPolicyTemplate(policyTemplate) && policyTemplate.categories
? policyTemplate.categories
: [];
const allCategories = [...topCategories, ...categories];
return {
...restOfPackage,
id: `${restOfPackage.id}-${name}`,
integration: name,
title,
description,
icons: icons || restOfPackage.icons,
categories: _.uniq(allCategories),
};
})
: []),
];
}, []);
};
// 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<{}> = ({}) => {
const [preference, setPreference] = useState<IntegrationPreferenceType>('recommended');
const [prereleaseIntegrationsEnabled, setPrereleaseIntegrationsEnabled] = React.useState<
boolean | undefined
>(undefined);
useBreadcrumbs('integrations_all');
const { http } = useStartServices();
const addBasePath = http.basePath.prepend;
const { selectedCategory, searchParam } = getParams(
useParams<CategoryParams>(),
useLocation().search
);
const [category, setCategory] = useState(selectedCategory);
const history = useHistory();
const { getHref, getAbsolutePath } = useLink();
function setUrlCategory(categoryId: string) {
setCategory(categoryId as ExtendedIntegrationCategory);
const url = pagePathGetters.integrations_all({
category: categoryId,
searchTerm: searchParam,
})[1];
history.push(url);
}
function setUrlSearchTerm(search: string) {
// Use .replace so the browser's back button is not tied to single keystroke
history.replace(pagePathGetters.integrations_all({ searchTerm: search, category })[1]);
}
const {
data: eprPackages,
isLoading: isLoadingAllPackages,
error: eprPackageLoadingError,
} = usePackages(prereleaseIntegrationsEnabled);
initialSelectedCategory,
selectedCategory,
setCategory,
allCategories,
mainCategories,
preference,
setPreference,
isLoadingCategories,
isLoadingAllPackages,
isLoadingAppendCustomIntegrations,
eprPackageLoadingError,
eprCategoryLoadingError,
searchTerm,
setSearchTerm,
setUrlandPushHistory,
setUrlandReplaceHistory,
filteredCards,
setPrereleaseIntegrationsEnabled,
availableSubCategories,
selectedSubCategory,
setSelectedSubCategory,
} = useAvailablePackages();
// Remove Kubernetes package granularity
if (eprPackages?.items) {
eprPackages.items.forEach(function (element) {
if (element.id === 'kubernetes') {
element.policy_templates = [];
}
});
}
const eprIntegrationList = useMemo(
() => packageListToIntegrationsList(eprPackages?.items || []),
[eprPackages]
);
const { value: replacementCustomIntegrations } = useGetReplacementCustomIntegrations();
const { loading: isLoadingAppendCustomIntegrations, value: appendCustomIntegrations } =
useGetAppendCustomIntegrations();
const mergedEprPackages: Array<PackageListItem | CustomIntegration> =
useMergeEprPackagesWithReplacements(
preference === 'beats' ? [] : eprIntegrationList,
preference === 'agent' ? [] : replacementCustomIntegrations || []
);
const cards: IntegrationCardItem[] = useMemo(() => {
const eprAndCustomPackages = [...mergedEprPackages, ...(appendCustomIntegrations || [])];
return eprAndCustomPackages
.map((item) => {
return mapToCard({ getAbsolutePath, getHref, item, addBasePath });
})
.sort((a, b) => a.title.localeCompare(b.title));
}, [addBasePath, appendCustomIntegrations, getAbsolutePath, getHref, mergedEprPackages]);
const filteredCards = useMemo(
() =>
cards.filter((c) => {
if (category === '') {
return true;
}
return c.categories.includes(category);
}),
[cards, category]
);
const {
data: eprCategories,
isLoading: isLoadingCategories,
error: eprCategoryLoadingError,
} = useCategories(prereleaseIntegrationsEnabled);
const categories: CategoryFacet[] = useMemo(() => {
const eprAndCustomCategories: CategoryFacet[] = isLoadingCategories
? []
: mergeCategoriesAndCount(
eprCategories
? (eprCategories.items as Array<{ id: string; title: string; count: number }>)
: [],
cards
);
return [
{
...ALL_CATEGORY,
count: cards.length,
},
...(eprAndCustomCategories ? eprAndCustomCategories : []),
];
}, [cards, eprCategories, isLoadingCategories]);
if (!isLoadingCategories && !categoryExists(selectedCategory, categories)) {
history.replace(pagePathGetters.integrations_all({ category: '', searchTerm: searchParam })[1]);
if (!isLoadingCategories && !categoryExists(initialSelectedCategory, allCategories)) {
setUrlandReplaceHistory({ searchString: searchTerm, categoryId: '', subCategoryId: '' });
return null;
}
@ -309,17 +142,20 @@ export const AvailablePackages: React.FC<{}> = ({}) => {
</EuiFlexItem>,
];
if (categories) {
if (mainCategories) {
controls = [
<EuiFlexItem className="eui-yScrollWithShadows">
<CategoryFacets
isLoading={
isLoadingCategories || isLoadingAllPackages || isLoadingAppendCustomIntegrations
}
categories={categories}
selectedCategory={category}
categories={mainCategories}
selectedCategory={selectedCategory}
onCategoryChange={({ id }) => {
setUrlCategory(id);
setCategory(id as ExtendedIntegrationCategory);
setSearchTerm('');
setSelectedSubCategory(undefined);
setUrlandPushHistory({ searchString: '', categoryId: id, subCategoryId: '' });
}}
/>
</EuiFlexItem>,
@ -337,15 +173,20 @@ export const AvailablePackages: React.FC<{}> = ({}) => {
<PackageListGrid
isLoading={isLoadingAllPackages || isLoadingAppendCustomIntegrations}
controls={controls}
initialSearch={searchParam}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
list={filteredCards}
selectedCategory={category}
setSelectedCategory={setUrlCategory}
categories={categories}
onSearchChange={setUrlSearchTerm}
showMissingIntegrationMessage
selectedCategory={selectedCategory}
setCategory={setCategory}
categories={mainCategories}
setUrlandReplaceHistory={setUrlandReplaceHistory}
setUrlandPushHistory={setUrlandPushHistory}
callout={noEprCallout}
showCardLabels={false}
availableSubCategories={availableSubCategories}
selectedSubCategory={selectedSubCategory}
setSelectedSubCategory={setSelectedSubCategory}
showMissingIntegrationMessage
/>
);
};

View file

@ -17,6 +17,8 @@ export interface CategoryFacet {
count: number;
id: string;
title: string;
parent_id?: string;
parent_title?: string;
}
export const UPDATES_AVAILABLE = 'updates_available';

View file

@ -0,0 +1,282 @@
/*
* 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, { useState, useMemo } from 'react';
import { useLocation, useParams, useHistory } from 'react-router-dom';
import { uniq, xorBy } from 'lodash';
import type { CustomIntegration } from '@kbn/custom-integrations-plugin/common';
import type { IntegrationPreferenceType } from '../../../components/integration_preference';
import { usePackages, useCategories, useStartServices } from '../../../../../hooks';
import {
useGetAppendCustomIntegrations,
useGetReplacementCustomIntegrations,
useLink,
} from '../../../../../hooks';
import { useMergeEprPackagesWithReplacements } from '../../../../../hooks/use_merge_epr_with_replacements';
import type { CategoryParams } from '..';
import { getParams, mapToCard } from '..';
import type { PackageList, PackageListItem } from '../../../../../types';
import { doesPackageHaveIntegrations } from '../../../../../services';
import {
isInputOnlyPolicyTemplate,
isIntegrationPolicyTemplate,
} from '../../../../../../../../common/services';
import { pagePathGetters } from '../../../../../constants';
import type { IntegrationCardItem } from '../../../../../../../../common/types/models';
import { ALL_CATEGORY } from '../category_facets';
import type { CategoryFacet } from '../category_facets';
import { mergeCategoriesAndCount } from '../util';
export interface IntegrationsURLParameters {
searchString?: string;
categoryId?: string;
subCategoryId?: string;
}
function getAllCategoriesFromIntegrations(pkg: PackageListItem) {
if (!doesPackageHaveIntegrations(pkg)) {
return pkg.categories;
}
const allCategories = pkg.policy_templates?.reduce((accumulator, policyTemplate) => {
if (isInputOnlyPolicyTemplate(policyTemplate)) {
// input only policy templates do not have categories
return accumulator;
}
return [...accumulator, ...(policyTemplate.categories || [])];
}, pkg.categories || []);
return uniq(allCategories);
}
// Packages can export multiple integrations, aka `policy_templates`
// In the case where packages ship >1 `policy_templates`, we flatten out the
// list of packages by bringing all integrations to top-level so that
// each integration is displayed as its own tile
const packageListToIntegrationsList = (packages: PackageList): PackageList => {
return packages.reduce((acc: PackageList, pkg) => {
const {
policy_templates: policyTemplates = [],
categories: topCategories = [],
...restOfPackage
} = pkg;
const topPackage = {
...restOfPackage,
categories: getAllCategoriesFromIntegrations(pkg),
};
return [
...acc,
topPackage,
...(doesPackageHaveIntegrations(pkg)
? policyTemplates.map((policyTemplate) => {
const { name, title, description, icons } = policyTemplate;
const categories =
isIntegrationPolicyTemplate(policyTemplate) && policyTemplate.categories
? policyTemplate.categories
: [];
const allCategories = [...topCategories, ...categories];
return {
...restOfPackage,
id: `${restOfPackage.id}-${name}`,
integration: name,
title,
description,
icons: icons || restOfPackage.icons,
categories: uniq(allCategories),
};
})
: []),
];
}, []);
};
export const useAvailablePackages = () => {
const [preference, setPreference] = useState<IntegrationPreferenceType>('recommended');
const [prereleaseIntegrationsEnabled, setPrereleaseIntegrationsEnabled] = React.useState<
boolean | undefined
>(undefined);
const { http } = useStartServices();
const addBasePath = http.basePath.prepend;
const {
selectedCategory: initialSelectedCategory,
selectedSubcategory: initialSubcategory,
searchParam,
} = getParams(useParams<CategoryParams>(), useLocation().search);
const [selectedCategory, setCategory] = useState(initialSelectedCategory);
const [selectedSubCategory, setSelectedSubCategory] = useState<string | undefined>(
initialSubcategory
);
const [searchTerm, setSearchTerm] = useState(searchParam || '');
const { getHref, getAbsolutePath } = useLink();
const history = useHistory();
const buildUrl = ({ searchString, categoryId, subCategoryId }: IntegrationsURLParameters) => {
const url = pagePathGetters.integrations_all({
category: categoryId ? categoryId : '',
subCategory: subCategoryId ? subCategoryId : '',
searchTerm: searchString ? searchString : '',
})[1];
return url;
};
const setUrlandPushHistory = ({
searchString,
categoryId,
subCategoryId,
}: IntegrationsURLParameters) => {
const url = buildUrl({
categoryId,
searchString,
subCategoryId,
});
history.push(url);
};
const setUrlandReplaceHistory = ({
searchString,
categoryId,
subCategoryId,
}: IntegrationsURLParameters) => {
const url = buildUrl({
categoryId,
searchString,
subCategoryId,
});
// Use .replace so the browser's back button is not tied to single keystroke
history.replace(url);
};
const {
data: eprPackages,
isLoading: isLoadingAllPackages,
error: eprPackageLoadingError,
} = usePackages(prereleaseIntegrationsEnabled);
// Remove Kubernetes package granularity
if (eprPackages?.items) {
eprPackages.items.forEach(function (element) {
if (element.id === 'kubernetes') {
element.policy_templates = [];
}
});
}
const eprIntegrationList = useMemo(
() => packageListToIntegrationsList(eprPackages?.items || []),
[eprPackages]
);
const { value: replacementCustomIntegrations } = useGetReplacementCustomIntegrations();
const { loading: isLoadingAppendCustomIntegrations, value: appendCustomIntegrations } =
useGetAppendCustomIntegrations();
const mergedEprPackages: Array<PackageListItem | CustomIntegration> =
useMergeEprPackagesWithReplacements(
preference === 'beats' ? [] : eprIntegrationList,
preference === 'agent' ? [] : replacementCustomIntegrations || []
);
const cards: IntegrationCardItem[] = useMemo(() => {
const eprAndCustomPackages = [...mergedEprPackages, ...(appendCustomIntegrations || [])];
return eprAndCustomPackages
.map((item) => {
return mapToCard({ getAbsolutePath, getHref, item, addBasePath });
})
.sort((a, b) => a.title.localeCompare(b.title));
}, [addBasePath, appendCustomIntegrations, getAbsolutePath, getHref, mergedEprPackages]);
// Packages to show
// Filters out based on selected category and subcategory (if any)
const filteredCards = useMemo(
() =>
cards.filter((c) => {
if (selectedCategory === '') {
return true;
}
if (!selectedSubCategory) return c.categories.includes(selectedCategory);
return c.categories.includes(selectedSubCategory);
}),
[cards, selectedCategory, selectedSubCategory]
);
const {
data: eprCategories,
isLoading: isLoadingCategories,
error: eprCategoryLoadingError,
} = useCategories(prereleaseIntegrationsEnabled);
// Subcategories
const subCategories = useMemo(() => {
return eprCategories?.items.filter((item) => item.parent_id !== undefined);
}, [eprCategories?.items]);
const allCategories: CategoryFacet[] = useMemo(() => {
const eprAndCustomCategories: CategoryFacet[] = isLoadingCategories
? []
: mergeCategoriesAndCount(
eprCategories
? (eprCategories.items as Array<{ id: string; title: string; count: number }>)
: [],
cards
);
return [
{
...ALL_CATEGORY,
count: cards.length,
},
...(eprAndCustomCategories ? eprAndCustomCategories : []),
];
}, [cards, eprCategories, isLoadingCategories]);
// Filter out subcategories
const mainCategories = xorBy(allCategories, subCategories, 'id');
const availableSubCategories = useMemo(() => {
return subCategories?.filter((c) => c.parent_id === selectedCategory);
}, [selectedCategory, subCategories]);
return {
initialSelectedCategory,
selectedCategory,
setCategory,
allCategories,
mainCategories,
availableSubCategories,
selectedSubCategory,
setSelectedSubCategory,
searchTerm,
setSearchTerm,
setUrlandPushHistory,
setUrlandReplaceHistory,
preference,
setPreference,
isLoadingCategories,
isLoadingAllPackages,
isLoadingAppendCustomIntegrations,
eprPackageLoadingError,
eprCategoryLoadingError,
filteredCards,
setPrereleaseIntegrationsEnabled,
};
};

View file

@ -35,14 +35,15 @@ import { AvailablePackages } from './available_packages';
export interface CategoryParams {
category?: ExtendedIntegrationCategory;
subcategory?: string;
}
export const getParams = (params: CategoryParams, search: string) => {
const { category } = params;
const { category, subcategory } = params;
const selectedCategory: ExtendedIntegrationCategory = category || '';
const queryParams = new URLSearchParams(search);
const searchParam = queryParams.get(INTEGRATIONS_SEARCH_QUERYPARAM) || '';
return { selectedCategory, searchParam };
return { selectedCategory, searchParam, selectedSubcategory: subcategory };
};
export const categoryExists = (category: string, categories: CategoryFacet[]) => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import { useLocation, useHistory, useParams } from 'react-router-dom';
import semverLt from 'semver/functions/lt';
import { i18n } from '@kbn/i18n';
@ -19,7 +19,9 @@ import { PackageListGrid } from '../../components/package_list_grid';
import type { PackageListItem } from '../../../../types';
import type { CategoryFacet } from './category_facets';
import type { IntegrationsURLParameters } from './hooks/use_available_packages';
import type { CategoryFacet, ExtendedIntegrationCategory } from './category_facets';
import { CategoryFacets } from './category_facets';
import type { CategoryParams } from '.';
@ -113,8 +115,6 @@ const VerificationWarningCallout: React.FC = () => {
);
};
// 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 InstalledPackages: React.FC<{
installedPackages: PackageListItem[];
isLoading: boolean;
@ -125,34 +125,42 @@ export const InstalledPackages: React.FC<{
const { getHref, getAbsolutePath } = useLink();
const { selectedCategory, searchParam } = getParams(
const { selectedCategory: initialSelectedCategory, searchParam } = getParams(
useParams<CategoryParams>(),
useLocation().search
);
const [selectedCategory, setCategory] = useState(initialSelectedCategory);
const [searchTerm, setSearchTerm] = useState(searchParam || '');
const { http } = useStartServices();
const addBasePath = http.basePath.prepend;
const history = useHistory();
function setUrlCategory(categoryId: string) {
const buildUrl = ({ searchString, categoryId, subCategoryId }: IntegrationsURLParameters) => {
const url = pagePathGetters.integrations_installed({
category: categoryId,
searchTerm: searchParam,
category: categoryId ? categoryId : '',
query: searchString ? searchString : '',
})[1];
return url;
};
const setUrlandPushHistory = ({ searchString, categoryId }: IntegrationsURLParameters) => {
const url = buildUrl({
categoryId,
searchString,
});
history.push(url);
}
};
function setUrlSearchTerm(search: string) {
const setUrlandReplaceHistory = ({ searchString, categoryId }: IntegrationsURLParameters) => {
const url = buildUrl({
categoryId,
searchString,
});
// Use .replace so the browser's back button is not tied to single keystroke
history.replace(
pagePathGetters.integrations_installed({
searchTerm: search,
selectedCategory,
})[1]
);
}
history.replace(url);
};
const updatablePackages = useMemo(
() =>
@ -178,10 +186,7 @@ export const InstalledPackages: React.FC<{
);
if (!categoryExists(selectedCategory, categories)) {
history.replace(
pagePathGetters.integrations_installed({ category: '', searchTerm: searchParam })[1]
);
setUrlandReplaceHistory({ searchString: searchTerm, categoryId: '' });
return null;
}
@ -189,7 +194,11 @@ export const InstalledPackages: React.FC<{
<CategoryFacets
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={({ id }: CategoryFacet) => setUrlCategory(id)}
onCategoryChange={({ id }: CategoryFacet) => {
setCategory(id as ExtendedIntegrationCategory);
setSearchTerm('');
setUrlandPushHistory({ searchString: '', categoryId: id });
}}
/>
);
@ -221,9 +230,11 @@ export const InstalledPackages: React.FC<{
<PackageListGrid
{...{ isLoading, controls, callout, categories }}
selectedCategory={selectedCategory}
setSelectedCategory={setUrlCategory}
onSearchChange={setUrlSearchTerm}
initialSearch={searchParam}
setCategory={setCategory}
setUrlandPushHistory={setUrlandPushHistory}
setUrlandReplaceHistory={setUrlandReplaceHistory}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
list={cards}
/>
);

View file

@ -91,7 +91,7 @@ export const FLEET_ROUTING_PATHS = {
export const INTEGRATIONS_SEARCH_QUERYPARAM = 'q';
export const INTEGRATIONS_ROUTING_PATHS = {
integrations: '/:tabId',
integrations_all: '/browse/:category?',
integrations_all: '/browse/:category?/:subcategory?',
integrations_installed: '/installed/:category?',
integrations_installed_updates_available: '/installed/updates_available/:category?',
integration_details: '/detail/:pkgkey/:panel?',
@ -114,8 +114,21 @@ export const pagePathGetters: {
base: () => [FLEET_BASE_PATH, '/'],
overview: () => [FLEET_BASE_PATH, '/'],
integrations: () => [INTEGRATIONS_BASE_PATH, '/'],
integrations_all: ({ searchTerm, category }: { searchTerm?: string; category?: string }) => {
const categoryPath = category ? `/${category}` : ``;
integrations_all: ({
searchTerm,
category,
subCategory,
}: {
searchTerm?: string;
category?: string;
subCategory?: string;
}) => {
const categoryPath =
category && subCategory
? `/${category}/${subCategory} `
: category && !subCategory
? `/${category}`
: ``;
const queryParams = searchTerm ? `?${INTEGRATIONS_SEARCH_QUERYPARAM}=${searchTerm}` : ``;
return [INTEGRATIONS_BASE_PATH, `/browse${categoryPath}${queryParams}`];
},