[Security Solution] Group contract components into one service (#167517)

## Summary

This PR refactors the public plugin contract of Security Solution,
grouping exposed components into one component service to reduce the
boilerplate code needed when adding new components to the plugin
contract for serverless.

It also refactors the `isILMAvailable` flag that was exposed in the
_start_ contract by the `dataQualityPanelConfig`, which is more
self-explanatory, and it has been exposed in the _setup_ contract
without observable.

### Usage of `ContractComponentsService`

1. Define the component in ess or serverless plugins:

```
    securitySolution.setComponents({
      getStarted: getSecurityGetStartedComponent(services, productTypes),
      dashboardsLandingCallout: getDashboardsLandingCallout(services),
      // ... other components
    });
```

2.  Use the component in the main security plugin

```
  const { services: { getComponent$ } } = useKibana();
  const GetStartedComponent = useObservable(getComponent$('getStarted'));
  return <>{GetStartedComponent}</>;
```

Component names are defined at 
 

84583e4960/x-pack/plugins/security_solution/public/contract_components.ts (L11)

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sergi Massaneda 2023-09-29 16:54:33 +02:00 committed by GitHub
parent 5364b9f887
commit fbd820b6c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 107 additions and 53 deletions

View file

@ -16,10 +16,10 @@ jest.mock('../../lib/kibana', () => ({
useKibana: jest.fn(),
}));
jest.mock('react-use/lib/useObservable', () => jest.fn((fn) => fn()));
jest.mock('react-use/lib/useObservable', () => jest.fn((component) => component));
describe('LandingPageComponent', () => {
const mockGetStartedComponent = jest.fn();
const mockGetComponent = jest.fn();
const history = createBrowserHistory();
const mockSecuritySolutionTemplateWrapper = jest
.fn()
@ -39,7 +39,7 @@ describe('LandingPageComponent', () => {
securityLayout: {
getPluginWrapper: jest.fn().mockReturnValue(mockSecuritySolutionTemplateWrapper),
},
getStartedComponent$: mockGetStartedComponent,
getComponent$: mockGetComponent,
},
});
});
@ -49,7 +49,7 @@ describe('LandingPageComponent', () => {
});
it('renders the get started component', () => {
mockGetStartedComponent.mockReturnValue(<div data-test-subj="get-started" />);
mockGetComponent.mockReturnValue(<div data-test-subj="get-started" />);
const { queryByTestId } = renderPage();
expect(queryByTestId('get-started')).toBeInTheDocument();

View file

@ -10,8 +10,8 @@ import useObservable from 'react-use/lib/useObservable';
import { useKibana } from '../../lib/kibana';
export const LandingPageComponent = memo(() => {
const { getStartedComponent$ } = useKibana().services;
const GetStartedComponent = useObservable(getStartedComponent$);
const { getComponent$ } = useKibana().services;
const GetStartedComponent = useObservable(getComponent$('getStarted'));
return <>{GetStartedComponent}</>;
});

View file

@ -47,12 +47,11 @@ import { mockApm } from '../apm/service.mock';
import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks';
import { guidedOnboardingMock } from '@kbn/guided-onboarding-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { of } from 'rxjs';
import { UpsellingService } from '@kbn/security-solution-upselling/service';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
import { NavigationProvider } from '@kbn/security-solution-navigation';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { savedSearchPluginMock } from '@kbn/saved-search-plugin/public/mocks';
import { contractStartServicesMock } from '../../../mocks';
const mockUiSettings: Record<string, unknown> = {
[DEFAULT_TIME_RANGE]: { from: 'now-15m', to: 'now', mode: 'quick' },
@ -125,6 +124,7 @@ export const createStartServicesMock = (
return {
...core,
...contractStartServicesMock,
apm,
cases,
unifiedSearch,
@ -217,8 +217,6 @@ export const createStartServicesMock = (
...cloud,
isCloudEnabled: false,
},
isSidebarEnabled$: of(true),
upselling: new UpsellingService(),
customDataService,
uiActions: uiActionsPluginMock.createStartContract(),
savedSearch: savedSearchPluginMock.createStartContract(),

View file

@ -0,0 +1,33 @@
/*
* 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 { BehaviorSubject, map } from 'rxjs';
import type { Observable } from 'rxjs';
export type ContractComponentName = 'getStarted' | 'dashboardsLandingCallout';
export type ContractComponents = Partial<Record<ContractComponentName, React.ComponentType>>;
export type SetComponents = (components: ContractComponents) => void;
export type GetComponent$ = (
name: ContractComponentName
) => Observable<React.ComponentType | undefined>;
export class ContractComponentsService {
private components$: BehaviorSubject<ContractComponents>;
constructor() {
this.components$ = new BehaviorSubject<ContractComponents>({});
}
public setComponents: SetComponents = (components) => {
this.components$.next(components);
};
public getComponent$: GetComponent$ = (name) =>
this.components$.pipe(map((components) => components[name]));
}

View file

@ -83,8 +83,8 @@ const Header: React.FC<{ canCreateDashboard: boolean }> = ({ canCreateDashboard
};
export const DashboardsLandingPage = () => {
const { dashboardsLandingCalloutComponent$ } = useKibana().services;
const dashboardLandingCallout = useObservable(dashboardsLandingCalloutComponent$);
const { getComponent$ } = useKibana().services;
const dashboardLandingCallout = useObservable(getComponent$('dashboardsLandingCallout'));
const { links = [] } = useRootNavLink(SecurityPageName.dashboards) ?? {};
const urlState = useGlobalQueryString();
const { show: canReadDashboard, createNew: canCreateDashboard } =

View file

@ -5,30 +5,37 @@
* 2.0.
*/
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, of } from 'rxjs';
import { UpsellingService } from '@kbn/security-solution-upselling/service';
import type { BreadcrumbsNav } from './common/breadcrumbs';
import type { NavigationLink } from './common/links/types';
import type { PluginStart, PluginSetup } from './types';
import type { PluginStart, PluginSetup, ContractStartServices } from './types';
const upselling = new UpsellingService();
export const contractStartServicesMock: ContractStartServices = {
extraRoutes$: of([]),
isSidebarEnabled$: of(true),
getComponent$: jest.fn(),
upselling,
dataQualityPanelConfig: undefined,
};
const setupMock = (): PluginSetup => ({
resolver: jest.fn(),
setAppLinksSwitcher: jest.fn(),
setDataQualityPanelConfig: jest.fn(),
});
const upselling = new UpsellingService();
const startMock = (): PluginStart => ({
getNavLinks$: jest.fn(() => new BehaviorSubject<NavigationLink[]>([])),
setIsSidebarEnabled: jest.fn(),
setGetStartedPage: jest.fn(),
setIsILMAvailable: jest.fn(),
setComponents: jest.fn(),
getBreadcrumbsNav$: jest.fn(
() => new BehaviorSubject<BreadcrumbsNav>({ leading: [], trailing: [] })
),
setExtraRoutes: jest.fn(),
getUpselling: () => upselling,
setDashboardsLandingCallout: jest.fn(),
});
export const securitySolutionMock = {

View file

@ -8,6 +8,8 @@
import type { SecuritySubPlugin } from '../app/types';
import { routes } from './routes';
export * from './types';
export class Overview {
public setup() {}

View file

@ -29,7 +29,6 @@ import {
} from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import useObservable from 'react-use/lib/useObservable';
import { useAssistantAvailability } from '../../assistant/use_assistant_availability';
import { SecurityPageName } from '../../app/types';
@ -55,8 +54,10 @@ import type {
ReportDataQualityCheckAllCompletedParams,
ReportDataQualityIndexCheckedParams,
} from '../../common/lib/telemetry';
import type { DataQualityPanelConfig } from '../types';
const LOCAL_STORAGE_KEY = 'dataQualityDashboardLastChecked';
const defaultDataQualityPanelConfig: DataQualityPanelConfig = { isILMAvailable: true };
const comboBoxStyle: React.CSSProperties = {
width: '322px',
@ -157,8 +158,8 @@ const DataQualityComponent: React.FC = () => {
const [selectedOptions, setSelectedOptions] = useState<EuiComboBoxOptionOption[]>(defaultOptions);
const { indicesExist, loading: isSourcererLoading, selectedPatterns } = useSourcererDataView();
const { signalIndexName, loading: isSignalIndexNameLoading } = useSignalIndex();
const { isILMAvailable$, cases } = useKibana().services;
const isILMAvailable = useObservable(isILMAvailable$);
const { dataQualityPanelConfig = defaultDataQualityPanelConfig, cases } = useKibana().services;
const { isILMAvailable } = dataQualityPanelConfig;
const [startDate, setStartTime] = useState<string>();
const [endDate, setEndTime] = useState<string>();
@ -172,7 +173,7 @@ const DataQualityComponent: React.FC = () => {
};
useEffect(() => {
if (isILMAvailable != null && isILMAvailable === false) {
if (isILMAvailable === false) {
setStartTime(DEFAULT_START_TIME);
setEndTime(DEFAULT_END_TIME);
}
@ -255,7 +256,7 @@ const DataQualityComponent: React.FC = () => {
return (
<>
{indicesExist && isILMAvailable != null ? (
{indicesExist ? (
<SecuritySolutionPageWrapper data-test-subj="ecsDataQualityDashboardPage">
<HeaderPage subtitle={subtitle} title={i18n.DATA_QUALITY_TITLE}>
{isILMAvailable && (

View file

@ -0,0 +1,10 @@
/*
* 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 interface DataQualityPanelConfig {
isILMAvailable: boolean;
}

View file

@ -9,26 +9,24 @@ import { BehaviorSubject } from 'rxjs';
import type { RouteProps } from 'react-router-dom';
import { UpsellingService } from '@kbn/security-solution-upselling/service';
import type { ContractStartServices, PluginSetup, PluginStart } from './types';
import type { DataQualityPanelConfig } from './overview/types';
import type { AppLinksSwitcher } from './common/links';
import { navLinks$ } from './common/links/nav_links';
import { breadcrumbsNav$ } from './common/breadcrumbs';
import { ContractComponentsService } from './contract_components';
export class PluginContract {
public isILMAvailable$: BehaviorSubject<boolean>;
public isSidebarEnabled$: BehaviorSubject<boolean>;
public getStartedComponent$: BehaviorSubject<React.ComponentType | null>;
public dashboardsLandingCallout$: BehaviorSubject<React.ComponentType | null>;
public componentsService: ContractComponentsService;
public upsellingService: UpsellingService;
public extraRoutes$: BehaviorSubject<RouteProps[]>;
public appLinksSwitcher: AppLinksSwitcher;
public dataQualityPanelConfig?: DataQualityPanelConfig;
constructor() {
this.extraRoutes$ = new BehaviorSubject<RouteProps[]>([]);
this.isILMAvailable$ = new BehaviorSubject<boolean>(true);
this.isSidebarEnabled$ = new BehaviorSubject<boolean>(true);
this.getStartedComponent$ = new BehaviorSubject<React.ComponentType | null>(null);
this.dashboardsLandingCallout$ = new BehaviorSubject<React.ComponentType | null>(null);
this.componentsService = new ContractComponentsService();
this.upsellingService = new UpsellingService();
this.appLinksSwitcher = (appLinks) => appLinks;
}
@ -36,11 +34,10 @@ export class PluginContract {
public getStartServices(): ContractStartServices {
return {
extraRoutes$: this.extraRoutes$.asObservable(),
isILMAvailable$: this.isILMAvailable$.asObservable(),
isSidebarEnabled$: this.isSidebarEnabled$.asObservable(),
getStartedComponent$: this.getStartedComponent$.asObservable(),
dashboardsLandingCalloutComponent$: this.dashboardsLandingCallout$.asObservable(),
getComponent$: this.componentsService.getComponent$.bind(this.componentsService),
upselling: this.upsellingService,
dataQualityPanelConfig: this.dataQualityPanelConfig,
};
}
@ -50,6 +47,9 @@ export class PluginContract {
setAppLinksSwitcher: (appLinksSwitcher) => {
this.appLinksSwitcher = appLinksSwitcher;
},
setDataQualityPanelConfig: (dataQualityPanelConfig) => {
this.dataQualityPanelConfig = dataQualityPanelConfig;
},
};
}
@ -59,12 +59,8 @@ export class PluginContract {
setExtraRoutes: (extraRoutes) => this.extraRoutes$.next(extraRoutes),
setIsSidebarEnabled: (isSidebarEnabled: boolean) =>
this.isSidebarEnabled$.next(isSidebarEnabled),
setIsILMAvailable: (isILMAvailable: boolean) => this.isILMAvailable$.next(isILMAvailable),
setGetStartedPage: (getStartedComponent) => {
this.getStartedComponent$.next(getStartedComponent);
},
setDashboardsLandingCallout: (dashboardsLandingCallout) => {
this.dashboardsLandingCallout$.next(dashboardsLandingCallout);
setComponents: (components) => {
this.componentsService.setComponents(components);
},
getBreadcrumbsNav$: () => breadcrumbsNav$,
getUpselling: () => this.upsellingService,

View file

@ -78,6 +78,8 @@ import type { TelemetryClientStart } from './common/lib/telemetry';
import type { Dashboards } from './dashboards';
import type { BreadcrumbsNav } from './common/breadcrumbs/types';
import type { TopValuesPopoverService } from './app/components/top_values_popover/top_values_popover_service';
import type { DataQualityPanelConfig } from './overview/types';
import type { SetComponents, GetComponent$ } from './contract_components';
export interface SetupPlugins {
cloud?: CloudSetup;
@ -141,11 +143,10 @@ export interface StartPluginsDependencies extends StartPlugins {
export interface ContractStartServices {
extraRoutes$: Observable<RouteProps[]>;
isILMAvailable$: Observable<boolean>;
isSidebarEnabled$: Observable<boolean>;
getStartedComponent$: Observable<React.ComponentType | null>;
dashboardsLandingCalloutComponent$: Observable<React.ComponentType | null>;
getComponent$: GetComponent$;
upselling: UpsellingService;
dataQualityPanelConfig: DataQualityPanelConfig | undefined;
}
export type StartServices = CoreStart &
@ -173,15 +174,14 @@ export type StartServices = CoreStart &
export interface PluginSetup {
resolver: () => Promise<ResolverPluginSetup>;
setAppLinksSwitcher: (appLinksSwitcher: AppLinksSwitcher) => void;
setDataQualityPanelConfig: (dataQualityPanelConfig: DataQualityPanelConfig) => void;
}
export interface PluginStart {
getNavLinks$: () => Observable<NavigationLink[]>;
setExtraRoutes: (extraRoutes: RouteProps[]) => void;
setIsILMAvailable: (isILMAvailable: boolean) => void;
setIsSidebarEnabled: (isSidebarEnabled: boolean) => void;
setGetStartedPage: (getStartedComponent: React.ComponentType) => void;
setDashboardsLandingCallout: (dashboardsLandingCallout: React.ComponentType) => void;
setComponents: SetComponents;
getBreadcrumbsNav$: () => Observable<BreadcrumbsNav>;
getUpselling: () => UpsellingService;
}

View file

@ -28,8 +28,11 @@ export class SecuritySolutionEssPlugin
{
public setup(
_core: CoreSetup,
_setupDeps: SecuritySolutionEssPluginSetupDeps
setupDeps: SecuritySolutionEssPluginSetupDeps
): SecuritySolutionEssPluginSetup {
const { securitySolution } = setupDeps;
securitySolution.setDataQualityPanelConfig({ isILMAvailable: true });
return {};
}
@ -44,8 +47,9 @@ export class SecuritySolutionEssPlugin
registerUpsellings(securitySolution.getUpselling(), license, services);
});
securitySolution.setGetStartedPage(getSecurityGetStartedComponent(services));
securitySolution.setIsILMAvailable(true);
securitySolution.setComponents({
getStarted: getSecurityGetStartedComponent(services),
});
subscribeBreadcrumbs(services);

View file

@ -41,7 +41,9 @@ export class SecuritySolutionServerlessPlugin
_core: CoreSetup,
setupDeps: SecuritySolutionServerlessPluginSetupDeps
): SecuritySolutionServerlessPluginSetup {
setupDeps.securitySolution.setAppLinksSwitcher(projectAppLinksSwitcher);
const { securitySolution } = setupDeps;
securitySolution.setAppLinksSwitcher(projectAppLinksSwitcher);
securitySolution.setDataQualityPanelConfig({ isILMAvailable: false });
return {};
}
@ -57,9 +59,10 @@ export class SecuritySolutionServerlessPlugin
registerUpsellings(securitySolution.getUpselling(), this.config.productTypes, services);
securitySolution.setGetStartedPage(getSecurityGetStartedComponent(services, productTypes));
securitySolution.setDashboardsLandingCallout(getDashboardsLandingCallout(services));
securitySolution.setIsILMAvailable(false);
securitySolution.setComponents({
getStarted: getSecurityGetStartedComponent(services, productTypes),
dashboardsLandingCallout: getDashboardsLandingCallout(services),
});
configureNavigation(services, this.config);
setRoutes(services);