mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Security Solution][Endpoint] Improvements to policy selection component (#215417)
## Summary ### Fleet changes - Fixed `GetPackagePoliciesRequest` (was missing `query` options supported) ### Security Solution This PR refactors the Policy Selection UI component - used mainly with Artifact pages/forms - to remove the prior limitation of only displaying the first 1,000 policies in the system. This new version of the component is not connected to the API and allows a user to paginate through the list of policies in the system, including being able to search for policies while maintaining those that have been already selected. Some of the new features in this new component include: - Page through list of available policies - Search for policies (by default, it searches against `name`, `description`, `policy_ids` and `package.name`) - Ability to `select all` / `unselect all` policies currently displayed - Ability to view the already selected list of policies
This commit is contained in:
parent
faae1423f0
commit
12d78d9477
41 changed files with 3040 additions and 1959 deletions
|
@ -7,7 +7,10 @@
|
|||
|
||||
import type { TypeOf } from '@kbn/config-schema';
|
||||
|
||||
import type { BulkGetPackagePoliciesRequestSchema } from '../../../server/types';
|
||||
import type {
|
||||
BulkGetPackagePoliciesRequestSchema,
|
||||
GetPackagePoliciesRequestSchema,
|
||||
} from '../../../server/types';
|
||||
|
||||
import type {
|
||||
PackagePolicy,
|
||||
|
@ -19,10 +22,10 @@ import type {
|
|||
} from '../models';
|
||||
import type { inputsFormat } from '../../constants';
|
||||
|
||||
import type { BulkGetResult, ListResult, ListWithKuery } from './common';
|
||||
import type { BulkGetResult, ListResult } from './common';
|
||||
|
||||
export interface GetPackagePoliciesRequest {
|
||||
query: ListWithKuery;
|
||||
query: TypeOf<typeof GetPackagePoliciesRequestSchema.query>;
|
||||
}
|
||||
|
||||
export type GetPackagePoliciesResponse = ListResult<PackagePolicy>;
|
||||
|
|
|
@ -23,7 +23,7 @@ import {
|
|||
import type { Action, Reducer, Store } from 'redux';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import { PLUGIN_ID } from '@kbn/fleet-plugin/common';
|
||||
import { INTEGRATIONS_PLUGIN_ID, PLUGIN_ID } from '@kbn/fleet-plugin/common';
|
||||
import type { UseBaseQueryResult } from '@tanstack/react-query';
|
||||
import ReactDOM from 'react-dom';
|
||||
import type { DeepReadonly } from 'utility-types';
|
||||
|
@ -436,18 +436,28 @@ const createCoreStartMock = (
|
|||
|
||||
const linkPaths = getLinksPaths(appLinks);
|
||||
|
||||
// Mock the certain APP Ids returned by `application.getUrlForApp()`
|
||||
coreStart.application.getUrlForApp.mockImplementation((appId, { deepLinkId, path } = {}) => {
|
||||
// Mock certain APP Ids returned by `application.getUrlForApp()`
|
||||
coreStart.application.getUrlForApp.mockImplementation((appId, { deepLinkId, path = '' } = {}) => {
|
||||
let appUrl: string = '';
|
||||
|
||||
switch (appId) {
|
||||
case PLUGIN_ID:
|
||||
return '/app/fleet';
|
||||
appUrl = '/app/fleet';
|
||||
break;
|
||||
case INTEGRATIONS_PLUGIN_ID:
|
||||
appUrl = '/app/integrations';
|
||||
break;
|
||||
|
||||
case APP_UI_ID:
|
||||
return `${APP_PATH}${deepLinkId && linkPaths[deepLinkId] ? linkPaths[deepLinkId] : ''}${
|
||||
path ?? ''
|
||||
}`;
|
||||
appUrl = `${APP_PATH}${deepLinkId && linkPaths[deepLinkId] ? linkPaths[deepLinkId] : ''}`;
|
||||
break;
|
||||
|
||||
default:
|
||||
return `${appId} not mocked!`;
|
||||
appUrl = `app-id-${appId}-not-mocked!`;
|
||||
break;
|
||||
}
|
||||
|
||||
return `${appUrl}${path}`;
|
||||
});
|
||||
|
||||
coreStart.application.navigateToApp.mockImplementation((appId, { deepLinkId, path } = {}) => {
|
||||
|
|
|
@ -13,6 +13,8 @@ import { act } from '@testing-library/react';
|
|||
|
||||
class ApiRouteNotMocked extends Error {}
|
||||
|
||||
const MOCK_REGISTERED_HANDLERS = Symbol('registered_mock_handlers');
|
||||
|
||||
// Source: https://stackoverflow.com/a/43001581
|
||||
type Writeable<T> = { -readonly [P in keyof T]: T[P] };
|
||||
|
||||
|
@ -82,7 +84,16 @@ const HTTP_METHODS: HttpMethods[] = ['delete', 'fetch', 'get', 'post', 'put', 'h
|
|||
|
||||
export type ApiHandlerMock<R extends ResponseProvidersInterface = ResponseProvidersInterface> = (
|
||||
http: jest.Mocked<HttpStart>,
|
||||
options?: { ignoreUnMockedApiRouteErrors?: boolean }
|
||||
options?: {
|
||||
/**
|
||||
* If an error should be thrown for an API route that might not have a mock defined for it.
|
||||
* Set it to true during development to understand which APIs are being called an not handled.
|
||||
* Default is `false`
|
||||
*/
|
||||
ignoreUnMockedApiRouteErrors?: boolean;
|
||||
/** Set to `true` during development to have additional logs in the run output */
|
||||
debug?: boolean;
|
||||
}
|
||||
) => MockedApi<R>;
|
||||
|
||||
interface RouteMock<R extends ResponseProvidersInterface = ResponseProvidersInterface> {
|
||||
|
@ -135,7 +146,13 @@ export const httpHandlerMockFactory = <R extends ResponseProvidersInterface = {}
|
|||
): ApiHandlerMock<R> => {
|
||||
return (http, options) => {
|
||||
let inflightApiCalls = 0;
|
||||
const { ignoreUnMockedApiRouteErrors = false } = options ?? {};
|
||||
const { ignoreUnMockedApiRouteErrors = false, debug = false } = options ?? {};
|
||||
const log: (typeof console)['log'] = (...data) => {
|
||||
if (debug) {
|
||||
window.console.log('httpHandlerMockFactory(): ', ...data);
|
||||
}
|
||||
};
|
||||
|
||||
const apiDoneListeners: Array<() => void> = [];
|
||||
const markApiCallAsInFlight = () => {
|
||||
inflightApiCalls++;
|
||||
|
@ -174,6 +191,8 @@ export const httpHandlerMockFactory = <R extends ResponseProvidersInterface = {}
|
|||
|
||||
const mockedApiInterface: MockedApi<R> = {
|
||||
async waitForApi() {
|
||||
log('waiting for all inflight apis to complete');
|
||||
|
||||
await act(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (inflightApiCalls > 0) {
|
||||
|
@ -191,11 +210,19 @@ export const httpHandlerMockFactory = <R extends ResponseProvidersInterface = {}
|
|||
const priorMockedFunction = http[method].getMockImplementation();
|
||||
const methodMocks = mocks.filter((mock) => mock.method === method);
|
||||
|
||||
// For debugging, store the list of mock handlers in the HTTP method
|
||||
// @ts-expect-error TS7053: Element implicitly has an any type because expression of type unique symbol can't be used to index type
|
||||
http[method][MOCK_REGISTERED_HANDLERS] = http[method][MOCK_REGISTERED_HANDLERS] ?? [];
|
||||
// @ts-expect-error TS7053: Element implicitly has an any type because expression of type unique symbol can't be used to index type
|
||||
http[method][MOCK_REGISTERED_HANDLERS].push(...methodMocks);
|
||||
|
||||
http[method].mockImplementation(async (...args) => {
|
||||
const path = isHttpFetchOptionsWithPath(args[0]) ? args[0].path : args[0];
|
||||
const routeMock = methodMocks.find((handler) => pathMatchesPattern(handler.path, path));
|
||||
|
||||
if (routeMock) {
|
||||
log(`Found mock handler for route [${path}]: ${JSON.stringify(routeMock, null, 2)}`);
|
||||
|
||||
// Use the handler defined for the HTTP Mocked interface (not the one passed on input to
|
||||
// the factory) for retrieving the response value because that one could have had its
|
||||
// response value manipulated by the individual test case.
|
||||
|
@ -222,6 +249,13 @@ export const httpHandlerMockFactory = <R extends ResponseProvidersInterface = {}
|
|||
return thisRouteResponseProvider(fetchOptions);
|
||||
} catch (err) {
|
||||
err.stack += `\n${testContextStackTrace}`;
|
||||
|
||||
log(
|
||||
`API mock [${routeMock.id as string}] for route [${routeMock.method.toUpperCase()} ${
|
||||
routeMock.path
|
||||
}] threw an error (if unexpected, register API mocks with 'debug' set to true to get more info)`
|
||||
);
|
||||
|
||||
return Promise.reject(err);
|
||||
} finally {
|
||||
markApiCallAsHandled();
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { OperatingSystem } from '@kbn/securitysolution-utils';
|
||||
import type { ServerApiError } from '../../common/types';
|
||||
|
||||
export const ENDPOINTS_TAB = i18n.translate('xpack.securitySolution.endpointsTab', {
|
||||
defaultMessage: 'Endpoints',
|
||||
|
@ -37,7 +36,7 @@ export const OS_TITLES: Readonly<{ [K in OperatingSystem]: string }> = {
|
|||
}),
|
||||
};
|
||||
|
||||
export const getLoadPoliciesError = (error: ServerApiError) => {
|
||||
export const getLoadPoliciesError = (error: Error) => {
|
||||
return i18n.translate('xpack.securitySolution.exceptions.failedLoadPolicies', {
|
||||
defaultMessage: 'There was an error loading policies: "{error}"',
|
||||
values: { error: error.message },
|
||||
|
|
|
@ -41,8 +41,6 @@ import { useUrlParams } from '../../hooks/use_url_params';
|
|||
import type { ListPageRouteState, MaybeImmutable } from '../../../../common/endpoint/types';
|
||||
import { DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS } from '../../../../common/endpoint/service/artifacts/constants';
|
||||
import { ArtifactDeleteModal } from './components/artifact_delete_modal';
|
||||
import { useGetEndpointSpecificPolicies } from '../../services/policies/hooks';
|
||||
import { getLoadPoliciesError } from '../../common/translations';
|
||||
import { useToasts } from '../../../common/lib/kibana';
|
||||
import { useMemoizedRouteState } from '../../common/hooks';
|
||||
import { BackToExternalAppSecondaryButton } from '../back_to_external_app_secondary_button';
|
||||
|
@ -165,13 +163,6 @@ export const ArtifactListPage = memo<ArtifactListPageProps>(
|
|||
allowCardEditAction,
|
||||
});
|
||||
|
||||
const policiesRequest = useGetEndpointSpecificPolicies({
|
||||
perPage: 1000,
|
||||
onError: (err) => {
|
||||
toasts.addWarning(getLoadPoliciesError(err));
|
||||
},
|
||||
});
|
||||
|
||||
const memoizedRouteState = useMemoizedRouteState(routeState);
|
||||
|
||||
const backButtonEmptyComponent = useMemo(() => {
|
||||
|
@ -299,8 +290,6 @@ export const ArtifactListPage = memo<ArtifactListPageProps>(
|
|||
labels={labels}
|
||||
size={flyoutSize}
|
||||
submitHandler={onFormSubmit}
|
||||
policies={policiesRequest.data?.items || []}
|
||||
policiesIsLoading={policiesRequest.isLoading}
|
||||
data-test-subj={getTestId('flyout')}
|
||||
/>
|
||||
)}
|
||||
|
@ -335,7 +324,6 @@ export const ArtifactListPage = memo<ArtifactListPageProps>(
|
|||
onSearch={handleOnSearch}
|
||||
placeholder={labels.searchPlaceholderInfo}
|
||||
hasPolicyFilter
|
||||
policyList={policiesRequest.data?.items}
|
||||
defaultIncludedPolicies={includedPolicies}
|
||||
/>
|
||||
|
||||
|
|
|
@ -117,8 +117,6 @@ describe('When the flyout is opened in the ArtifactListPage component', () => {
|
|||
},
|
||||
mode: 'create',
|
||||
onChange: expect.any(Function),
|
||||
policies: expect.any(Array),
|
||||
policiesIsLoading: false,
|
||||
},
|
||||
expect.anything()
|
||||
);
|
||||
|
|
|
@ -47,7 +47,6 @@ import { createExceptionListItemForCreate } from '../../../../../common/endpoint
|
|||
import { useWithArtifactSubmitData } from '../hooks/use_with_artifact_submit_data';
|
||||
import { useIsArtifactAllowedPerPolicyUsage } from '../hooks/use_is_artifact_allowed_per_policy_usage';
|
||||
import { useGetArtifact } from '../../../hooks/artifacts';
|
||||
import type { PolicyData } from '../../../../../common/endpoint/types';
|
||||
import { ArtifactConfirmModal } from './artifact_confirm_modal';
|
||||
import { useUserPrivileges } from '../../../../common/components/user_privileges';
|
||||
|
||||
|
@ -162,8 +161,6 @@ const createFormInitialState = (
|
|||
export interface ArtifactFlyoutProps {
|
||||
apiClient: ExceptionsListApiClient;
|
||||
FormComponent: React.ComponentType<ArtifactFormComponentProps>;
|
||||
policies: PolicyData[];
|
||||
policiesIsLoading: boolean;
|
||||
onSuccess(): void;
|
||||
onClose(): void;
|
||||
submitHandler?: (
|
||||
|
@ -188,8 +185,6 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
|
|||
({
|
||||
apiClient,
|
||||
item,
|
||||
policies,
|
||||
policiesIsLoading,
|
||||
FormComponent,
|
||||
onSuccess,
|
||||
onClose,
|
||||
|
@ -493,8 +488,6 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
|
|||
item={formState.item}
|
||||
error={submitError ?? undefined}
|
||||
mode={formMode}
|
||||
policies={policies}
|
||||
policiesIsLoading={policiesIsLoading}
|
||||
/>
|
||||
)}
|
||||
</EuiFlyoutBody>
|
||||
|
|
|
@ -6,11 +6,13 @@
|
|||
*/
|
||||
|
||||
import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import type { PolicyData } from '../../../../../common/endpoint/types';
|
||||
import { useBulkFetchFleetIntegrationPolicies } from '../../../hooks/policy/use_bulk_fetch_fleet_integration_policies';
|
||||
import { getPolicyIdsFromArtifact } from '../../../../../common/endpoint/service/artifacts';
|
||||
import type { AnyArtifact, ArtifactEntryCardProps } from '../../artifact_entry_card';
|
||||
import { useEndpointPoliciesToArtifactPolicies } from '../../artifact_entry_card';
|
||||
import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
|
||||
import { useGetEndpointSpecificPolicies } from '../../../services/policies/hooks';
|
||||
import { getLoadPoliciesError } from '../../../common/translations';
|
||||
import { useToasts } from '../../../../common/lib/kibana';
|
||||
|
||||
|
@ -44,11 +46,14 @@ export const useArtifactCardPropsProvider = ({
|
|||
const getTestId = useTestIdGenerator(dataTestSubj);
|
||||
const toasts = useToasts();
|
||||
|
||||
const { data: policyData } = useGetEndpointSpecificPolicies({
|
||||
onError: (error) => {
|
||||
toasts.addDanger(getLoadPoliciesError(error));
|
||||
},
|
||||
});
|
||||
const itemsPolicyIds = useMemo(() => {
|
||||
return items.map((item) => getPolicyIdsFromArtifact(item)).flat();
|
||||
}, [items]);
|
||||
|
||||
const { data: policyData, error } = useBulkFetchFleetIntegrationPolicies<PolicyData>(
|
||||
{ ids: itemsPolicyIds },
|
||||
{ enabled: itemsPolicyIds.length > 0 }
|
||||
);
|
||||
|
||||
const policies: ArtifactEntryCardProps['policies'] = useEndpointPoliciesToArtifactPolicies(
|
||||
policyData?.items
|
||||
|
@ -107,6 +112,12 @@ export const useArtifactCardPropsProvider = ({
|
|||
cardActionDeleteLabel,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toasts.addDanger(getLoadPoliciesError(error));
|
||||
}
|
||||
}, [error, toasts]);
|
||||
|
||||
return useCallback(
|
||||
(artifactItem: ExceptionListItemSchema) => {
|
||||
return artifactCardPropsPerItem[artifactItem.id];
|
||||
|
|
|
@ -149,14 +149,6 @@ describe('When using the ArtifactListPage component', () => {
|
|||
expect(getAllByText('mock decorator')).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('should call useGetEndpointSpecificPolicies hook with specific perPage value', () => {
|
||||
expect(mockUseGetEndpointSpecificPolicies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
perPage: 1000,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('and interacting with card actions', () => {
|
||||
const clickCardAction = async (action: 'edit' | 'delete') => {
|
||||
await getFirstCard({ showActions: true });
|
||||
|
@ -252,7 +244,7 @@ describe('When using the ArtifactListPage component', () => {
|
|||
|
||||
it('should persist policy filter to the URL params', async () => {
|
||||
const policyId = mockedApi.responseProvider.endpointPackagePolicyList().items[0].id;
|
||||
const firstPolicyTestId = `policiesSelector-popover-items-${policyId}`;
|
||||
const firstPolicyTestId = `policiesSelectorButton-policySelector-policy-${policyId}`;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderResult.getByTestId('policiesSelectorButton')).toBeTruthy();
|
||||
|
|
|
@ -10,7 +10,6 @@ import type {
|
|||
ExceptionListItemSchema,
|
||||
CreateExceptionListItemSchema,
|
||||
} from '@kbn/securitysolution-io-ts-list-types';
|
||||
import type { PolicyData } from '../../../../common/endpoint/types';
|
||||
|
||||
export interface ArtifactListPageUrlParams {
|
||||
/** The page number for the list. Must be 1 based. */
|
||||
|
@ -29,10 +28,6 @@ export interface ArtifactFormComponentProps {
|
|||
disabled: boolean;
|
||||
/** Error will be set if the submission of the form to the api results in an API error. Form can use it to provide feedback to the user */
|
||||
error: IHttpFetchError | undefined;
|
||||
|
||||
policies: PolicyData[];
|
||||
policiesIsLoading: boolean;
|
||||
|
||||
/** reports the state of the form data and the current updated item */
|
||||
onChange(formStatus: ArtifactFormComponentOnChangeCallbackProps): void;
|
||||
}
|
||||
|
|
|
@ -10,7 +10,6 @@ import { EffectedPolicySelect } from './effected_policy_select';
|
|||
import React from 'react';
|
||||
import { forceHTMLElementOffsetWidth } from './test_utils';
|
||||
import { fireEvent, act, waitFor } from '@testing-library/react';
|
||||
import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data';
|
||||
import type { AppContextTestRender } from '../../../common/mock/endpoint';
|
||||
import { createAppRootMockRenderer } from '../../../common/mock/endpoint';
|
||||
import { useUserPrivileges } from '../../../common/components/user_privileges';
|
||||
|
@ -21,8 +20,8 @@ import { useLicense as _useLicense } from '../../../common/hooks/use_license';
|
|||
import type { LicenseService } from '../../../../common/license';
|
||||
import { buildPerPolicyTag } from '../../../../common/endpoint/service/artifacts/utils';
|
||||
import { ARTIFACT_POLICIES_NOT_ACCESSIBLE_IN_ACTIVE_SPACE_MESSAGE } from '../../common/translations';
|
||||
import { fleetBulkGetPackagePoliciesListHttpMock } from '../../mocks';
|
||||
import { FleetPackagePolicyGenerator } from '../../../../common/endpoint/data_generators/fleet_package_policy_generator';
|
||||
import { allFleetHttpMocks } from '../../mocks';
|
||||
import { policySelectorMocks } from '../policy_selector/mocks';
|
||||
|
||||
jest.mock('../../../common/components/user_privileges');
|
||||
jest.mock('../../../common/hooks/use_license');
|
||||
|
@ -30,21 +29,18 @@ jest.mock('../../../common/hooks/use_license');
|
|||
const useLicenseMock = _useLicense as jest.Mock;
|
||||
|
||||
describe('when using EffectedPolicySelect component', () => {
|
||||
const generator = new EndpointDocGenerator('effected-policy-select');
|
||||
|
||||
let mockedContext: AppContextTestRender;
|
||||
let componentProps: EffectedPolicySelectProps;
|
||||
let handleOnChange: jest.MockedFunction<EffectedPolicySelectProps['onChange']>;
|
||||
let renderResult: ReturnType<AppContextTestRender['render']>;
|
||||
let apiMocks: ReturnType<typeof allFleetHttpMocks>;
|
||||
let render: (
|
||||
props?: Partial<EffectedPolicySelectProps>
|
||||
) => Promise<ReturnType<AppContextTestRender['render']>>;
|
||||
let policyId: string;
|
||||
// Note: testUtils will only be set after render()
|
||||
let policySelectorTestUtils: ReturnType<typeof policySelectorMocks.getTestHelpers>;
|
||||
|
||||
const handleOnChange: jest.MockedFunction<EffectedPolicySelectProps['onChange']> = jest.fn();
|
||||
const render = (props: Partial<EffectedPolicySelectProps> = {}) => {
|
||||
componentProps = {
|
||||
...componentProps,
|
||||
...props,
|
||||
};
|
||||
renderResult = mockedContext.render(<EffectedPolicySelect {...componentProps} />);
|
||||
return renderResult;
|
||||
};
|
||||
let resetHTMLElementOffsetWidth: () => void;
|
||||
|
||||
beforeAll(() => {
|
||||
|
@ -55,17 +51,39 @@ describe('when using EffectedPolicySelect component', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
mockedContext = createAppRootMockRenderer();
|
||||
apiMocks = allFleetHttpMocks(mockedContext.coreStart.http);
|
||||
handleOnChange = jest.fn((updatedArtifact) => {
|
||||
componentProps.item = updatedArtifact;
|
||||
renderResult.rerender(<EffectedPolicySelect {...componentProps} />);
|
||||
});
|
||||
|
||||
policyId = apiMocks.responseProvider.packagePolicies().items[0].id;
|
||||
|
||||
// Default props
|
||||
componentProps = {
|
||||
item: new ExceptionsListItemGenerator('seed').generateTrustedApp({
|
||||
tags: [GLOBAL_ARTIFACT_TAG],
|
||||
}),
|
||||
options: [],
|
||||
onChange: handleOnChange,
|
||||
'data-test-subj': 'test',
|
||||
};
|
||||
|
||||
render = async (
|
||||
props: Partial<EffectedPolicySelectProps> = {}
|
||||
): Promise<ReturnType<AppContextTestRender['render']>> => {
|
||||
componentProps = {
|
||||
...componentProps,
|
||||
...props,
|
||||
};
|
||||
renderResult = mockedContext.render(<EffectedPolicySelect {...componentProps} />);
|
||||
policySelectorTestUtils = policySelectorMocks.getTestHelpers(
|
||||
`${componentProps['data-test-subj']!}-policiesSelector`,
|
||||
renderResult
|
||||
);
|
||||
|
||||
return renderResult;
|
||||
};
|
||||
|
||||
(useLicenseMock() as jest.Mocked<LicenseService>).isPlatinumPlus.mockReturnValue(true);
|
||||
});
|
||||
|
||||
|
@ -73,207 +91,176 @@ describe('when using EffectedPolicySelect component', () => {
|
|||
handleOnChange.mockClear();
|
||||
});
|
||||
|
||||
describe('and no policy entries exist', () => {
|
||||
it('should display no options available message', () => {
|
||||
componentProps.item.tags = [];
|
||||
const { getByTestId } = render();
|
||||
const euiSelectableMessageElement =
|
||||
getByTestId('test-policiesSelectable').getElementsByClassName('euiSelectableMessage')[0];
|
||||
expect(euiSelectableMessageElement).not.toBeNull();
|
||||
expect(euiSelectableMessageElement.textContent).toEqual('No options available');
|
||||
const clickOnGlobalButton = () => {
|
||||
act(() => {
|
||||
fireEvent.click(renderResult.getByTestId('test-global'));
|
||||
});
|
||||
};
|
||||
|
||||
const clickOnPerPolicyButton = () => {
|
||||
act(() => {
|
||||
fireEvent.click(renderResult.getByTestId('test-perPolicy'));
|
||||
});
|
||||
};
|
||||
|
||||
it('should display button group with Global and Per-Policy choices', async () => {
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect(getByTestId('test-global').textContent).toEqual('Global');
|
||||
expect(getByTestId('test-perPolicy').textContent).toEqual('Per Policy');
|
||||
});
|
||||
|
||||
it('should show Global as current selection when artifact is global', async () => {
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect(getByTestId('test-global').getAttribute('aria-pressed')).toEqual('true');
|
||||
});
|
||||
|
||||
it('should show Per Policy as current selection when artifact is per-policy', async () => {
|
||||
componentProps.item.tags = [];
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect(getByTestId('test-perPolicy').getAttribute('aria-pressed')).toEqual('true');
|
||||
});
|
||||
|
||||
it('should hide policy selection area when artifact is global', async () => {
|
||||
const { queryByTestId } = await render();
|
||||
expect(queryByTestId('test-policiesSelector')).toBeNull();
|
||||
});
|
||||
|
||||
it('should show policy items when user clicks per-policy', async () => {
|
||||
const { getByTestId } = await render();
|
||||
clickOnPerPolicyButton();
|
||||
await policySelectorTestUtils.waitForDataToLoad();
|
||||
|
||||
expect(getByTestId('test-policiesSelector')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should display custom description', async () => {
|
||||
componentProps.description = 'custom here';
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect(getByTestId('test-description').textContent).toEqual('custom here');
|
||||
});
|
||||
|
||||
it('should show button group as disabled', async () => {
|
||||
componentProps.disabled = true;
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect((getByTestId('test-byPolicyGlobalButtonGroup') as HTMLFieldSetElement).disabled).toEqual(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should call onChange when artifact is set to Per-Policy', async () => {
|
||||
await render();
|
||||
clickOnPerPolicyButton();
|
||||
await policySelectorTestUtils.waitForDataToLoad();
|
||||
|
||||
expect(handleOnChange).toHaveBeenCalledWith(expect.objectContaining({ tags: [] }));
|
||||
});
|
||||
|
||||
it('should call onChange when Per Policy artifact is set to Global', async () => {
|
||||
componentProps.item.tags = [];
|
||||
await render();
|
||||
clickOnGlobalButton();
|
||||
|
||||
expect(handleOnChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tags: [GLOBAL_ARTIFACT_TAG] })
|
||||
);
|
||||
});
|
||||
|
||||
it('should call onChange when policy selection changes', async () => {
|
||||
componentProps.item.tags = [];
|
||||
await render();
|
||||
await policySelectorTestUtils.waitForDataToLoad();
|
||||
policySelectorTestUtils.clickOnPolicy(policyId);
|
||||
|
||||
expect(handleOnChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tags: [buildPerPolicyTag(policyId)] })
|
||||
);
|
||||
});
|
||||
|
||||
describe('and when license downgrades below Platinum', () => {
|
||||
let policyIdList: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
(useLicenseMock() as jest.Mocked<LicenseService>).isPlatinumPlus.mockReturnValue(false);
|
||||
componentProps.item.tags = [buildPerPolicyTag(policyId)];
|
||||
|
||||
policyIdList = apiMocks.responseProvider.packagePolicies().items.map((policy) => policy.id);
|
||||
});
|
||||
|
||||
it('should maintain policy assignments but disable the ability to select/unselect policies', async () => {
|
||||
await render();
|
||||
await policySelectorTestUtils.waitForDataToLoad();
|
||||
|
||||
expect(policySelectorTestUtils.isPolicySelected(policyId)).toBe(true);
|
||||
|
||||
policyIdList.forEach((id) => {
|
||||
expect(policySelectorTestUtils.isPolicyDisabled(id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow the user to select 'Global' in the edit option", async () => {
|
||||
const { getByTestId } = await render();
|
||||
await policySelectorTestUtils.waitForDataToLoad();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(getByTestId('test-global'));
|
||||
});
|
||||
|
||||
expect(componentProps.onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tags: [GLOBAL_ARTIFACT_TAG] })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and policy entries exist', () => {
|
||||
const policyId = 'abc123';
|
||||
const policyTestSubj = `policy-${policyId}`;
|
||||
|
||||
const selectGlobalPolicy = () => {
|
||||
act(() => {
|
||||
fireEvent.click(renderResult.getByTestId('test-global'));
|
||||
});
|
||||
};
|
||||
|
||||
const selectPerPolicy = () => {
|
||||
act(() => {
|
||||
fireEvent.click(renderResult.getByTestId('test-perPolicy'));
|
||||
});
|
||||
};
|
||||
|
||||
const clickOnPolicy = () => {
|
||||
act(() => {
|
||||
fireEvent.click(renderResult.getByTestId(policyTestSubj));
|
||||
});
|
||||
};
|
||||
|
||||
describe('and space awareness is enabled', () => {
|
||||
beforeEach(() => {
|
||||
const policy = generator.generatePolicyPackagePolicy();
|
||||
policy.name = 'test policy A';
|
||||
policy.id = policyId;
|
||||
|
||||
componentProps = {
|
||||
...componentProps,
|
||||
options: [policy],
|
||||
};
|
||||
|
||||
handleOnChange.mockImplementation((updatedItem) => {
|
||||
componentProps = {
|
||||
...componentProps,
|
||||
item: updatedItem,
|
||||
};
|
||||
renderResult.rerender(<EffectedPolicySelect {...componentProps} />);
|
||||
mockedContext.setExperimentalFlag({ endpointManagementSpaceAwarenessEnabled: true });
|
||||
componentProps.item.tags = [buildPerPolicyTag('policy-321-not-in-space')];
|
||||
apiMocks.responseProvider.bulkPackagePolicies.mockReturnValue({
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should display policies', () => {
|
||||
componentProps.item.tags = [];
|
||||
const { getByTestId } = render();
|
||||
expect(getByTestId(policyTestSubj));
|
||||
});
|
||||
it('should display count of policies assigned to artifact that are not accessible in active space', async () => {
|
||||
const { getByTestId } = await render();
|
||||
|
||||
it('should hide policy items if global is checked', () => {
|
||||
const { queryByTestId } = render();
|
||||
expect(queryByTestId(policyTestSubj)).toBeNull();
|
||||
});
|
||||
|
||||
it('should show policy items when user clicks per-policy', async () => {
|
||||
const { getByTestId } = render();
|
||||
selectPerPolicy();
|
||||
|
||||
expect(getByTestId(policyTestSubj)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should call onChange with updated item', () => {
|
||||
render();
|
||||
|
||||
selectPerPolicy();
|
||||
expect(handleOnChange.mock.calls[0][0]).toEqual({
|
||||
...componentProps.item,
|
||||
tags: [],
|
||||
});
|
||||
|
||||
selectGlobalPolicy();
|
||||
expect(handleOnChange.mock.calls[1][0]).toEqual(componentProps.item);
|
||||
});
|
||||
|
||||
it('should maintain policies selection even if global was checked, and user switched back to per policy', () => {
|
||||
const { debug } = render();
|
||||
debug(undefined, 999999);
|
||||
|
||||
selectPerPolicy();
|
||||
clickOnPolicy();
|
||||
// FYI: If wondering why `componentProps.item` is being used successfully here and below:
|
||||
// its because `handlOnChange` is setup above to re-render the component everytime an update
|
||||
// is received, thus it will always reflect the latest state of the artifact
|
||||
expect(handleOnChange).toHaveBeenLastCalledWith(componentProps.item);
|
||||
|
||||
// Toggle isGlobal back to True
|
||||
selectGlobalPolicy();
|
||||
expect(handleOnChange).toHaveBeenLastCalledWith(componentProps.item);
|
||||
});
|
||||
|
||||
it('should show loader only when by policy selected', () => {
|
||||
componentProps.isLoading = true;
|
||||
const { queryByTestId, getByTestId, rerender } = render();
|
||||
expect(queryByTestId('loading-spinner')).toBeNull();
|
||||
|
||||
componentProps.item = {
|
||||
...componentProps.item,
|
||||
tags: [],
|
||||
};
|
||||
rerender(<EffectedPolicySelect {...componentProps} />);
|
||||
|
||||
expect(getByTestId('loading-spinner')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should hide policy link when no policy management privileges', () => {
|
||||
(useUserPrivileges as jest.Mock).mockReturnValue({
|
||||
...initialUserPrivilegesState(),
|
||||
endpointPrivileges: {
|
||||
loading: false,
|
||||
canWritePolicyManagement: false,
|
||||
canReadPolicyManagement: false,
|
||||
},
|
||||
});
|
||||
componentProps.item.tags = [];
|
||||
const { queryByTestId } = render();
|
||||
expect(queryByTestId('test-policyLink')).toBeNull();
|
||||
});
|
||||
|
||||
it('should show policy link when all policy management privileges', () => {
|
||||
(useUserPrivileges as jest.Mock).mockReturnValue({
|
||||
...initialUserPrivilegesState(),
|
||||
endpointPrivileges: {
|
||||
loading: false,
|
||||
canWritePolicyManagement: true,
|
||||
canReadPolicyManagement: true,
|
||||
},
|
||||
});
|
||||
componentProps.item.tags = [];
|
||||
const { getByTestId } = render();
|
||||
expect(getByTestId('test-policyLink'));
|
||||
});
|
||||
|
||||
it('should show policy link when read policy management privileges', () => {
|
||||
(useUserPrivileges as jest.Mock).mockReturnValue({
|
||||
...initialUserPrivilegesState(),
|
||||
endpointPrivileges: {
|
||||
loading: false,
|
||||
canWritePolicyManagement: false,
|
||||
canReadPolicyManagement: true,
|
||||
},
|
||||
});
|
||||
componentProps.item.tags = [];
|
||||
const { getByTestId } = render();
|
||||
expect(getByTestId('test-policyLink'));
|
||||
});
|
||||
|
||||
describe('and space awareness is enabled', () => {
|
||||
let httpMocks: ReturnType<typeof fleetBulkGetPackagePoliciesListHttpMock>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedContext.setExperimentalFlag({ endpointManagementSpaceAwarenessEnabled: true });
|
||||
componentProps.item.tags = [buildPerPolicyTag('321')];
|
||||
httpMocks = fleetBulkGetPackagePoliciesListHttpMock(mockedContext.coreStart.http);
|
||||
httpMocks.responseProvider.bulkPackagePolicies.mockReturnValue({
|
||||
items: [new FleetPackagePolicyGenerator('seed').generate({ id: 'abc123' })],
|
||||
});
|
||||
});
|
||||
|
||||
it('should display count of policies assigned to artifact that are not accessible in active space', async () => {
|
||||
const { getByTestId } = render();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('test-unAccessiblePoliciesCallout').textContent).toEqual(
|
||||
ARTIFACT_POLICIES_NOT_ACCESSIBLE_IN_ACTIVE_SPACE_MESSAGE(1)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable global button if user has no global artifact privilege', async () => {
|
||||
(useUserPrivileges as jest.Mock).mockReturnValue({
|
||||
...initialUserPrivilegesState(),
|
||||
endpointPrivileges: {
|
||||
loading: false,
|
||||
canManageGroupPolicies: false,
|
||||
},
|
||||
});
|
||||
const { getByTestId } = render();
|
||||
|
||||
expect((getByTestId('test-global') as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve assignment to policies not currently accessible in active space', async () => {
|
||||
const { getByTestId } = render();
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('test-unAccessiblePoliciesCallout'));
|
||||
});
|
||||
clickOnPolicy();
|
||||
|
||||
expect(handleOnChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ tags: ['policy:321', 'policy:abc123'] })
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('test-unAccessiblePoliciesCallout').textContent).toEqual(
|
||||
ARTIFACT_POLICIES_NOT_ACCESSIBLE_IN_ACTIVE_SPACE_MESSAGE(1)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable global button if user has no global artifact privilege', async () => {
|
||||
(useUserPrivileges as jest.Mock).mockReturnValue({
|
||||
...initialUserPrivilegesState(),
|
||||
endpointPrivileges: {
|
||||
loading: false,
|
||||
canManageGroupPolicies: false,
|
||||
},
|
||||
});
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect((getByTestId('test-global') as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve assignment to policies not currently accessible in active space', async () => {
|
||||
const { getByTestId } = await render();
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('test-unAccessiblePoliciesCallout'));
|
||||
});
|
||||
await policySelectorTestUtils.waitForDataToLoad();
|
||||
policySelectorTestUtils.clickOnPolicy(policyId);
|
||||
|
||||
expect(handleOnChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
tags: [buildPerPolicyTag('policy-321-not-in-space'), buildPerPolicyTag(policyId)],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,27 +6,25 @@
|
|||
*/
|
||||
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import type { EuiButtonGroupOptionProps, EuiSelectableProps } from '@elastic/eui';
|
||||
import type { EuiButtonGroupOptionProps } from '@elastic/eui';
|
||||
import {
|
||||
EuiCallOut,
|
||||
EuiButtonGroup,
|
||||
EuiCheckbox,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiSelectable,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
htmlIdGenerator,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import styled from '@emotion/styled';
|
||||
import type {
|
||||
CreateExceptionListItemSchema,
|
||||
ExceptionListItemSchema,
|
||||
} from '@kbn/securitysolution-io-ts-list-types';
|
||||
import type { PolicySelectorProps } from '../policy_selector';
|
||||
import { PolicySelector } from '../policy_selector';
|
||||
import { useArtifactRestrictedPolicyAssignments } from '../../hooks/artifacts/use_artifact_restricted_policy_assignments';
|
||||
import { useGetUpdatedTags } from '../../hooks/artifacts';
|
||||
import { useLicense } from '../../../common/hooks/use_license';
|
||||
|
@ -37,11 +35,7 @@ import {
|
|||
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
|
||||
import { useUserPrivileges } from '../../../common/components/user_privileges';
|
||||
import type { PolicyData } from '../../../../common/endpoint/types';
|
||||
import { LinkToApp } from '../../../common/components/endpoint/link_to_app';
|
||||
import { getPolicyDetailPath } from '../../common/routing';
|
||||
import { useTestIdGenerator } from '../../hooks/use_test_id_generator';
|
||||
import { useAppUrl } from '../../../common/lib/kibana/hooks';
|
||||
import { Loader } from '../../../common/components/loader';
|
||||
import {
|
||||
getPolicyIdsFromArtifact,
|
||||
GLOBAL_ARTIFACT_TAG,
|
||||
|
@ -49,22 +43,6 @@ import {
|
|||
} from '../../../../common/endpoint/service/artifacts';
|
||||
import { buildPerPolicyTag } from '../../../../common/endpoint/service/artifacts/utils';
|
||||
|
||||
const NOOP = () => {};
|
||||
const DEFAULT_LIST_PROPS: EuiSelectableProps['listProps'] = { bordered: true, showIcons: false };
|
||||
const SEARCH_PROPS = { className: 'effected-policies-search' };
|
||||
|
||||
const StyledEuiSelectable = styled.div`
|
||||
.effected-policies-search {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.euiSelectableList {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top-width: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledEuiFlexItemButtonGroup = styled(EuiFlexItem)`
|
||||
@media only screen and (max-width: ${({ theme }) => theme.euiTheme.breakpoint.m}) {
|
||||
align-items: center;
|
||||
|
@ -86,42 +64,21 @@ const EffectivePolicyFormContainer = styled.div`
|
|||
}
|
||||
`;
|
||||
|
||||
interface OptionPolicyData {
|
||||
policy: PolicyData;
|
||||
}
|
||||
|
||||
type EffectedPolicyOption = EuiSelectableOption<OptionPolicyData>;
|
||||
|
||||
export interface EffectedPolicySelection {
|
||||
isGlobal: boolean;
|
||||
selected: PolicyData[];
|
||||
}
|
||||
|
||||
export type EffectedPolicySelectProps = Omit<
|
||||
EuiSelectableProps<OptionPolicyData>,
|
||||
'onChange' | 'options' | 'children' | 'searchable'
|
||||
> & {
|
||||
export interface EffectedPolicySelectProps {
|
||||
item: ExceptionListItemSchema | CreateExceptionListItemSchema;
|
||||
options: PolicyData[];
|
||||
description?: string;
|
||||
onChange: (updatedItem: ExceptionListItemSchema | CreateExceptionListItemSchema) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
'data-test-subj'?: string;
|
||||
}
|
||||
|
||||
export const EffectedPolicySelect = memo<EffectedPolicySelectProps>(
|
||||
({
|
||||
item,
|
||||
description,
|
||||
isLoading = false,
|
||||
onChange,
|
||||
listProps,
|
||||
options,
|
||||
disabled = false,
|
||||
'data-test-subj': dataTestSubj,
|
||||
...otherSelectableProps
|
||||
}) => {
|
||||
const { getAppUrl } = useAppUrl();
|
||||
const { canReadPolicyManagement } = useUserPrivileges().endpointPrivileges;
|
||||
({ item, description, onChange, disabled = false, 'data-test-subj': dataTestSubj }) => {
|
||||
const getTestId = useTestIdGenerator(dataTestSubj);
|
||||
const isPlatinumPlus = useLicense().isPlatinumPlus();
|
||||
const isSpaceAwarenessEnabled = useIsExperimentalFeatureEnabled(
|
||||
|
@ -169,74 +126,18 @@ export const EffectedPolicySelect = memo<EffectedPolicySelectProps>(
|
|||
];
|
||||
}, [canManageGlobalArtifacts, getTestId, isSpaceAwarenessEnabled, selectedAssignmentType]);
|
||||
|
||||
const selectableOptions: EffectedPolicyOption[] = useMemo(() => {
|
||||
const isPolicySelected = new Set<string>(selectedPolicyIds);
|
||||
|
||||
return options
|
||||
.map<EffectedPolicyOption>((policy) => ({
|
||||
label: policy.name,
|
||||
className: 'policy-name',
|
||||
prepend: (
|
||||
<EuiCheckbox
|
||||
id={htmlIdGenerator()()}
|
||||
onChange={NOOP}
|
||||
checked={isPolicySelected.has(policy.id)}
|
||||
disabled={isGlobal || !isPlatinumPlus || disabled}
|
||||
data-test-subj={`policy-${policy.id}-checkbox`}
|
||||
/>
|
||||
),
|
||||
append: canReadPolicyManagement ? (
|
||||
<LinkToApp
|
||||
href={getAppUrl({ path: getPolicyDetailPath(policy.id) })}
|
||||
appPath={getPolicyDetailPath(policy.id)}
|
||||
target="_blank"
|
||||
data-test-subj={getTestId('policyLink')}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.effectedPolicySelect.viewPolicyLinkLabel"
|
||||
defaultMessage="View policy"
|
||||
/>
|
||||
</LinkToApp>
|
||||
) : null,
|
||||
policy,
|
||||
checked: isPolicySelected.has(policy.id) ? 'on' : undefined,
|
||||
disabled: isGlobal || !isPlatinumPlus || disabled,
|
||||
'data-test-subj': `policy-${policy.id}`,
|
||||
}))
|
||||
.sort(({ label: labelA }, { label: labelB }) => labelA.localeCompare(labelB));
|
||||
}, [
|
||||
canReadPolicyManagement,
|
||||
disabled,
|
||||
getAppUrl,
|
||||
getTestId,
|
||||
isGlobal,
|
||||
isPlatinumPlus,
|
||||
options,
|
||||
selectedPolicyIds,
|
||||
]);
|
||||
|
||||
const handleOnPolicySelectChange = useCallback<
|
||||
Required<EuiSelectableProps<OptionPolicyData>>['onChange']
|
||||
>(
|
||||
(currentOptions) => {
|
||||
const newPolicyAssignmentTags: string[] =
|
||||
artifactRestrictedPolicyIds.policyIds.map(buildPerPolicyTag);
|
||||
const newPolicyIds: string[] = [];
|
||||
|
||||
for (const opt of currentOptions) {
|
||||
if (opt.checked) {
|
||||
newPolicyIds.push(opt.policy.id);
|
||||
newPolicyAssignmentTags.push(buildPerPolicyTag(opt.policy.id));
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedPolicyIds(newPolicyIds);
|
||||
const handleOnPolicySelectChange = useCallback<PolicySelectorProps['onChange']>(
|
||||
(updatedSelectedPolicyIds) => {
|
||||
setSelectedPolicyIds(updatedSelectedPolicyIds);
|
||||
onChange({
|
||||
...item,
|
||||
tags: getTagsUpdatedBy('policySelection', newPolicyAssignmentTags),
|
||||
tags: getTagsUpdatedBy(
|
||||
'policySelection',
|
||||
updatedSelectedPolicyIds.map(buildPerPolicyTag)
|
||||
),
|
||||
});
|
||||
},
|
||||
[artifactRestrictedPolicyIds.policyIds, getTagsUpdatedBy, item, onChange]
|
||||
[getTagsUpdatedBy, item, onChange]
|
||||
);
|
||||
|
||||
const handleGlobalButtonChange = useCallback(
|
||||
|
@ -256,20 +157,8 @@ export const EffectedPolicySelect = memo<EffectedPolicySelectProps>(
|
|||
[artifactRestrictedPolicyIds.policyIds, getTagsUpdatedBy, item, onChange, selectedPolicyIds]
|
||||
);
|
||||
|
||||
const listBuilderCallback = useCallback<NonNullable<EuiSelectableProps['children']>>(
|
||||
(list, search) => {
|
||||
return (
|
||||
<>
|
||||
{search}
|
||||
{list}
|
||||
</>
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<EffectivePolicyFormContainer>
|
||||
<EffectivePolicyFormContainer data-test-subj={getTestId()}>
|
||||
<EuiText size="xs">
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
|
@ -281,7 +170,7 @@ export const EffectedPolicySelect = memo<EffectedPolicySelectProps>(
|
|||
<EuiSpacer size="xs" />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiText size="s">
|
||||
<EuiText size="s" data-test-subj={getTestId('description')}>
|
||||
<p>
|
||||
{description
|
||||
? description
|
||||
|
@ -311,26 +200,19 @@ export const EffectedPolicySelect = memo<EffectedPolicySelectProps>(
|
|||
</EuiFlexGroup>
|
||||
<EuiSpacer />
|
||||
|
||||
{selectedAssignmentType === 'perPolicy' &&
|
||||
(isLoading ? (
|
||||
<Loader size="l" data-test-subj={getTestId('policiesLoader')} />
|
||||
) : (
|
||||
<EuiFormRow fullWidth>
|
||||
<StyledEuiSelectable>
|
||||
<EuiSelectable<OptionPolicyData>
|
||||
{...otherSelectableProps}
|
||||
options={selectableOptions}
|
||||
listProps={listProps || DEFAULT_LIST_PROPS}
|
||||
onChange={handleOnPolicySelectChange}
|
||||
searchProps={SEARCH_PROPS}
|
||||
searchable={true}
|
||||
data-test-subj={getTestId('policiesSelectable')}
|
||||
>
|
||||
{listBuilderCallback}
|
||||
</EuiSelectable>
|
||||
</StyledEuiSelectable>
|
||||
</EuiFormRow>
|
||||
))}
|
||||
{selectedAssignmentType === 'perPolicy' && (
|
||||
<EuiFormRow fullWidth>
|
||||
<PolicySelector
|
||||
selectedPolicyIds={selectedPolicyIds}
|
||||
onChange={handleOnPolicySelectChange}
|
||||
data-test-subj={getTestId('policiesSelector')}
|
||||
useCheckbox={true}
|
||||
showPolicyLink={true}
|
||||
isDisabled={isGlobal || !isPlatinumPlus || disabled}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
|
||||
{artifactRestrictedPolicyIds.policyIds.length > 0 && !isGlobal && (
|
||||
<>
|
||||
<EuiSpacer />
|
||||
|
|
|
@ -1,137 +0,0 @@
|
|||
/*
|
||||
* 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 { I18nProvider } from '@kbn/i18n-react';
|
||||
import type { RenderResult } from '@testing-library/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl';
|
||||
|
||||
import React from 'react';
|
||||
import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data';
|
||||
|
||||
import type { PoliciesSelectorProps } from '.';
|
||||
import { PoliciesSelector } from '.';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
|
||||
|
||||
// TODO: remove this mock when feature flag is removed
|
||||
jest.mock('../../../common/hooks/use_experimental_features');
|
||||
const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock;
|
||||
|
||||
let onChangeSelectionMock: jest.Mock;
|
||||
|
||||
// Failing: See https://github.com/elastic/kibana/issues/192688
|
||||
describe.skip('Policies selector', () => {
|
||||
let getElement: (params: Partial<PoliciesSelectorProps>) => RenderResult;
|
||||
beforeEach(() => {
|
||||
onChangeSelectionMock = jest.fn();
|
||||
useIsExperimentalFeatureEnabledMock.mockReturnValue(false);
|
||||
getElement = (params: Partial<PoliciesSelectorProps>) => {
|
||||
return render(
|
||||
<I18nProvider>
|
||||
<PoliciesSelector
|
||||
policies={[policy]}
|
||||
onChangeSelection={onChangeSelectionMock}
|
||||
{...params}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
};
|
||||
});
|
||||
const generator = new EndpointDocGenerator('policy-list');
|
||||
const policy = generator.generatePolicyPackagePolicy();
|
||||
policy.name = 'test policy A';
|
||||
policy.id = 'abc123';
|
||||
|
||||
describe('When click on policy', () => {
|
||||
it('should have a default value', async () => {
|
||||
const defaultIncludedPolicies = 'abc123';
|
||||
const defaultExcludedPolicies = 'global';
|
||||
const element = getElement({ defaultExcludedPolicies, defaultIncludedPolicies });
|
||||
|
||||
await userEvent.click(element.getByTestId('policiesSelectorButton'));
|
||||
await waitForEuiPopoverOpen();
|
||||
|
||||
expect(element.getByText(policy.name)).toHaveTextContent(policy.name);
|
||||
|
||||
await userEvent.click(element.getByText('Unassigned entries'));
|
||||
expect(onChangeSelectionMock).toHaveBeenCalledWith([
|
||||
{ checked: 'on', id: 'abc123', name: 'test policy A' },
|
||||
{ checked: 'off', id: 'global', name: 'Global entries' },
|
||||
{ checked: 'on', id: 'unassigned', name: 'Unassigned entries' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should disable enabled default value', async () => {
|
||||
useIsExperimentalFeatureEnabledMock.mockReturnValue(true);
|
||||
const defaultIncludedPolicies = 'abc123';
|
||||
const defaultExcludedPolicies = 'global';
|
||||
const element = getElement({ defaultExcludedPolicies, defaultIncludedPolicies });
|
||||
|
||||
await userEvent.click(element.getByTestId('policiesSelectorButton'));
|
||||
await waitForEuiPopoverOpen();
|
||||
|
||||
await userEvent.click(element.getByText(policy.name));
|
||||
expect(onChangeSelectionMock).toHaveBeenCalledWith([
|
||||
{ checked: 'off', id: 'abc123', name: 'test policy A' },
|
||||
{ checked: 'off', id: 'global', name: 'Global entries' },
|
||||
{ checked: undefined, id: 'unassigned', name: 'Unassigned entries' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove disabled default value', async () => {
|
||||
const defaultIncludedPolicies = 'abc123';
|
||||
const defaultExcludedPolicies = 'global';
|
||||
const element = getElement({ defaultExcludedPolicies, defaultIncludedPolicies });
|
||||
|
||||
await userEvent.click(element.getByTestId('policiesSelectorButton'));
|
||||
await waitForEuiPopoverOpen();
|
||||
|
||||
await userEvent.click(element.getByText('Global entries'));
|
||||
expect(onChangeSelectionMock).toHaveBeenCalledWith([
|
||||
{ checked: 'on', id: 'abc123', name: 'test policy A' },
|
||||
{ checked: undefined, id: 'global', name: 'Global entries' },
|
||||
{ checked: undefined, id: 'unassigned', name: 'Unassigned entries' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When filter policy', () => {
|
||||
it('should filter policy by name', async () => {
|
||||
const element = getElement({});
|
||||
|
||||
await userEvent.click(element.getByTestId('policiesSelectorButton'));
|
||||
await waitForEuiPopoverOpen();
|
||||
|
||||
await userEvent.type(element.getByTestId('policiesSelectorSearch'), policy.name);
|
||||
expect(element.queryAllByText('Global entries')).toStrictEqual([]);
|
||||
expect(element.getByText(policy.name)).toHaveTextContent(policy.name);
|
||||
});
|
||||
it('should filter with no results', async () => {
|
||||
const element = getElement({});
|
||||
|
||||
await userEvent.click(element.getByTestId('policiesSelectorButton'));
|
||||
await waitForEuiPopoverOpen();
|
||||
|
||||
await userEvent.type(element.getByTestId('policiesSelectorSearch'), 'no results');
|
||||
expect(element.queryAllByText('Global entries')).toStrictEqual([]);
|
||||
expect(element.queryAllByText('Unassigned entries')).toStrictEqual([]);
|
||||
expect(element.queryAllByText(policy.name)).toStrictEqual([]);
|
||||
});
|
||||
it('should filter with special chars', async () => {
|
||||
const element = getElement({});
|
||||
|
||||
await userEvent.click(element.getByTestId('policiesSelectorButton'));
|
||||
await waitForEuiPopoverOpen();
|
||||
|
||||
await userEvent.type(element.getByTestId('policiesSelectorSearch'), '*');
|
||||
expect(element.queryAllByText('Global entries')).toStrictEqual([]);
|
||||
expect(element.queryAllByText('Unassigned entries')).toStrictEqual([]);
|
||||
expect(element.queryAllByText(policy.name)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,218 +0,0 @@
|
|||
/*
|
||||
* 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 { ChangeEvent } from 'react';
|
||||
import React, { memo, useCallback, useMemo, useState, useEffect } from 'react';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { FilterChecked } from '@elastic/eui';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFilterGroup,
|
||||
EuiPopover,
|
||||
EuiPopoverTitle,
|
||||
EuiFieldSearch,
|
||||
EuiFilterButton,
|
||||
EuiFilterSelectItem,
|
||||
EuiText,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import type { ImmutableArray, PolicyData } from '../../../../common/endpoint/types';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
|
||||
|
||||
export interface PoliciesSelectorProps {
|
||||
policies: ImmutableArray<PolicyData>;
|
||||
defaultIncludedPolicies?: string;
|
||||
defaultExcludedPolicies?: string;
|
||||
onChangeSelection: (items: PolicySelectionItem[]) => void;
|
||||
}
|
||||
|
||||
export interface PolicySelectionItem {
|
||||
name: string;
|
||||
id?: string;
|
||||
checked?: FilterChecked;
|
||||
}
|
||||
|
||||
interface DefaultPoliciesByKey {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
|
||||
const GLOBAL_ENTRIES = i18n.translate(
|
||||
'xpack.securitySolution.management.policiesSelector.globalEntries',
|
||||
{
|
||||
defaultMessage: 'Global entries',
|
||||
}
|
||||
);
|
||||
const UNASSIGNED_ENTRIES = i18n.translate(
|
||||
'xpack.securitySolution.management.policiesSelector.unassignedEntries',
|
||||
{
|
||||
defaultMessage: 'Unassigned entries',
|
||||
}
|
||||
);
|
||||
|
||||
export const PoliciesSelector = memo<PoliciesSelectorProps>(
|
||||
({ policies, onChangeSelection, defaultExcludedPolicies, defaultIncludedPolicies }) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [query, setQuery] = useState<string>('');
|
||||
const [itemsList, setItemsList] = useState<PolicySelectionItem[]>([]);
|
||||
|
||||
const isExcludePoliciesInFilterEnabled = useIsExperimentalFeatureEnabled(
|
||||
'excludePoliciesInFilterEnabled'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultIncludedPoliciesByKey: DefaultPoliciesByKey = defaultIncludedPolicies
|
||||
? defaultIncludedPolicies.split(',').reduce((acc, val) => ({ ...acc, [val]: true }), {})
|
||||
: {};
|
||||
|
||||
const defaultExcludedPoliciesByKey: DefaultPoliciesByKey = defaultExcludedPolicies
|
||||
? defaultExcludedPolicies.split(',').reduce((acc, val) => ({ ...acc, [val]: true }), {})
|
||||
: {};
|
||||
|
||||
const getCheckedValue = (id: string): FilterChecked | undefined =>
|
||||
defaultIncludedPoliciesByKey[id]
|
||||
? 'on'
|
||||
: defaultExcludedPoliciesByKey[id]
|
||||
? 'off'
|
||||
: undefined;
|
||||
|
||||
setItemsList([
|
||||
...policies.map((policy) => ({
|
||||
name: policy.name,
|
||||
id: policy.id,
|
||||
checked: getCheckedValue(policy.id),
|
||||
})),
|
||||
{ name: GLOBAL_ENTRIES, id: 'global', checked: getCheckedValue('global') },
|
||||
{ name: UNASSIGNED_ENTRIES, id: 'unassigned', checked: getCheckedValue('unassigned') },
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [policies]);
|
||||
|
||||
const onButtonClick = useCallback(() => {
|
||||
setIsPopoverOpen((prevIsPopoverOpen) => !prevIsPopoverOpen);
|
||||
}, []);
|
||||
|
||||
const closePopover = useCallback(() => {
|
||||
setIsPopoverOpen(false);
|
||||
}, []);
|
||||
|
||||
const onChange = useCallback((ev: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = ev.target.value || '';
|
||||
setQuery(value);
|
||||
}, []);
|
||||
|
||||
const updateItem = useCallback(
|
||||
(index: number) => {
|
||||
if (!itemsList[index]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newItems = [...itemsList];
|
||||
|
||||
switch (newItems[index].checked) {
|
||||
case 'on':
|
||||
newItems[index].checked = isExcludePoliciesInFilterEnabled ? 'off' : undefined;
|
||||
break;
|
||||
|
||||
case 'off':
|
||||
newItems[index].checked = undefined;
|
||||
break;
|
||||
|
||||
default:
|
||||
newItems[index].checked = 'on';
|
||||
}
|
||||
|
||||
setItemsList(newItems);
|
||||
onChangeSelection(newItems);
|
||||
},
|
||||
[itemsList, onChangeSelection, isExcludePoliciesInFilterEnabled]
|
||||
);
|
||||
|
||||
const dropdownItems = useMemo(
|
||||
() =>
|
||||
itemsList.map((item, index) =>
|
||||
item.name.toLowerCase().includes(query.toLowerCase()) ? (
|
||||
<EuiFilterSelectItem
|
||||
checked={item.checked}
|
||||
key={index}
|
||||
onClick={() => updateItem(index)}
|
||||
data-test-subj={`policiesSelector-popover-items-${item.id}`}
|
||||
>
|
||||
{item.name}
|
||||
</EuiFilterSelectItem>
|
||||
) : null
|
||||
),
|
||||
[itemsList, query, updateItem]
|
||||
);
|
||||
|
||||
const button = useMemo(
|
||||
() => (
|
||||
<EuiFilterButton
|
||||
iconType="arrowDown"
|
||||
data-test-subj="policiesSelectorButton"
|
||||
onClick={onButtonClick}
|
||||
isSelected={isPopoverOpen}
|
||||
numFilters={itemsList.length}
|
||||
hasActiveFilters={!!itemsList.find((item) => item.checked === 'on')}
|
||||
numActiveFilters={itemsList.filter((item) => item.checked === 'on').length}
|
||||
>
|
||||
<EuiText>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.management.policiesSelector.label"
|
||||
defaultMessage="Policies"
|
||||
/>
|
||||
</EuiText>
|
||||
</EuiFilterButton>
|
||||
),
|
||||
[isPopoverOpen, itemsList, onButtonClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
data-test-subj="policiesSelector"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gutterSize="l"
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<EuiFilterGroup>
|
||||
<EuiPopover
|
||||
button={button}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={closePopover}
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<EuiPopoverTitle paddingSize="s">
|
||||
<EuiFieldSearch
|
||||
data-test-subj="policiesSelectorSearch"
|
||||
compressed
|
||||
onChange={onChange}
|
||||
value={query}
|
||||
/>
|
||||
</EuiPopoverTitle>
|
||||
{/* EUI NOTE: Please use EuiSelectable (which already has height/scrolling built in)
|
||||
instead of EuiFilterSelectItem (which is pending deprecation).
|
||||
@see https://elastic.github.io/eui/#/forms/filter-group#multi-select */}
|
||||
<div
|
||||
data-test-subj="policiesSelector-popover"
|
||||
className="eui-yScroll"
|
||||
css={{ maxHeight: euiTheme.base * 30 }}
|
||||
>
|
||||
{dropdownItems}
|
||||
</div>
|
||||
</EuiPopover>
|
||||
</EuiFilterGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
PoliciesSelector.displayName = 'PoliciesSelector';
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* 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 { UseQueryResult } from '@tanstack/react-query';
|
||||
import type { GetPackagePoliciesResponse } from '@kbn/fleet-plugin/common';
|
||||
import type { IHttpFetchError } from '@kbn/core-http-browser';
|
||||
import { useMemo } from 'react';
|
||||
import { chunk } from 'lodash';
|
||||
import type { PolicyData } from '../../../../../common/endpoint/types';
|
||||
import type { PolicySelectorProps } from '..';
|
||||
import { useFetchIntegrationPolicyList } from '../../../hooks/policy/use_fetch_integration_policy_list';
|
||||
import { useBulkFetchFleetIntegrationPolicies } from '../../../hooks/policy/use_bulk_fetch_fleet_integration_policies';
|
||||
|
||||
/**
|
||||
* Hook for use in the PolicySelector component. It normalizes the retrieval of data between
|
||||
* just fetching the regular list of policies -OR- bulk retrieving a list of policy by IDs.
|
||||
*
|
||||
* The primary goal of this hook is to efficiently manage the display of selected policies, which
|
||||
* uses the Bulk Get Package Policies API from fleet which does not have support for pagination.
|
||||
*/
|
||||
export const useFetchPolicyData = (
|
||||
queryOptions: PolicySelectorProps['queryOptions'] & { page: number },
|
||||
selectedPolicyIds: PolicySelectorProps['selectedPolicyIds'],
|
||||
mode: 'full-list' | 'selected-list'
|
||||
): Pick<
|
||||
UseQueryResult<GetPackagePoliciesResponse, IHttpFetchError>,
|
||||
'data' | 'isFetching' | 'isLoading' | 'error'
|
||||
> => {
|
||||
const selectedPoliciesPagination: string[][] = useMemo(() => {
|
||||
if (mode === 'selected-list') {
|
||||
return chunk(selectedPolicyIds, queryOptions?.perPage ?? 20);
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [mode, queryOptions?.perPage, selectedPolicyIds]);
|
||||
|
||||
const bulkFetchPage = useMemo(() => {
|
||||
return selectedPoliciesPagination[queryOptions.page - 1] ? queryOptions.page : 1;
|
||||
}, [queryOptions.page, selectedPoliciesPagination]);
|
||||
|
||||
const fetchListResult = useFetchIntegrationPolicyList<PolicyData>(queryOptions, {
|
||||
keepPreviousData: true,
|
||||
enabled: mode === 'full-list',
|
||||
});
|
||||
|
||||
const bulkFetchResult = useBulkFetchFleetIntegrationPolicies(
|
||||
{ ids: selectedPoliciesPagination[bulkFetchPage - 1] ?? [] },
|
||||
{
|
||||
keepPreviousData: true,
|
||||
enabled: mode === 'selected-list',
|
||||
}
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (mode === 'selected-list') {
|
||||
return {
|
||||
data: bulkFetchResult.data
|
||||
? {
|
||||
items: bulkFetchResult.data.items,
|
||||
total: selectedPolicyIds.length,
|
||||
perPage: queryOptions.perPage ?? 20,
|
||||
page: bulkFetchPage,
|
||||
}
|
||||
: undefined,
|
||||
isFetching: bulkFetchResult.isFetching,
|
||||
isLoading: bulkFetchResult.isLoading,
|
||||
error: bulkFetchResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: fetchListResult.data,
|
||||
isFetching: fetchListResult.isFetching,
|
||||
isLoading: fetchListResult.isLoading,
|
||||
error: fetchListResult.error,
|
||||
};
|
||||
}, [
|
||||
bulkFetchPage,
|
||||
bulkFetchResult.data,
|
||||
bulkFetchResult.error,
|
||||
bulkFetchResult.isFetching,
|
||||
bulkFetchResult.isLoading,
|
||||
fetchListResult.data,
|
||||
fetchListResult.error,
|
||||
fetchListResult.isFetching,
|
||||
fetchListResult.isLoading,
|
||||
mode,
|
||||
queryOptions.perPage,
|
||||
selectedPolicyIds.length,
|
||||
]);
|
||||
};
|
|
@ -5,5 +5,5 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export type { PolicySelectionItem, PoliciesSelectorProps } from './policies_selector';
|
||||
export { PoliciesSelector } from './policies_selector';
|
||||
export * from './policy_selector';
|
||||
export * from './policy_selector_menu_button';
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* 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 { RenderResult } from '@testing-library/react';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { act, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { createTestSubjGenerator } from '../../mocks/utils';
|
||||
|
||||
/**
|
||||
* Get a list of test ids for the policy selector comonent
|
||||
* @param dataTestSubj
|
||||
*/
|
||||
const getTestIdList = (dataTestSubj: string) => {
|
||||
const generateTestId = createTestSubjGenerator(dataTestSubj);
|
||||
|
||||
return {
|
||||
root: dataTestSubj,
|
||||
policy: (policyId: string) => generateTestId(`policy-${policyId}`),
|
||||
policyLink: (policyId: string) => generateTestId(`policy-${policyId}-policyLink`),
|
||||
policyCheckbox: (policyId: string) => generateTestId(`policy-${policyId}-checkbox`),
|
||||
get noPoliciesFound() {
|
||||
return generateTestId('noPolicies');
|
||||
},
|
||||
get searchbarInput() {
|
||||
return generateTestId('searchbar');
|
||||
},
|
||||
get viewSelectedButton() {
|
||||
return generateTestId('viewSelectedButton');
|
||||
},
|
||||
get isFetchingProgress() {
|
||||
return generateTestId('isFetching');
|
||||
},
|
||||
get selectAllButton() {
|
||||
return generateTestId('selectAllButton');
|
||||
},
|
||||
get unselectAllButton() {
|
||||
return generateTestId('unselectAllButton');
|
||||
},
|
||||
get policyList() {
|
||||
return generateTestId('list');
|
||||
},
|
||||
get policyFetchTotal() {
|
||||
return generateTestId('policyFetchTotal');
|
||||
},
|
||||
get pagination() {
|
||||
return generateTestId('pagination');
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns an object with most of the test subjects used by the PolicySelector component.
|
||||
* @param dataTestSubj
|
||||
* @param renderResult
|
||||
*/
|
||||
const getTestHelpers = (
|
||||
/** The `data-test-subj` that was passed to the `PolicySelector` component */
|
||||
dataTestSubj: string,
|
||||
/** The render result of the test being evaluated */
|
||||
renderResult: RenderResult
|
||||
) => {
|
||||
const testIds = getTestIdList(dataTestSubj);
|
||||
|
||||
return {
|
||||
testIds,
|
||||
|
||||
isPolicySelected(policyId: string): boolean {
|
||||
return (
|
||||
renderResult.getByTestId(testIds.policy(policyId)).getAttribute('aria-checked') === 'true'
|
||||
);
|
||||
},
|
||||
|
||||
isPolicyDisabled(policyId: string): boolean {
|
||||
return (
|
||||
renderResult.getByTestId(testIds.policy(policyId)).getAttribute('aria-disabled') === 'true'
|
||||
);
|
||||
},
|
||||
|
||||
waitForDataToLoad: async (): Promise<void> => {
|
||||
await waitFor(() => {
|
||||
expect(renderResult.queryByTestId(testIds.isFetchingProgress)).toBeNull();
|
||||
});
|
||||
},
|
||||
|
||||
clickOnPolicy: (policyId: string) => {
|
||||
act(() => {
|
||||
fireEvent.click(renderResult.getByTestId(testIds.policy(policyId)));
|
||||
});
|
||||
},
|
||||
|
||||
clickOnSelectAll: () => {
|
||||
act(() => {
|
||||
fireEvent.click(renderResult.getByTestId(testIds.selectAllButton));
|
||||
});
|
||||
},
|
||||
|
||||
clickOnUnSelectAll: () => {
|
||||
act(() => {
|
||||
fireEvent.click(renderResult.getByTestId(testIds.unselectAllButton));
|
||||
});
|
||||
},
|
||||
|
||||
clickOnViewSelected: () => {
|
||||
act(() => {
|
||||
fireEvent.click(renderResult.getByTestId(testIds.viewSelectedButton));
|
||||
});
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const policySelectorMocks = Object.freeze({
|
||||
getTestIdList,
|
||||
|
||||
getTestHelpers,
|
||||
});
|
|
@ -0,0 +1,565 @@
|
|||
/*
|
||||
* 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 { PolicySelectorProps } from './policy_selector';
|
||||
import { PolicySelector } from './policy_selector';
|
||||
import type { AppContextTestRender } from '../../../common/mock/endpoint';
|
||||
import { createAppRootMockRenderer } from '../../../common/mock/endpoint';
|
||||
import { allFleetHttpMocks } from '../../mocks';
|
||||
import React from 'react';
|
||||
import { act, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { packagePolicyRouteService } from '@kbn/fleet-plugin/common';
|
||||
import { FleetPackagePolicyGenerator } from '../../../../common/endpoint/data_generators/fleet_package_policy_generator';
|
||||
import { useUserPrivileges as _useUserPrivileges } from '../../../common/components/user_privileges';
|
||||
import { getPolicyDetailPath } from '../../common/routing';
|
||||
import { pagePathGetters } from '@kbn/fleet-plugin/public';
|
||||
import type { BulkGetPackagePoliciesRequestBody } from '@kbn/fleet-plugin/common/types';
|
||||
import { policySelectorMocks } from './mocks';
|
||||
|
||||
jest.mock('../../../common/components/user_privileges');
|
||||
|
||||
const useUserPrivilegesMock = _useUserPrivileges as jest.Mock;
|
||||
|
||||
describe('PolicySelector component', () => {
|
||||
let mockedContext: AppContextTestRender;
|
||||
let apiMocks: ReturnType<typeof allFleetHttpMocks>;
|
||||
let testPolicyId1: string;
|
||||
let testPolicyId2: string;
|
||||
let testPolicyId3: string;
|
||||
let props: PolicySelectorProps;
|
||||
let render: () => Promise<ReturnType<AppContextTestRender['render']>>;
|
||||
let renderResult: ReturnType<AppContextTestRender['render']>;
|
||||
// Note: testUtils will only be set after render()
|
||||
let testUtils: ReturnType<typeof policySelectorMocks.getTestHelpers>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedContext = createAppRootMockRenderer();
|
||||
apiMocks = allFleetHttpMocks(mockedContext.coreStart.http);
|
||||
|
||||
const apiPolicies = apiMocks.responseProvider.packagePolicies().items;
|
||||
testPolicyId1 = apiPolicies[0].id;
|
||||
testPolicyId2 = apiPolicies[1].id;
|
||||
testPolicyId3 = apiPolicies[2].id;
|
||||
apiMocks.responseProvider.packagePolicies.mockClear();
|
||||
|
||||
// Mock API to have a total count that will trigger multiple pages in the UI
|
||||
const generatePackagePoliciesResponse =
|
||||
apiMocks.responseProvider.packagePolicies.getMockImplementation()!;
|
||||
apiMocks.responseProvider.packagePolicies.mockImplementation(() => {
|
||||
return {
|
||||
...generatePackagePoliciesResponse(),
|
||||
total: 50,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the BulkGet API to return a record for each policy id that was requested
|
||||
apiMocks.responseProvider.bulkPackagePolicies.mockImplementation(
|
||||
({ body } = { body: JSON.stringify({ ids: [] }), path: '' }) => {
|
||||
const reqBody = JSON.parse(body as string) as BulkGetPackagePoliciesRequestBody;
|
||||
const fleetPackagePolicyGenerator = new FleetPackagePolicyGenerator('seed');
|
||||
|
||||
return {
|
||||
items: reqBody.ids.map((id) => fleetPackagePolicyGenerator.generate({ id })),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
props = {
|
||||
selectedPolicyIds: [],
|
||||
onChange: jest.fn((updatedPolicySelection, updatedAdditionalItems) => {
|
||||
// Update props and re-render component so we get the latest state of it after user interactions
|
||||
const updatedProps: PolicySelectorProps = {
|
||||
...props,
|
||||
selectedPolicyIds: updatedPolicySelection,
|
||||
additionalListItems: updatedAdditionalItems,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
renderResult.rerender(<PolicySelector {...updatedProps} />);
|
||||
});
|
||||
}),
|
||||
'data-test-subj': 'test',
|
||||
};
|
||||
|
||||
render = async (): Promise<ReturnType<AppContextTestRender['render']>> => {
|
||||
renderResult = mockedContext.render(<PolicySelector {...props} />);
|
||||
testUtils = policySelectorMocks.getTestHelpers(props['data-test-subj']!, renderResult);
|
||||
|
||||
// Wait for API to be called
|
||||
await waitFor(async () => {
|
||||
expect(apiMocks.responseProvider.packagePolicies).toHaveBeenCalled();
|
||||
await testUtils.waitForDataToLoad();
|
||||
});
|
||||
|
||||
return renderResult;
|
||||
};
|
||||
});
|
||||
|
||||
it('should display expected interface', async () => {
|
||||
const { getByTestId, queryByTestId } = await render();
|
||||
|
||||
expect(getByTestId('test-searchbar')).toBeTruthy();
|
||||
expect(getByTestId('test-viewSelectedButton')).toBeTruthy();
|
||||
expect((getByTestId('test-viewSelectedButton') as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(getByTestId(testUtils.testIds.policyFetchTotal)).toBeTruthy();
|
||||
expect(getByTestId('test-pagination')).toBeTruthy();
|
||||
expect(queryByTestId(`test-policy-${testPolicyId1}-checkbox`)).toBeNull();
|
||||
expect(queryByTestId(`test-policy-${testPolicyId1}-policyLink`)).toBeNull();
|
||||
});
|
||||
|
||||
it('should enable the selected policies button when policies are selected', async () => {
|
||||
const { getByTestId } = await render();
|
||||
testUtils.clickOnPolicy(testPolicyId1);
|
||||
|
||||
expect(getByTestId(testUtils.testIds.policyFetchTotal).textContent).toEqual('1 of 50 selected');
|
||||
expect((getByTestId('test-viewSelectedButton') as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(props.onChange).toHaveBeenCalledWith([testPolicyId1], []);
|
||||
});
|
||||
|
||||
it('should select all policies displayed when "select all" is clicked', async () => {
|
||||
const { getByTestId } = await render();
|
||||
testUtils.clickOnSelectAll();
|
||||
|
||||
expect(getByTestId(testUtils.testIds.policyFetchTotal).textContent).toEqual('3 of 50 selected');
|
||||
expect(props.onChange).toHaveBeenCalledWith([testPolicyId1, testPolicyId2, testPolicyId3], []);
|
||||
});
|
||||
|
||||
it('should un-select all policies displayed when "un-select all" is clicked', async () => {
|
||||
props.selectedPolicyIds = [testPolicyId1, testPolicyId2];
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect(getByTestId(`test-policy-${testPolicyId1}`).getAttribute('aria-checked')).toEqual(
|
||||
'true'
|
||||
);
|
||||
expect(getByTestId(`test-policy-${testPolicyId2}`).getAttribute('aria-checked')).toEqual(
|
||||
'true'
|
||||
);
|
||||
|
||||
testUtils.clickOnUnSelectAll();
|
||||
|
||||
expect(getByTestId(testUtils.testIds.policyFetchTotal).textContent).toEqual('0 of 50 selected');
|
||||
expect(props.onChange).toHaveBeenCalledWith([], []);
|
||||
});
|
||||
|
||||
it('should use search value typed by user when fetching from fleet', async () => {
|
||||
const { getByTestId } = await render();
|
||||
act(() => {
|
||||
userEvent.type(getByTestId('test-searchbar'), 'foo');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedContext.coreStart.http.get).toHaveBeenCalledWith(
|
||||
packagePolicyRouteService.getListPath(),
|
||||
{
|
||||
query: {
|
||||
kuery:
|
||||
'(ingest-package-policies.package.name: endpoint) AND ((ingest-package-policies.name:*foo*) OR (ingest-package-policies.description:*foo*))',
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
sortField: 'name',
|
||||
sortOrder: 'asc',
|
||||
withAgentCount: false,
|
||||
},
|
||||
version: '2023-10-31',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should display a checkbox when "useCheckbox" prop is true', async () => {
|
||||
props.useCheckbox = true;
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect(getByTestId(`test-policy-${testPolicyId1}-checkbox`)).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('and when "showPolicyLink" prop is true', () => {
|
||||
let endpointPolicyId: string;
|
||||
let endpointPolicyTestId: string;
|
||||
let nonEndpointPolicyId: string;
|
||||
let nonEndpontPolicyTestId: string;
|
||||
let privilegeSetter: ReturnType<AppContextTestRender['getUserPrivilegesMockSetter']>;
|
||||
|
||||
beforeEach(() => {
|
||||
const fleetPackagePolicyGenerator = new FleetPackagePolicyGenerator('seed');
|
||||
|
||||
privilegeSetter = mockedContext.getUserPrivilegesMockSetter(useUserPrivilegesMock);
|
||||
|
||||
privilegeSetter.set({
|
||||
canReadPolicyManagement: true,
|
||||
canWriteIntegrationPolicies: true,
|
||||
});
|
||||
|
||||
const apiReturnedItems = [
|
||||
fleetPackagePolicyGenerator.generateEndpointPackagePolicy(),
|
||||
fleetPackagePolicyGenerator.generate({
|
||||
package: {
|
||||
name: 'some-other-integration',
|
||||
title: 'some-other-integration',
|
||||
version: '1.0.0',
|
||||
},
|
||||
}),
|
||||
];
|
||||
endpointPolicyId = apiReturnedItems[0].id;
|
||||
endpointPolicyTestId = `test-policy-${endpointPolicyId}-policyLink`;
|
||||
nonEndpointPolicyId = apiReturnedItems[1].id;
|
||||
nonEndpontPolicyTestId = `test-policy-${nonEndpointPolicyId}-policyLink`;
|
||||
props.showPolicyLink = true;
|
||||
apiMocks.responseProvider.packagePolicies.mockReturnValue({
|
||||
items: apiReturnedItems,
|
||||
total: 2,
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('should display "view policy" link when "showPolicyLink" prop is true', async () => {
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect(getByTestId(endpointPolicyTestId)).toBeTruthy();
|
||||
expect(getByTestId(nonEndpontPolicyTestId)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display correct policy link url for endpoint polices', async () => {
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect((getByTestId(endpointPolicyTestId) as HTMLAnchorElement).href).toMatch(
|
||||
getPolicyDetailPath(endpointPolicyId)
|
||||
);
|
||||
});
|
||||
|
||||
it('should display correct policy link url for NON-endpoint polices', async () => {
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect((getByTestId(nonEndpontPolicyTestId) as HTMLAnchorElement).href).toMatch(
|
||||
pagePathGetters.integration_policy_edit({ packagePolicyId: nonEndpointPolicyId })[1]
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT display the "view policy" link if user does not have privileges', async () => {
|
||||
privilegeSetter.set({
|
||||
canReadPolicyManagement: false,
|
||||
canWriteIntegrationPolicies: false,
|
||||
});
|
||||
const { queryByTestId } = await render();
|
||||
|
||||
expect(queryByTestId(endpointPolicyTestId)).toBeNull();
|
||||
expect(queryByTestId(nonEndpontPolicyTestId)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('and when the "Selected" policies button is clicked', () => {
|
||||
beforeEach(() => {
|
||||
props.selectedPolicyIds = [testPolicyId1, testPolicyId2];
|
||||
|
||||
const renderComponent = render;
|
||||
render = () =>
|
||||
renderComponent().then(async (result) => {
|
||||
testUtils.clickOnViewSelected();
|
||||
|
||||
// Wait for API to be called
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.responseProvider.bulkPackagePolicies).toHaveBeenCalled();
|
||||
expect(renderResult.queryByTestId('test-isFetching')).toBeNull();
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
});
|
||||
|
||||
it('should displayed selected policies', async () => {
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect(getByTestId(testUtils.testIds.policyFetchTotal).textContent).toEqual('2 selected');
|
||||
expect(getByTestId(`test-policy-${testPolicyId1}`).getAttribute('aria-checked')).toEqual(
|
||||
'true'
|
||||
);
|
||||
expect(getByTestId(`test-policy-${testPolicyId2}`).getAttribute('aria-checked')).toEqual(
|
||||
'true'
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable search field', async () => {
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect((getByTestId('test-searchbar') as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should display "un-select all" button', async () => {
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect(getByTestId('test-unselectAllButton')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should NOT display "select all" button', async () => {
|
||||
const { queryByTestId } = await render();
|
||||
|
||||
expect(queryByTestId('test-selectAllButton')).toBeNull();
|
||||
});
|
||||
|
||||
it('should remove item from the list when it is unselected', async () => {
|
||||
const { queryByTestId, getByTestId } = await render();
|
||||
testUtils.clickOnPolicy(testPolicyId2);
|
||||
|
||||
expect(getByTestId(testUtils.testIds.policyFetchTotal).textContent).toEqual('1 selected');
|
||||
expect(props.onChange).toHaveBeenCalledWith([testPolicyId1], []);
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId(`test-policy-${testPolicyId2}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should revert back to the full policy list when all items are unselected', async () => {
|
||||
const { getByTestId } = await render();
|
||||
testUtils.clickOnUnSelectAll();
|
||||
await testUtils.waitForDataToLoad();
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledWith([], []);
|
||||
expect((getByTestId('test-searchbar') as HTMLInputElement).disabled).toBe(false);
|
||||
expect(getByTestId(testUtils.testIds.policyFetchTotal).textContent).toEqual(
|
||||
'0 of 50 selected'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and "additionalListItems" prop is provided', () => {
|
||||
beforeEach(() => {
|
||||
props.additionalListItems = [
|
||||
{
|
||||
label: 'Item 1',
|
||||
checked: 'on', // << This one is selected
|
||||
'data-test-subj': 'customItem1',
|
||||
},
|
||||
{
|
||||
label: 'Item 2',
|
||||
checked: undefined,
|
||||
'data-test-subj': 'customItem2',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
it('should display the additional items on the list', async () => {
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect(getByTestId('customItem1')).toBeTruthy();
|
||||
expect(getByTestId('customItem2')).toBeTruthy();
|
||||
expect(getByTestId(testUtils.testIds.policyFetchTotal).textContent).toEqual(
|
||||
'1 of 52 selected'
|
||||
);
|
||||
});
|
||||
|
||||
it('should show custom items in the selected items', async () => {
|
||||
const { getByTestId, queryByTestId } = await render();
|
||||
testUtils.clickOnViewSelected();
|
||||
await testUtils.waitForDataToLoad();
|
||||
|
||||
expect(getByTestId('customItem1')).toBeTruthy();
|
||||
expect(queryByTestId('customItem2')).toBeNull();
|
||||
});
|
||||
|
||||
it('should include selection updates to additional items in the call to onChange', async () => {
|
||||
const { getByTestId } = await render();
|
||||
act(() => {
|
||||
fireEvent.click(getByTestId(`customItem1`));
|
||||
});
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledWith(
|
||||
[],
|
||||
[
|
||||
{
|
||||
label: 'Item 1',
|
||||
checked: undefined,
|
||||
'data-test-subj': 'customItem1',
|
||||
},
|
||||
{
|
||||
label: 'Item 2',
|
||||
checked: undefined,
|
||||
'data-test-subj': 'customItem2',
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('should display with a checkbox when "useCheckbox" prop is true', async () => {
|
||||
props.useCheckbox = true;
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect(getByTestId('test-customItem1-checkbox')).toBeTruthy();
|
||||
expect(getByTestId('test-customItem2-checkbox')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should default queryOptions.kuery to endpoint packages filter', async () => {
|
||||
await render();
|
||||
|
||||
expect(mockedContext.coreStart.http.get).toHaveBeenCalledWith(
|
||||
packagePolicyRouteService.getListPath(),
|
||||
{
|
||||
query: {
|
||||
kuery: 'ingest-package-policies.package.name: endpoint',
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
sortField: 'name',
|
||||
sortOrder: 'asc',
|
||||
withAgentCount: false,
|
||||
},
|
||||
version: '2023-10-31',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should use defined "queryOptions" in API call to fleet', async () => {
|
||||
props.queryOptions = {
|
||||
kuery: '',
|
||||
perPage: 100,
|
||||
sortField: 'description',
|
||||
sortOrder: 'desc',
|
||||
withAgentCount: true,
|
||||
};
|
||||
await render();
|
||||
|
||||
expect(mockedContext.coreStart.http.get).toHaveBeenCalledWith(
|
||||
packagePolicyRouteService.getListPath(),
|
||||
{
|
||||
query: {
|
||||
kuery: '',
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
sortField: 'description',
|
||||
sortOrder: 'desc',
|
||||
withAgentCount: true,
|
||||
},
|
||||
version: '2023-10-31',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should call "onFetch" after having queried Fleet API', async () => {
|
||||
props.selectedPolicyIds = [testPolicyId1];
|
||||
props.onFetch = jest.fn();
|
||||
await render();
|
||||
|
||||
expect(props.onFetch).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
total: 50,
|
||||
items: expect.any(Array),
|
||||
}),
|
||||
type: 'search',
|
||||
filtered: false,
|
||||
});
|
||||
|
||||
// Click the selected policies and check that onFetch is called with the results from the Bulk Get API
|
||||
testUtils.clickOnViewSelected();
|
||||
await testUtils.waitForDataToLoad();
|
||||
|
||||
expect(props.onFetch).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
total: 1,
|
||||
items: expect.any(Array),
|
||||
}),
|
||||
type: 'selected',
|
||||
filtered: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow policy display configuration via "policyDisplayOptions" prop', async () => {
|
||||
props.policyDisplayOptions = jest.fn((_policy) => {
|
||||
return { disabled: true };
|
||||
});
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect(getByTestId(`test-policy-${testPolicyId1}`).getAttribute('aria-disabled')).toEqual(
|
||||
'true'
|
||||
);
|
||||
expect(getByTestId(`test-policy-${testPolicyId2}`).getAttribute('aria-disabled')).toEqual(
|
||||
'true'
|
||||
);
|
||||
expect(getByTestId(`test-policy-${testPolicyId3}`).getAttribute('aria-disabled')).toEqual(
|
||||
'true'
|
||||
);
|
||||
});
|
||||
|
||||
it('should display as readonly when "isDisabled" prop is true', async () => {
|
||||
props.isDisabled = true;
|
||||
props.useCheckbox = true;
|
||||
props.selectedPolicyIds = [testPolicyId1];
|
||||
props.additionalListItems = [{ label: 'custom item 1', 'data-test-subj': 'customItem1' }];
|
||||
const { getByTestId } = await render();
|
||||
|
||||
[testPolicyId2, testPolicyId3].forEach((policyId) => {
|
||||
expect(getByTestId(`test-policy-${policyId}`).getAttribute('aria-disabled')).toEqual('true');
|
||||
expect((getByTestId(`test-policy-${policyId}-checkbox`) as HTMLInputElement).disabled).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
// We don't disable custom items since those can be fully controlled by the caller of the component
|
||||
expect(getByTestId('customItem1').getAttribute('aria-disabled')).toEqual('true');
|
||||
expect((getByTestId('test-customItem1-checkbox') as HTMLInputElement).disabled).toBe(true);
|
||||
|
||||
expect((getByTestId('test-searchbar') as HTMLInputElement).disabled).toBe(true);
|
||||
expect((getByTestId('test-selectAllButton') as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((getByTestId('test-unselectAllButton') as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
// Pagination and View Selected button should NOT be disabled
|
||||
expect((getByTestId('test-viewSelectedButton') as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((getByTestId('pagination-button-next') as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
// should still be able to see selected, but they also would be disabled
|
||||
testUtils.clickOnViewSelected();
|
||||
await testUtils.waitForDataToLoad();
|
||||
|
||||
expect((getByTestId('test-unselectAllButton') as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(getByTestId(`test-policy-${testPolicyId1}`).getAttribute('aria-disabled')).toEqual(
|
||||
'true'
|
||||
);
|
||||
expect(
|
||||
(getByTestId(`test-policy-${testPolicyId1}-checkbox`) as HTMLInputElement).disabled
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow additionalListItems to override the isDisabled prop default', async () => {
|
||||
props.isDisabled = true;
|
||||
props.useCheckbox = true;
|
||||
props.additionalListItems = [
|
||||
{ label: 'custom item 1', 'data-test-subj': 'customItem1', disabled: false },
|
||||
];
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect(getByTestId('customItem1').getAttribute('aria-disabled')).toEqual('false');
|
||||
expect((getByTestId('test-customItem1-checkbox') as HTMLInputElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should display no policies found empty state', async () => {
|
||||
apiMocks.responseProvider.packagePolicies.mockReturnValue({
|
||||
items: [],
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
total: 0,
|
||||
});
|
||||
const { getByTestId } = await render();
|
||||
|
||||
expect(getByTestId('test-noPolicies').textContent).toEqual('No policies found');
|
||||
});
|
||||
|
||||
it('should display API errors via a toast message', async () => {
|
||||
const err = new Error('something failed!');
|
||||
apiMocks.responseProvider.packagePolicies.mockImplementation(() => {
|
||||
throw err;
|
||||
});
|
||||
await render();
|
||||
|
||||
expect(mockedContext.coreStart.notifications.toasts.addError).toHaveBeenCalledWith(err, {
|
||||
title: 'Failed to fetch list of policies',
|
||||
toastMessage: undefined,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,776 @@
|
|||
/*
|
||||
* 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, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type EuiSelectableProps,
|
||||
type EuiSelectableOption,
|
||||
type EuiFieldSearchProps,
|
||||
EuiSelectable,
|
||||
EuiEmptyPrompt,
|
||||
EuiCheckbox,
|
||||
EuiSpacer,
|
||||
htmlIdGenerator,
|
||||
EuiPanel,
|
||||
EuiPagination,
|
||||
EuiProgress,
|
||||
EuiFieldSearch,
|
||||
EuiFlexGroup,
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiToolTip,
|
||||
EuiFlexItem,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import type {
|
||||
GetPackagePoliciesRequest,
|
||||
GetPackagePoliciesResponse,
|
||||
PackagePolicy,
|
||||
} from '@kbn/fleet-plugin/common';
|
||||
import { INTEGRATIONS_PLUGIN_ID, PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '@kbn/fleet-plugin/common';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import styled from '@emotion/styled';
|
||||
import { pagePathGetters } from '@kbn/fleet-plugin/public';
|
||||
import type { EuiPaginationProps } from '@elastic/eui/src/components/pagination/pagination';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import { css } from '@emotion/react';
|
||||
import { useFetchPolicyData } from './hooks/use_fetch_policy_data';
|
||||
import { APP_UI_ID } from '../../../../common';
|
||||
import { useAppUrl, useToasts } from '../../../common/lib/kibana';
|
||||
import { LinkToApp } from '../../../common/components/endpoint';
|
||||
import { useTestIdGenerator } from '../../hooks/use_test_id_generator';
|
||||
import { getPolicyDetailPath } from '../../common/routing';
|
||||
import { useUserPrivileges } from '../../../common/components/user_privileges';
|
||||
import { getEmptyTagValue } from '../../../common/components/empty_value';
|
||||
|
||||
const NOOP = () => {};
|
||||
const PolicySelectorContainer = styled.div<{ height?: string }>`
|
||||
.header-container {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.body-container {
|
||||
position: relative;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.footer-container {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.list-container {
|
||||
height: ${(props) => props.height ?? '225px'};
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.searchbar {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.policy-name .euiSelectableListItem__text {
|
||||
text-decoration: none !important;
|
||||
color: ${({ theme }) => theme.euiTheme.colors.textParagraph} !important;
|
||||
}
|
||||
|
||||
.euiSelectableList {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top-width: 0;
|
||||
}
|
||||
|
||||
.border-right {
|
||||
border-right: ${({ theme }) => theme.euiTheme.border.thin};
|
||||
padding-right: ${({ theme }) => theme.euiTheme.size.s};
|
||||
}
|
||||
`;
|
||||
|
||||
interface OptionPolicyData {
|
||||
policy: PackagePolicy;
|
||||
}
|
||||
|
||||
type CustomPolicyDisplayOptions = Pick<
|
||||
EuiSelectableOption,
|
||||
'disabled' | 'toolTipContent' | 'toolTipProps'
|
||||
>;
|
||||
|
||||
type AdditionalListItemProps = Omit<EuiSelectableOption, 'prepend'>;
|
||||
|
||||
export interface PolicySelectorProps {
|
||||
/** The list of policy IDs that are currently selected */
|
||||
selectedPolicyIds: string[];
|
||||
/** Callback for when selection changes occur. */
|
||||
onChange: (
|
||||
updatedSelectedPolicyIds: string[],
|
||||
updatedAdditionalListItems?: AdditionalListItemProps[]
|
||||
) => void;
|
||||
/**
|
||||
* Any query options supported by the fleet api. The defaults applied will filter for only
|
||||
* Endpoint integration policies sorted by name.
|
||||
*/
|
||||
queryOptions?: Pick<
|
||||
GetPackagePoliciesRequest['query'],
|
||||
'perPage' | 'kuery' | 'sortField' | 'sortOrder' | 'withAgentCount'
|
||||
>;
|
||||
/**
|
||||
* A list of Integration policy fields that should be used when user enters a search value. The
|
||||
* fields should match those that are defined in the `PackagePolicy` type, including deep
|
||||
* references like `package.name`, etc.
|
||||
* Default: `['name', 'description']`
|
||||
*/
|
||||
searchFields?: string[];
|
||||
/**
|
||||
* Callback function that is called everytime a new page of data is fetched from fleet. Use it
|
||||
* if needing to gain access to the API results that are returned
|
||||
*/
|
||||
onFetch?: (apiFetchResult: {
|
||||
/**
|
||||
* The type of data search. Will be set to `search` when fetching policies that are available
|
||||
* in the system, and to `selected` if the fetching of data was for policies that were selected.
|
||||
*/
|
||||
type: 'search' | 'selected';
|
||||
/** The filter that was entered by the user (if any). Applies only to `type === search`. */
|
||||
filtered: boolean;
|
||||
/** The data returned from the API */
|
||||
data: GetPackagePoliciesResponse;
|
||||
}) => void;
|
||||
/**
|
||||
* A set of additional items to include in the selectable list. Items will be displayed at the
|
||||
* bottom of each policy page.
|
||||
*/
|
||||
additionalListItems?: AdditionalListItemProps[];
|
||||
/** A css size value for the height of the area that shows the policies. Default is `225px` */
|
||||
height?: string;
|
||||
/**
|
||||
* If `true`, then checkboxes will be used next to each item on the list as the selector component.
|
||||
* This is the likely choice when using this component in a Form (ex. Artifact create/update form)
|
||||
*/
|
||||
useCheckbox?: boolean;
|
||||
/**
|
||||
* If `true`, each policy option on the list will have a `view details` link. The link will point
|
||||
* to the Security Solution policy details page if the policy is for elastic defend, and to the
|
||||
* fleet package policy details if not.
|
||||
*/
|
||||
showPolicyLink?: boolean;
|
||||
/**
|
||||
* Callback for setting additional display properties for each policy displayed on the list. callback is provided
|
||||
* with the package policy from fleet
|
||||
* @param policy
|
||||
*/
|
||||
policyDisplayOptions?: (
|
||||
policy: PackagePolicy
|
||||
) => Pick<EuiSelectableOption, 'disabled' | 'toolTipContent' | 'toolTipProps'>;
|
||||
/** If `true`, then only a single selection will be allowed. Default is `false` */
|
||||
singleSelection?: boolean;
|
||||
/** Disable selector */
|
||||
isDisabled?: boolean;
|
||||
'data-test-subj'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a component that displays a list of policies fetched from Fleet, which the user can
|
||||
* select and unselect. By default, it queries Fleet for Elastic Defend policies, but that can be
|
||||
* configured via `queryOptions.kuery`, thus it can display any type of Fleet integration policies
|
||||
*/
|
||||
export const PolicySelector = memo<PolicySelectorProps>(
|
||||
({
|
||||
queryOptions: {
|
||||
kuery = `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: endpoint`,
|
||||
sortField = 'name',
|
||||
sortOrder = 'asc',
|
||||
withAgentCount = false,
|
||||
perPage = 20,
|
||||
} = {},
|
||||
selectedPolicyIds,
|
||||
searchFields = ['name', 'description'],
|
||||
onChange,
|
||||
onFetch,
|
||||
height,
|
||||
useCheckbox = false,
|
||||
showPolicyLink = false,
|
||||
policyDisplayOptions,
|
||||
singleSelection = false,
|
||||
additionalListItems = [],
|
||||
isDisabled = false,
|
||||
'data-test-subj': dataTestSubj,
|
||||
}) => {
|
||||
const toasts = useToasts();
|
||||
const getTestId = useTestIdGenerator(dataTestSubj);
|
||||
const { getAppUrl } = useAppUrl();
|
||||
const { canReadPolicyManagement, canWriteIntegrationPolicies } =
|
||||
useUserPrivileges().endpointPrivileges;
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedListPage, setSelectedListPage] = useState(1);
|
||||
const [userSearchValue, setUserSearchValue] = useState('');
|
||||
const [searchKuery, setSearchKuery] = useState(kuery);
|
||||
const [view, setView] = useState<'full-list' | 'selected-list'>('full-list');
|
||||
const reactWindowFixedSizeList = useRef<{ scrollToItem: (index: number) => void }>();
|
||||
const handledApiData = useRef(new WeakSet<GetPackagePoliciesResponse>());
|
||||
|
||||
// Delays setting the `searchKuery` value thus allowing the user to pause typing - so
|
||||
// that we don't call the API on every character change.
|
||||
useDebounce(
|
||||
() => {
|
||||
setPage(1);
|
||||
|
||||
if (userSearchValue) {
|
||||
const kueryWithSearchValue: string = searchFields
|
||||
.map((field) => `(${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.${field}:*${userSearchValue}*)`)
|
||||
.join(' OR ');
|
||||
|
||||
if (kuery) {
|
||||
setSearchKuery(`(${kuery}) AND (${kueryWithSearchValue})`);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchKuery(kueryWithSearchValue);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchKuery(kuery);
|
||||
},
|
||||
300,
|
||||
[userSearchValue, kuery]
|
||||
);
|
||||
|
||||
const {
|
||||
data: policyListResponse,
|
||||
isFetching,
|
||||
isLoading,
|
||||
error,
|
||||
} = useFetchPolicyData(
|
||||
{
|
||||
kuery: searchKuery,
|
||||
sortOrder,
|
||||
sortField,
|
||||
perPage,
|
||||
withAgentCount,
|
||||
page: view === 'full-list' ? page : selectedListPage,
|
||||
},
|
||||
selectedPolicyIds,
|
||||
view
|
||||
);
|
||||
|
||||
const selectedCount = useMemo(() => {
|
||||
return (
|
||||
selectedPolicyIds.length +
|
||||
additionalListItems.filter((item) => item.checked === 'on').length
|
||||
);
|
||||
}, [additionalListItems, selectedPolicyIds.length]);
|
||||
|
||||
const totalItems: number = useMemo(() => {
|
||||
return (policyListResponse?.total ?? 0) + additionalListItems?.length ?? 0;
|
||||
}, [additionalListItems?.length, policyListResponse?.total]);
|
||||
|
||||
// @ts-expect-error EUI does not seem to have correctly types the `windowProps` which come from React-Window `FixedSizeList` component
|
||||
const listProps: EuiSelectableProps['listProps'] = useMemo(() => {
|
||||
return {
|
||||
bordered: false,
|
||||
showIcons: !useCheckbox,
|
||||
windowProps: {
|
||||
ref: reactWindowFixedSizeList,
|
||||
},
|
||||
};
|
||||
}, [useCheckbox]);
|
||||
|
||||
const selectableOptions: Array<EuiSelectableOption<OptionPolicyData>> = useMemo(() => {
|
||||
if (!policyListResponse) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const isPolicySelected = new Set<string>(selectedPolicyIds);
|
||||
const buildLinkToApp = (policy: PackagePolicy): React.ReactNode | null => {
|
||||
if (!showPolicyLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isEndpointPolicy = policy.package?.name === 'endpoint';
|
||||
|
||||
if ((isEndpointPolicy && !canReadPolicyManagement) || !canWriteIntegrationPolicies) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const appId = isEndpointPolicy ? APP_UI_ID : INTEGRATIONS_PLUGIN_ID;
|
||||
const urlPath = isEndpointPolicy
|
||||
? getPolicyDetailPath(policy.id)
|
||||
: pagePathGetters.integration_policy_edit({ packagePolicyId: policy.id })[1];
|
||||
|
||||
return (
|
||||
<LinkToApp
|
||||
href={getAppUrl({ path: urlPath, appId })}
|
||||
appPath={urlPath}
|
||||
target="_blank"
|
||||
data-test-subj={getTestId(`policy-${policy.id}-policyLink`)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.effectedPolicySelect.viewPolicyLinkLabel"
|
||||
defaultMessage="View policy"
|
||||
/>
|
||||
</LinkToApp>
|
||||
);
|
||||
};
|
||||
|
||||
return policyListResponse.items
|
||||
.map<EuiSelectableOption<OptionPolicyData>>((policy) => {
|
||||
const customDisplayOptions: CustomPolicyDisplayOptions = policyDisplayOptions
|
||||
? policyDisplayOptions(policy)
|
||||
: {};
|
||||
|
||||
return {
|
||||
disabled: isDisabled,
|
||||
...customDisplayOptions,
|
||||
label: policy.name,
|
||||
className: 'policy-name',
|
||||
'data-test-subj': getTestId(`policy-${policy.id}`),
|
||||
policy,
|
||||
checked: isPolicySelected.has(policy.id) ? 'on' : undefined,
|
||||
prepend: useCheckbox ? (
|
||||
<EuiCheckbox
|
||||
id={htmlIdGenerator()()}
|
||||
onChange={NOOP}
|
||||
checked={isPolicySelected.has(policy.id)}
|
||||
disabled={customDisplayOptions.disabled ?? isDisabled}
|
||||
data-test-subj={getTestId(`policy-${policy.id}-checkbox`)}
|
||||
/>
|
||||
) : undefined,
|
||||
append: showPolicyLink ? buildLinkToApp(policy) : null,
|
||||
};
|
||||
})
|
||||
.concat(
|
||||
...additionalListItems
|
||||
.filter(
|
||||
(additionalItem) => !(view === 'selected-list' && additionalItem.checked !== 'on')
|
||||
)
|
||||
.map((additionalItem) => {
|
||||
return {
|
||||
disabled: isDisabled,
|
||||
...additionalItem,
|
||||
'data-type': 'customItem',
|
||||
prepend: useCheckbox ? (
|
||||
<EuiCheckbox
|
||||
id={htmlIdGenerator()()}
|
||||
onChange={NOOP}
|
||||
checked={additionalItem.checked === 'on'}
|
||||
disabled={additionalItem.disabled ?? isDisabled}
|
||||
data-test-subj={getTestId(
|
||||
`${additionalItem['data-test-subj'] ?? 'additionalItem'}-checkbox`
|
||||
)}
|
||||
/>
|
||||
) : null,
|
||||
} as unknown as EuiSelectableOption<OptionPolicyData>;
|
||||
})
|
||||
);
|
||||
}, [
|
||||
additionalListItems,
|
||||
canReadPolicyManagement,
|
||||
canWriteIntegrationPolicies,
|
||||
getAppUrl,
|
||||
getTestId,
|
||||
isDisabled,
|
||||
policyDisplayOptions,
|
||||
policyListResponse,
|
||||
selectedPolicyIds,
|
||||
showPolicyLink,
|
||||
useCheckbox,
|
||||
view,
|
||||
]);
|
||||
|
||||
const noPoliciesFoundEmptyState = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiEmptyPrompt
|
||||
title={<h3>{'No policies found'}</h3>}
|
||||
titleSize="s"
|
||||
paddingSize="m"
|
||||
color="subdued"
|
||||
data-test-subj={getTestId('noPolicies')}
|
||||
body={
|
||||
userSearchValue ? (
|
||||
<EuiText size="s">{'Your search criteria did not match any policy'}</EuiText>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}, [getTestId, userSearchValue]);
|
||||
|
||||
const isCustomOption = useCallback((option: EuiSelectableOption) => {
|
||||
// @ts-expect-error
|
||||
return option['data-type'] === 'customItem';
|
||||
}, []);
|
||||
|
||||
const getUpdatedAdditionalListItems = useCallback(
|
||||
(
|
||||
updatedItem: AdditionalListItemProps,
|
||||
listOfAdditionalItems: AdditionalListItemProps[]
|
||||
): AdditionalListItemProps[] => {
|
||||
return listOfAdditionalItems.map((item) => {
|
||||
if (item.label === updatedItem.label) {
|
||||
return {
|
||||
...item,
|
||||
checked: updatedItem.checked,
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const listBuilderCallback = useCallback<NonNullable<EuiSelectableProps['children']>>(
|
||||
(list, _search) => {
|
||||
return <>{list}</>;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const getUpdatedSelectedPolicyIds = useCallback(
|
||||
(addToList: string[], removeFromList: string[]) => {
|
||||
return Array.from(
|
||||
new Set(
|
||||
selectedPolicyIds.filter((id) => !removeFromList.includes(id)).concat(...addToList)
|
||||
)
|
||||
);
|
||||
},
|
||||
[selectedPolicyIds]
|
||||
);
|
||||
|
||||
const handleOnPolicySelectChange = useCallback<
|
||||
Required<EuiSelectableProps<OptionPolicyData>>['onChange']
|
||||
>(
|
||||
(_updatedOptions, _ev, changedOption) => {
|
||||
const isChangedOptionCustom = isCustomOption(changedOption);
|
||||
const updatedPolicyIds = !isChangedOptionCustom
|
||||
? changedOption.checked === 'on'
|
||||
? selectedPolicyIds.concat(changedOption.policy.id)
|
||||
: selectedPolicyIds.filter((id) => id !== changedOption.policy.id)
|
||||
: selectedPolicyIds;
|
||||
|
||||
const updatedAdditionalItems: AdditionalListItemProps[] = isChangedOptionCustom
|
||||
? getUpdatedAdditionalListItems(changedOption, additionalListItems)
|
||||
: additionalListItems;
|
||||
|
||||
return onChange(updatedPolicyIds, updatedAdditionalItems);
|
||||
},
|
||||
[
|
||||
additionalListItems,
|
||||
getUpdatedAdditionalListItems,
|
||||
isCustomOption,
|
||||
onChange,
|
||||
selectedPolicyIds,
|
||||
]
|
||||
);
|
||||
|
||||
const onPageClickHandler: Required<EuiPaginationProps>['onPageClick'] = useCallback(
|
||||
(activePage) => {
|
||||
if (view === 'selected-list') {
|
||||
setSelectedListPage(activePage + 1);
|
||||
} else {
|
||||
setPage(activePage + 1);
|
||||
}
|
||||
},
|
||||
[view]
|
||||
);
|
||||
|
||||
const onSearchHandler: Required<EuiFieldSearchProps>['onSearch'] = useCallback(
|
||||
(updatedSearchValue) => {
|
||||
setUserSearchValue(updatedSearchValue);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onSearchInputChangeHandler: Required<EuiFieldSearchProps>['onChange'] = useCallback(
|
||||
(ev) => {
|
||||
setUserSearchValue(ev.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const viewSelectedOnClickHandler = useCallback(() => {
|
||||
setView((prevState) => (prevState === 'selected-list' ? 'full-list' : 'selected-list'));
|
||||
}, []);
|
||||
|
||||
const onSelectUnselectAllClickHandler = useCallback(
|
||||
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const isSelectAll = ev.currentTarget.value === 'selectAll';
|
||||
const policiesToSelect: string[] = [];
|
||||
const policiesToUnSelect: string[] = [];
|
||||
let updatedAdditionalItems = additionalListItems;
|
||||
|
||||
for (const option of selectableOptions) {
|
||||
if (isSelectAll) {
|
||||
if (!isCustomOption(option)) {
|
||||
policiesToSelect.push(option.policy.id);
|
||||
} else {
|
||||
updatedAdditionalItems = getUpdatedAdditionalListItems(
|
||||
{ ...option, checked: 'on' },
|
||||
updatedAdditionalItems
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!isCustomOption(option)) {
|
||||
policiesToUnSelect.push(option.policy.id);
|
||||
} else {
|
||||
updatedAdditionalItems = getUpdatedAdditionalListItems(
|
||||
{
|
||||
...option,
|
||||
checked: undefined,
|
||||
},
|
||||
updatedAdditionalItems
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onChange(
|
||||
getUpdatedSelectedPolicyIds(policiesToSelect, policiesToUnSelect),
|
||||
updatedAdditionalItems
|
||||
);
|
||||
},
|
||||
[
|
||||
additionalListItems,
|
||||
getUpdatedAdditionalListItems,
|
||||
getUpdatedSelectedPolicyIds,
|
||||
isCustomOption,
|
||||
onChange,
|
||||
selectableOptions,
|
||||
]
|
||||
);
|
||||
|
||||
// Show API errors when they are encountered
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toasts.addError(error, {
|
||||
title: i18n.translate('xpack.securitySolution.policySelector.apiFetchErrorToastTitle', {
|
||||
defaultMessage: 'Failed to fetch list of policies',
|
||||
}),
|
||||
toastMessage: error.body ? JSON.stringify(error.body) : undefined,
|
||||
});
|
||||
}
|
||||
}, [toasts, error]);
|
||||
|
||||
// When viewing Selected Policies, if they are all "un-selected", then set the view back to 'full-list'
|
||||
useEffect(() => {
|
||||
if (view === 'selected-list' && selectedCount === 0) {
|
||||
setView('full-list');
|
||||
}
|
||||
}, [selectedCount, view]);
|
||||
|
||||
// Everytime the `data` changes:
|
||||
// 1. scroll list back up to the top
|
||||
// 2. call `onFetch()` if defined
|
||||
useEffect(() => {
|
||||
if (policyListResponse && !isFetching && !handledApiData.current.has(policyListResponse)) {
|
||||
handledApiData.current.add(policyListResponse);
|
||||
|
||||
if (reactWindowFixedSizeList.current) {
|
||||
reactWindowFixedSizeList.current.scrollToItem(0);
|
||||
}
|
||||
|
||||
if (onFetch) {
|
||||
onFetch({
|
||||
type: view === 'selected-list' ? 'selected' : 'search',
|
||||
filtered: Boolean(userSearchValue),
|
||||
data: policyListResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isFetching, onFetch, policyListResponse, userSearchValue, view]);
|
||||
|
||||
return (
|
||||
<PolicySelectorContainer data-test-subj={dataTestSubj} height={height}>
|
||||
<EuiPanel paddingSize="s" hasShadow={false} hasBorder className="header-container">
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem>
|
||||
<EuiToolTip
|
||||
content={
|
||||
view === 'selected-list' ? (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.policySelector.searchbarTooltipMessage"
|
||||
defaultMessage="Search is not available when viewing selected policies"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<EuiFieldSearch
|
||||
placeholder={i18n.translate(
|
||||
'xpack.securitySolution.policySelector.searchbarPlaceholder',
|
||||
{ defaultMessage: 'Search policies' }
|
||||
)}
|
||||
value={userSearchValue}
|
||||
onSearch={onSearchHandler}
|
||||
onChange={onSearchInputChangeHandler}
|
||||
incremental={false}
|
||||
disabled={isDisabled || view === 'selected-list'}
|
||||
data-test-subj={getTestId('searchbar')}
|
||||
isClearable
|
||||
fullWidth
|
||||
compressed
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
iconType="filter"
|
||||
size="s"
|
||||
onClick={viewSelectedOnClickHandler}
|
||||
disabled={selectedCount === 0}
|
||||
fill={view === 'selected-list'}
|
||||
data-test-subj={getTestId('viewSelectedButton')}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.policySelector.selectedButton"
|
||||
defaultMessage="Selected"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
|
||||
<EuiPanel paddingSize="s" hasShadow={false} hasBorder className="body-container">
|
||||
{isFetching && (
|
||||
<EuiProgress
|
||||
size="xs"
|
||||
color="accent"
|
||||
position="absolute"
|
||||
data-test-subj={getTestId('isFetching')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectableOptions.length > 0 && (
|
||||
<EuiPanel
|
||||
paddingSize="xs"
|
||||
hasShadow={false}
|
||||
hasBorder={false}
|
||||
css={css`
|
||||
padding-top: 0 !important;
|
||||
`}
|
||||
>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
{view === 'full-list' && (
|
||||
<EuiFlexItem grow={false} className="border-right">
|
||||
<EuiToolTip
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.policySelector.selectAllTooltipMessage"
|
||||
defaultMessage="Select all displayed in current page"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiButtonEmpty
|
||||
size="xs"
|
||||
value="selectAll"
|
||||
onClick={onSelectUnselectAllClickHandler}
|
||||
data-test-subj={getTestId('selectAllButton')}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.policySelector.selectAll"
|
||||
defaultMessage="Select all"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.policySelector.unSelectAllTooltipMessage"
|
||||
defaultMessage="Un-select all in current page"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiButtonEmpty
|
||||
size="xs"
|
||||
value="unSelectAll"
|
||||
onClick={onSelectUnselectAllClickHandler}
|
||||
data-test-subj={getTestId('unselectAllButton')}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.policySelector.unSelectAll"
|
||||
defaultMessage="Un-select all"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
)}
|
||||
|
||||
<div className="list-container">
|
||||
<EuiSelectable<OptionPolicyData>
|
||||
options={selectableOptions}
|
||||
listProps={listProps}
|
||||
onChange={handleOnPolicySelectChange}
|
||||
searchable={false}
|
||||
singleSelection={singleSelection}
|
||||
isLoading={isLoading}
|
||||
height="full"
|
||||
data-test-subj={getTestId('list')}
|
||||
emptyMessage={noPoliciesFoundEmptyState}
|
||||
>
|
||||
{listBuilderCallback}
|
||||
</EuiSelectable>
|
||||
</div>
|
||||
</EuiPanel>
|
||||
|
||||
<EuiPanel paddingSize="s" hasShadow={false} hasBorder className="footer-container">
|
||||
<EuiFlexGroup gutterSize="s" justifyContent="center" alignItems="center">
|
||||
<EuiFlexItem className="border-right">
|
||||
<EuiText size="s" data-test-subj={getTestId('policyFetchTotal')}>
|
||||
{view === 'selected-list' ? (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.policySelector.totalSelected"
|
||||
defaultMessage="{selectedCount} selected"
|
||||
values={{ selectedCount }}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.policySelector.totalPoliciesFound"
|
||||
defaultMessage="{selectedCount} of {count} selected"
|
||||
values={{
|
||||
selectedCount,
|
||||
count: totalItems,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
{policyListResponse && policyListResponse.total > 0 ? (
|
||||
<EuiPagination
|
||||
aria-label={i18n.translate(
|
||||
'xpack.securitySolution.policySelector.policyListPagination',
|
||||
{ defaultMessage: 'Policy list pagination' }
|
||||
)}
|
||||
pageCount={Math.ceil((policyListResponse?.total ?? 0) / perPage)}
|
||||
activePage={(policyListResponse?.page ?? 1) - 1}
|
||||
onPageClick={onPageClickHandler}
|
||||
data-test-subj={getTestId('pagination')}
|
||||
/>
|
||||
) : (
|
||||
<EuiText size="s" color="textSu">
|
||||
{getEmptyTagValue()}
|
||||
</EuiText>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
</PolicySelectorContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
PolicySelector.displayName = 'PolicySelector';
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* 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 AppContextTestRender,
|
||||
createAppRootMockRenderer,
|
||||
} from '../../../common/mock/endpoint';
|
||||
import { allFleetHttpMocks } from '../../mocks';
|
||||
import type { PolicySelectorProps } from './policy_selector';
|
||||
import { act, fireEvent, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { PolicySelectorMenuButton } from './policy_selector_menu_button';
|
||||
|
||||
describe('PolicySelectorMenuButton component', () => {
|
||||
let mockedContext: AppContextTestRender;
|
||||
let apiMocks: ReturnType<typeof allFleetHttpMocks>;
|
||||
let testPolicyId1: string;
|
||||
let props: PolicySelectorProps;
|
||||
let render: () => ReturnType<AppContextTestRender['render']>;
|
||||
let renderResult: ReturnType<AppContextTestRender['render']>;
|
||||
|
||||
const clickOnButton = () => {
|
||||
act(() => {
|
||||
fireEvent.click(renderResult.getByTestId(`test`));
|
||||
});
|
||||
};
|
||||
|
||||
const waitForDataToLoad = async () => {
|
||||
await waitFor(() => {
|
||||
expect(renderResult.queryByTestId('test-policySelector-isFetching')).toBeNull();
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockedContext = createAppRootMockRenderer();
|
||||
apiMocks = allFleetHttpMocks(mockedContext.coreStart.http);
|
||||
|
||||
const apiPolicies = apiMocks.responseProvider.packagePolicies().items;
|
||||
testPolicyId1 = apiPolicies[0].id;
|
||||
apiMocks.responseProvider.packagePolicies.mockClear();
|
||||
|
||||
props = {
|
||||
selectedPolicyIds: [],
|
||||
onChange: jest.fn((updatedPolicySelection, updatedAdditionalItems) => {
|
||||
// Update props and re-render component so we get the latest state of it after user interactions
|
||||
const updatedProps: PolicySelectorProps = {
|
||||
...props,
|
||||
selectedPolicyIds: updatedPolicySelection,
|
||||
additionalListItems: updatedAdditionalItems,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
renderResult.rerender(<PolicySelectorMenuButton {...updatedProps} />);
|
||||
});
|
||||
}),
|
||||
'data-test-subj': 'test',
|
||||
};
|
||||
|
||||
render = (): ReturnType<AppContextTestRender['render']> => {
|
||||
renderResult = mockedContext.render(<PolicySelectorMenuButton {...props} />);
|
||||
return renderResult;
|
||||
};
|
||||
});
|
||||
|
||||
it('should display a button', async () => {
|
||||
const { getByTestId } = render();
|
||||
|
||||
expect(getByTestId('test').textContent).toEqual('Policies');
|
||||
});
|
||||
|
||||
it('should display button as disabled when "isDisabled" prop is true', async () => {
|
||||
props.isDisabled = true;
|
||||
const { getByTestId } = render();
|
||||
|
||||
expect((getByTestId('test') as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should display policy selector when button is clicked', async () => {
|
||||
const { getByTestId } = render();
|
||||
clickOnButton();
|
||||
|
||||
expect(getByTestId('test-policySelector')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should hide policy selector if button is clicked after popup was opened', async () => {
|
||||
const { getByTestId, queryByTestId } = render();
|
||||
clickOnButton();
|
||||
|
||||
expect(getByTestId('test-policySelector')).toBeTruthy();
|
||||
|
||||
clickOnButton();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('test-policySelector')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display total count of policies once the popover has been opened once', async () => {
|
||||
const { getByTestId } = render();
|
||||
clickOnButton();
|
||||
await waitForDataToLoad();
|
||||
|
||||
expect(getByTestId('test').textContent).toEqual('Policies3');
|
||||
});
|
||||
|
||||
it('should display count of selected filters', async () => {
|
||||
const { getByTestId } = render();
|
||||
clickOnButton();
|
||||
await waitForDataToLoad();
|
||||
act(() => {
|
||||
fireEvent.click(getByTestId(`test-policySelector-policy-${testPolicyId1}`));
|
||||
});
|
||||
|
||||
expect(getByTestId('test').textContent).toEqual('Policies1');
|
||||
});
|
||||
|
||||
it('should display count of selected filters that includes additionalListItems', async () => {
|
||||
props.additionalListItems = [{ label: 'custom item 1', 'data-test-subj': 'customItem1' }];
|
||||
const { getByTestId } = render();
|
||||
clickOnButton();
|
||||
await waitForDataToLoad();
|
||||
act(() => {
|
||||
fireEvent.click(getByTestId(`customItem1`));
|
||||
});
|
||||
|
||||
expect(getByTestId('test').textContent).toEqual('Policies1');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* 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, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { EuiFilterButton, EuiFilterGroup, EuiPopover, EuiText } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { useTestIdGenerator } from '../../hooks/use_test_id_generator';
|
||||
import type { PolicySelectorProps } from './policy_selector';
|
||||
import { PolicySelector } from './policy_selector';
|
||||
|
||||
export type PolicySelectorMenuButtonProps = PolicySelectorProps;
|
||||
|
||||
/**
|
||||
* A policy selector button - user is shown the list of policies when they click on the button.
|
||||
* Count of selections are reflected on the button.
|
||||
*/
|
||||
export const PolicySelectorMenuButton = memo<PolicySelectorMenuButtonProps>((props) => {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [countOfPolicies, setCountOfPolicies] = useState(0);
|
||||
const getTestId = useTestIdGenerator(props['data-test-subj']);
|
||||
|
||||
const countOfSelectedPolicies: number = useMemo(() => {
|
||||
return (
|
||||
props.selectedPolicyIds.length +
|
||||
(props.additionalListItems ?? []).filter((additionalItem) => additionalItem.checked === 'on')
|
||||
.length
|
||||
);
|
||||
}, [props.additionalListItems, props.selectedPolicyIds.length]);
|
||||
|
||||
const onFetch: Required<PolicySelectorMenuButtonProps>['onFetch'] = useCallback(
|
||||
(fetchedData) => {
|
||||
const { type, filtered, data } = fetchedData;
|
||||
|
||||
if (type === 'search' && !filtered) {
|
||||
setCountOfPolicies(data.total + (props.additionalListItems ?? []).length);
|
||||
}
|
||||
|
||||
if (props.onFetch) {
|
||||
props.onFetch(fetchedData);
|
||||
}
|
||||
},
|
||||
[props]
|
||||
);
|
||||
|
||||
const button = useMemo(
|
||||
() => (
|
||||
<EuiFilterButton
|
||||
iconType="arrowDown"
|
||||
data-test-subj={getTestId()}
|
||||
onClick={() => {
|
||||
setIsPopoverOpen((prevState) => !prevState);
|
||||
}}
|
||||
isSelected={isPopoverOpen}
|
||||
numFilters={countOfPolicies > 0 ? countOfPolicies : undefined}
|
||||
hasActiveFilters={countOfSelectedPolicies > 0}
|
||||
numActiveFilters={countOfSelectedPolicies}
|
||||
disabled={props.isDisabled}
|
||||
>
|
||||
<EuiText>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.management.policiesSelector.label"
|
||||
defaultMessage="Policies"
|
||||
/>
|
||||
</EuiText>
|
||||
</EuiFilterButton>
|
||||
),
|
||||
[getTestId, isPopoverOpen, countOfPolicies, countOfSelectedPolicies, props.isDisabled]
|
||||
);
|
||||
|
||||
const closePopover = useCallback(() => {
|
||||
setIsPopoverOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EuiFilterGroup>
|
||||
<EuiPopover
|
||||
button={button}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={closePopover}
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<PolicySelector {...props} onFetch={onFetch} data-test-subj={getTestId('policySelector')} />
|
||||
</EuiPopover>
|
||||
</EuiFilterGroup>
|
||||
);
|
||||
});
|
||||
PolicySelectorMenuButton.displayName = 'PolicySelectorMenuButton';
|
|
@ -6,10 +6,9 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import { act, fireEvent, waitFor } from '@testing-library/react';
|
||||
import type { AppContextTestRender } from '../../../common/mock/endpoint';
|
||||
import { createAppRootMockRenderer } from '../../../common/mock/endpoint';
|
||||
import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data';
|
||||
import { useUserPrivileges } from '../../../common/components/user_privileges';
|
||||
|
||||
import type { SearchExceptionsProps } from '.';
|
||||
|
@ -18,6 +17,7 @@ import { getEndpointPrivilegesInitialStateMock } from '../../../common/component
|
|||
import type { UserPrivilegesState } from '../../../common/components/user_privileges/user_privileges_context';
|
||||
import { initialUserPrivilegesState } from '../../../common/components/user_privileges/user_privileges_context';
|
||||
import type { EndpointPrivileges } from '../../../../common/endpoint/types';
|
||||
import { allFleetHttpMocks } from '../../mocks';
|
||||
|
||||
jest.mock('../../../common/components/user_privileges');
|
||||
|
||||
|
@ -46,6 +46,8 @@ describe('Search exceptions', () => {
|
|||
onSearchMock = jest.fn();
|
||||
appTestContext = createAppRootMockRenderer();
|
||||
|
||||
allFleetHttpMocks(appTestContext.coreStart.http);
|
||||
|
||||
render = (overrideProps = {}) => {
|
||||
const props: SearchExceptionsProps = {
|
||||
placeholder: 'search test',
|
||||
|
@ -106,24 +108,58 @@ describe('Search exceptions', () => {
|
|||
});
|
||||
|
||||
it('should hide policies selector when no license', () => {
|
||||
const generator = new EndpointDocGenerator('policy-list');
|
||||
const policy = generator.generatePolicyPackagePolicy();
|
||||
mockUseUserPrivileges.mockReturnValue(
|
||||
loadedUserPrivilegesState({ canCreateArtifactsByPolicy: false })
|
||||
);
|
||||
const element = render({ policyList: [policy], hasPolicyFilter: true });
|
||||
const element = render({ hasPolicyFilter: true });
|
||||
|
||||
expect(element.queryByTestId('policiesSelectorButton')).toBeNull();
|
||||
});
|
||||
|
||||
it('should display policies selector when right license', () => {
|
||||
const generator = new EndpointDocGenerator('policy-list');
|
||||
const policy = generator.generatePolicyPackagePolicy();
|
||||
mockUseUserPrivileges.mockReturnValue(
|
||||
loadedUserPrivilegesState({ canCreateArtifactsByPolicy: true })
|
||||
);
|
||||
const element = render({ policyList: [policy], hasPolicyFilter: true });
|
||||
const element = render({ hasPolicyFilter: true });
|
||||
|
||||
expect(element.queryByTestId('policiesSelectorButton')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should display additional policy selection items', async () => {
|
||||
const element = render({ hasPolicyFilter: true });
|
||||
act(() => {
|
||||
fireEvent.click(element.getByTestId(`policiesSelectorButton`));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(element.queryByTestId('policiesSelectorButton-policySelector-isFetching')).toBeNull();
|
||||
});
|
||||
|
||||
expect(element.getByTestId('globalOption')).toBeTruthy();
|
||||
expect(element.getByTestId('unassignedOption')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should include global option in onSearch call when user clicks on it', async () => {
|
||||
const element = render({ hasPolicyFilter: true });
|
||||
act(() => {
|
||||
fireEvent.click(element.getByTestId(`policiesSelectorButton`));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(element.queryByTestId('policiesSelectorButton-policySelector-isFetching')).toBeNull();
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(element.getByTestId(`globalOption`));
|
||||
});
|
||||
|
||||
expect(onSearchMock).toHaveBeenCalledWith('', 'global', false);
|
||||
});
|
||||
|
||||
it('should show global and unassigned policy options checked', async () => {
|
||||
const element = render({ hasPolicyFilter: true, defaultIncludedPolicies: 'global,unassigned' });
|
||||
act(() => {
|
||||
fireEvent.click(element.getByTestId(`policiesSelectorButton`));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(element.queryByTestId('policiesSelectorButton-policySelector-isFetching')).toBeNull();
|
||||
});
|
||||
|
||||
expect(element.getByTestId('globalOption').getAttribute('aria-checked')).toEqual('true');
|
||||
expect(element.getByTestId('unassignedOption').getAttribute('aria-checked')).toEqual('true');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,20 +5,31 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useState } from 'react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import type { EuiFieldSearchProps } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiButton } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { PolicySelectionItem } from '../policies_selector';
|
||||
import { PoliciesSelector } from '../policies_selector';
|
||||
import type { ImmutableArray, PolicyData } from '../../../../common/endpoint/types';
|
||||
import type { PolicySelectorMenuButtonProps } from '../policy_selector';
|
||||
import { PolicySelectorMenuButton } from '../policy_selector';
|
||||
import { useUserPrivileges } from '../../../common/components/user_privileges';
|
||||
|
||||
const GLOBAL_ENTRIES = i18n.translate(
|
||||
'xpack.securitySolution.management.policiesSelector.globalEntries',
|
||||
{
|
||||
defaultMessage: 'Global entries',
|
||||
}
|
||||
);
|
||||
const UNASSIGNED_ENTRIES = i18n.translate(
|
||||
'xpack.securitySolution.management.policiesSelector.unassignedEntries',
|
||||
{
|
||||
defaultMessage: 'Unassigned entries',
|
||||
}
|
||||
);
|
||||
|
||||
export interface SearchExceptionsProps {
|
||||
defaultValue?: string;
|
||||
placeholder: string;
|
||||
hasPolicyFilter?: boolean;
|
||||
policyList?: ImmutableArray<PolicyData>;
|
||||
defaultIncludedPolicies?: string;
|
||||
hideRefreshButton?: boolean;
|
||||
onSearch(
|
||||
|
@ -37,24 +48,61 @@ export const SearchExceptions = memo<SearchExceptionsProps>(
|
|||
onSearch,
|
||||
placeholder,
|
||||
hasPolicyFilter,
|
||||
policyList,
|
||||
defaultIncludedPolicies,
|
||||
defaultIncludedPolicies = '',
|
||||
hideRefreshButton = false,
|
||||
}) => {
|
||||
const { canCreateArtifactsByPolicy } = useUserPrivileges().endpointPrivileges;
|
||||
const initiallySelectedPolicies = useMemo(() => {
|
||||
return Array.from(
|
||||
new Set(defaultIncludedPolicies.split(',').filter((id) => id.trim() !== ''))
|
||||
);
|
||||
}, [defaultIncludedPolicies]);
|
||||
const [query, setQuery] = useState<string>(defaultValue);
|
||||
const [includedPolicies, setIncludedPolicies] = useState<string>(defaultIncludedPolicies || '');
|
||||
const [includedPolicies, setIncludedPolicies] = useState<string[]>(
|
||||
initiallySelectedPolicies.filter((id) => id !== 'global' && id !== 'unassigned')
|
||||
);
|
||||
|
||||
const onChangeSelection = useCallback(
|
||||
(items: PolicySelectionItem[]) => {
|
||||
const includePoliciesNew = items
|
||||
.filter((item) => item.checked === 'on')
|
||||
.map((item) => item.id)
|
||||
.join(',');
|
||||
const [additionalSelectionItems, setAdditionalSelectionItems] = useState<
|
||||
Required<PolicySelectorMenuButtonProps>['additionalListItems']
|
||||
>([
|
||||
{
|
||||
label: i18n.translate(
|
||||
'xpack.securitySolution.searchExceptions.additionalFiltersGroupLabel',
|
||||
{ defaultMessage: 'Additional filters' }
|
||||
),
|
||||
isGroupLabel: true,
|
||||
},
|
||||
{
|
||||
label: GLOBAL_ENTRIES,
|
||||
data: { id: 'global' },
|
||||
checked: initiallySelectedPolicies.includes('global') ? 'on' : undefined,
|
||||
'data-test-subj': 'globalOption',
|
||||
},
|
||||
{
|
||||
label: UNASSIGNED_ENTRIES,
|
||||
data: { id: 'unassigned' },
|
||||
checked: initiallySelectedPolicies.includes('unassigned') ? 'on' : undefined,
|
||||
'data-test-subj': 'unassignedOption',
|
||||
},
|
||||
]);
|
||||
|
||||
setIncludedPolicies(includePoliciesNew);
|
||||
const policySelectionOnChangeHandler = useCallback<PolicySelectorMenuButtonProps['onChange']>(
|
||||
(updatedSelectedPolicyIds, updatedAdditionalListItems) => {
|
||||
setIncludedPolicies(updatedSelectedPolicyIds);
|
||||
|
||||
onSearch(query, includePoliciesNew, false);
|
||||
const updatedFullSelection = [...updatedSelectedPolicyIds];
|
||||
|
||||
if (updatedAdditionalListItems) {
|
||||
for (const additionalItem of updatedAdditionalListItems) {
|
||||
if (additionalItem.checked === 'on') {
|
||||
updatedFullSelection.push(additionalItem.data?.id);
|
||||
}
|
||||
}
|
||||
|
||||
setAdditionalSelectionItems(updatedAdditionalListItems);
|
||||
}
|
||||
|
||||
onSearch(query, updatedFullSelection.join(','), false);
|
||||
},
|
||||
[onSearch, query]
|
||||
);
|
||||
|
@ -64,13 +112,13 @@ export const SearchExceptions = memo<SearchExceptionsProps>(
|
|||
[setQuery]
|
||||
);
|
||||
const handleOnSearch = useCallback(
|
||||
() => onSearch(query, includedPolicies, true),
|
||||
() => onSearch(query, includedPolicies.join(), true),
|
||||
[onSearch, query, includedPolicies]
|
||||
);
|
||||
|
||||
const handleOnSearchQuery = useCallback<NonNullable<EuiFieldSearchProps['onSearch']>>(
|
||||
(value) => {
|
||||
onSearch(value, includedPolicies, false);
|
||||
onSearch(value, includedPolicies.join(), false);
|
||||
},
|
||||
[onSearch, includedPolicies]
|
||||
);
|
||||
|
@ -93,12 +141,13 @@ export const SearchExceptions = memo<SearchExceptionsProps>(
|
|||
data-test-subj="searchField"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{canCreateArtifactsByPolicy && hasPolicyFilter && policyList ? (
|
||||
{canCreateArtifactsByPolicy && hasPolicyFilter ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<PoliciesSelector
|
||||
policies={policyList}
|
||||
defaultIncludedPolicies={defaultIncludedPolicies}
|
||||
onChangeSelection={onChangeSelection}
|
||||
<PolicySelectorMenuButton
|
||||
selectedPolicyIds={includedPolicies}
|
||||
additionalListItems={additionalSelectionItems}
|
||||
onChange={policySelectionOnChangeHandler}
|
||||
data-test-subj="policiesSelectorButton"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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 {
|
||||
BulkGetPackagePoliciesResponse,
|
||||
GetPackagePoliciesResponse,
|
||||
PackagePolicy,
|
||||
} from '@kbn/fleet-plugin/common';
|
||||
|
||||
/** Generic of the Fleet `GetPackagePoliciesResponse` */
|
||||
export type GetIntegrationPolicyListResponse<T extends PackagePolicy = PackagePolicy> = Omit<
|
||||
GetPackagePoliciesResponse,
|
||||
'items'
|
||||
> & {
|
||||
items: T[];
|
||||
};
|
||||
|
||||
/** Generic of the Fleet `BulkGetPackagePoliciesResponse` */
|
||||
export type GetBulkIntegrationPoliciesResponse<T extends PackagePolicy = PackagePolicy> = Omit<
|
||||
BulkGetPackagePoliciesResponse,
|
||||
'items'
|
||||
> & {
|
||||
items: T[];
|
||||
};
|
|
@ -7,10 +7,11 @@
|
|||
|
||||
import type { QueryObserverResult } from '@tanstack/react-query';
|
||||
import { useQuery, type UseQueryOptions } from '@tanstack/react-query';
|
||||
import type { BulkGetPackagePoliciesResponse } from '@kbn/fleet-plugin/common';
|
||||
import type { PackagePolicy } from '@kbn/fleet-plugin/common';
|
||||
import { packagePolicyRouteService } from '@kbn/fleet-plugin/common';
|
||||
import type { IHttpFetchError } from '@kbn/core-http-browser';
|
||||
import type { BulkGetPackagePoliciesRequestBody } from '@kbn/fleet-plugin/common/types';
|
||||
import type { GetBulkIntegrationPoliciesResponse } from './types';
|
||||
import { useHttp } from '../../../common/lib/kibana';
|
||||
|
||||
/**
|
||||
|
@ -19,21 +20,24 @@ import { useHttp } from '../../../common/lib/kibana';
|
|||
* @param ignoreMissing
|
||||
* @param options
|
||||
*/
|
||||
export const useBulkFetchFleetIntegrationPolicies = (
|
||||
export const useBulkFetchFleetIntegrationPolicies = <T extends PackagePolicy = PackagePolicy>(
|
||||
{ ids, ignoreMissing = true }: BulkGetPackagePoliciesRequestBody,
|
||||
options: UseQueryOptions<BulkGetPackagePoliciesResponse, IHttpFetchError> = {}
|
||||
): QueryObserverResult<BulkGetPackagePoliciesResponse> => {
|
||||
options: UseQueryOptions<GetBulkIntegrationPoliciesResponse<T>, IHttpFetchError> = {}
|
||||
): QueryObserverResult<GetBulkIntegrationPoliciesResponse<T>, IHttpFetchError> => {
|
||||
const http = useHttp();
|
||||
|
||||
return useQuery<BulkGetPackagePoliciesResponse, IHttpFetchError>({
|
||||
return useQuery<GetBulkIntegrationPoliciesResponse<T>, IHttpFetchError>({
|
||||
queryKey: ['bulkFetchFleetIntegrationPolicies', ids, ignoreMissing],
|
||||
refetchOnWindowFocus: false,
|
||||
...options,
|
||||
queryFn: async () => {
|
||||
return http.post(packagePolicyRouteService.getBulkGetPath(), {
|
||||
body: JSON.stringify({ ids, ignoreMissing }),
|
||||
version: '2023-10-31',
|
||||
});
|
||||
return http.post<GetBulkIntegrationPoliciesResponse<T>>(
|
||||
packagePolicyRouteService.getBulkGetPath(),
|
||||
{
|
||||
body: JSON.stringify({ ids, ignoreMissing }),
|
||||
version: '2023-10-31',
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* 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 { useQuery as _useQuery } from '@tanstack/react-query';
|
||||
import type { AppContextTestRender, ReactQueryHookRenderer } from '../../../common/mock/endpoint';
|
||||
import { createAppRootMockRenderer } from '../../../common/mock/endpoint';
|
||||
import type { Mutable } from 'utility-types';
|
||||
import type { GetPackagePoliciesRequest } from '@kbn/fleet-plugin/common/types';
|
||||
import { allFleetHttpMocks } from '../../mocks';
|
||||
import { useFetchIntegrationPolicyList } from './use_fetch_integration_policy_list';
|
||||
import { packagePolicyRouteService } from '@kbn/fleet-plugin/common';
|
||||
|
||||
const useQueryMock = _useQuery as jest.Mock;
|
||||
|
||||
jest.mock('@tanstack/react-query', () => {
|
||||
const actualReactQueryModule = jest.requireActual('@tanstack/react-query');
|
||||
|
||||
return {
|
||||
...actualReactQueryModule,
|
||||
useQuery: jest.fn((...args) => actualReactQueryModule.useQuery(...args)),
|
||||
};
|
||||
});
|
||||
|
||||
describe('useFetchIntegrationPolicyList() hook', () => {
|
||||
type HookRenderer = ReactQueryHookRenderer<
|
||||
Parameters<typeof useFetchIntegrationPolicyList>,
|
||||
ReturnType<typeof useFetchIntegrationPolicyList>
|
||||
>;
|
||||
|
||||
let queryOptions: Mutable<GetPackagePoliciesRequest['query']>;
|
||||
let options: NonNullable<Parameters<typeof useFetchIntegrationPolicyList>[1]>;
|
||||
let http: AppContextTestRender['coreStart']['http'];
|
||||
let renderHook: () => ReturnType<HookRenderer>;
|
||||
|
||||
beforeEach(() => {
|
||||
const testContext = createAppRootMockRenderer();
|
||||
|
||||
queryOptions = {};
|
||||
options = {};
|
||||
http = testContext.coreStart.http;
|
||||
allFleetHttpMocks(http);
|
||||
renderHook = () => {
|
||||
return (testContext.renderReactQueryHook as HookRenderer)(() =>
|
||||
useFetchIntegrationPolicyList(queryOptions, options)
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useQueryMock.mockClear();
|
||||
});
|
||||
|
||||
it('should call the correct fleet api with the query data provided', async () => {
|
||||
const { data } = await renderHook();
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
packagePolicyRouteService.getListPath(),
|
||||
expect.objectContaining({
|
||||
query: {},
|
||||
})
|
||||
);
|
||||
expect(data).toEqual(expect.objectContaining({ items: expect.any(Array) }));
|
||||
});
|
||||
|
||||
it('should pass defined query options to the fleet api', async () => {
|
||||
queryOptions = {
|
||||
withAgentCount: true,
|
||||
sortOrder: 'asc',
|
||||
sortField: 'name',
|
||||
kuery: 'somevalue',
|
||||
perPage: 100,
|
||||
page: 5,
|
||||
};
|
||||
await renderHook();
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
packagePolicyRouteService.getListPath(),
|
||||
expect.objectContaining({
|
||||
query: queryOptions,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow useQuery options overrides', async () => {
|
||||
options.queryKey = ['a', 'b'];
|
||||
options.retry = false;
|
||||
options.refetchInterval = 5;
|
||||
await renderHook();
|
||||
|
||||
expect(useQueryMock).toHaveBeenCalledWith(expect.objectContaining(queryOptions));
|
||||
});
|
||||
});
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* 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 { GetPackagePoliciesRequest, PackagePolicy } from '@kbn/fleet-plugin/common';
|
||||
import { API_VERSIONS, packagePolicyRouteService } from '@kbn/fleet-plugin/common';
|
||||
import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { IHttpFetchError } from '@kbn/core-http-browser';
|
||||
import { useHttp } from '../../../common/lib/kibana';
|
||||
import type { GetIntegrationPolicyListResponse } from './types';
|
||||
|
||||
/**
|
||||
* Fetch integration policies from Fleet.
|
||||
*/
|
||||
export const useFetchIntegrationPolicyList = <T extends PackagePolicy = PackagePolicy>(
|
||||
query: GetPackagePoliciesRequest['query'] = {},
|
||||
options: Omit<
|
||||
UseQueryOptions<GetIntegrationPolicyListResponse<T>, IHttpFetchError>,
|
||||
'queryFn'
|
||||
> = {}
|
||||
): UseQueryResult<GetIntegrationPolicyListResponse<T>, IHttpFetchError> => {
|
||||
const http = useHttp();
|
||||
|
||||
return useQuery<GetIntegrationPolicyListResponse<T>, IHttpFetchError>({
|
||||
queryKey: ['fetch-integration-policy-list', query],
|
||||
refetchOnWindowFocus: false,
|
||||
...options,
|
||||
queryFn: async () => {
|
||||
return http.get<GetIntegrationPolicyListResponse<T>>(
|
||||
packagePolicyRouteService.getListPath(),
|
||||
{
|
||||
query,
|
||||
version: API_VERSIONS.public.v1,
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
|
@ -24,6 +24,7 @@ import {
|
|||
EPM_API_ROUTES,
|
||||
PACKAGE_POLICY_API_ROUTES,
|
||||
} from '@kbn/fleet-plugin/common';
|
||||
import type { HttpFetchOptionsWithPath } from '@kbn/core-http-browser';
|
||||
import type { ResponseProvidersInterface } from '../../common/mock/endpoint/http_handler_mock_factory';
|
||||
import {
|
||||
composeHttpHandlerMocks,
|
||||
|
@ -186,6 +187,8 @@ export const fleetGetEndpointPackagePolicyHttpMock =
|
|||
export type FleetGetEndpointPackagePolicyListHttpMockInterface = ResponseProvidersInterface<{
|
||||
endpointPackagePolicyList: () => GetPolicyListResponse;
|
||||
}>;
|
||||
// TODO:PT delete this mock (duplicate against the fleet list api)
|
||||
/** @deprecated use `fleetGetPackagePoliciesListHttpMock` instead */
|
||||
export const fleetGetEndpointPackagePolicyListHttpMock =
|
||||
httpHandlerMockFactory<FleetGetEndpointPackagePolicyListHttpMockInterface>([
|
||||
{
|
||||
|
@ -292,7 +295,7 @@ export const fleetBulkGetAgentPolicyListHttpMock =
|
|||
]);
|
||||
|
||||
export type FleetBulkGetPackagePoliciesListHttpMockInterface = ResponseProvidersInterface<{
|
||||
bulkPackagePolicies: () => BulkGetPackagePoliciesResponse;
|
||||
bulkPackagePolicies: (options?: HttpFetchOptionsWithPath) => BulkGetPackagePoliciesResponse;
|
||||
}>;
|
||||
export const fleetBulkGetPackagePoliciesListHttpMock =
|
||||
httpHandlerMockFactory<FleetBulkGetPackagePoliciesListHttpMockInterface>([
|
||||
|
@ -300,7 +303,7 @@ export const fleetBulkGetPackagePoliciesListHttpMock =
|
|||
id: 'bulkPackagePolicies',
|
||||
path: PACKAGE_POLICY_API_ROUTES.BULK_GET_PATTERN,
|
||||
method: 'post',
|
||||
handler: ({ body }) => {
|
||||
handler: (_) => {
|
||||
const generator = new EndpointDocGenerator('seed');
|
||||
const fleetPackagePolicyGenerator = new FleetPackagePolicyGenerator('seed');
|
||||
const endpointMetadata = generator.generateHostMetadata();
|
||||
|
@ -318,13 +321,6 @@ export const fleetBulkGetPackagePoliciesListHttpMock =
|
|||
// FIXME: remove hard-coded IDs below and get them from the new FleetPackagePolicyGenerator (#2262)
|
||||
'ddf6570b-9175-4a6d-b288-61a09771c647',
|
||||
'b8e616ae-44fc-4be7-846c-ce8fa5c082dd',
|
||||
|
||||
// And finally, include any kql filters for package policies ids
|
||||
...getPackagePoliciesFromKueryString(
|
||||
`${AGENT_POLICY_SAVED_OBJECT_TYPE}.package_policies: (${(
|
||||
JSON.parse(body?.toString() ?? '{}')?.ids as string[]
|
||||
).join(' or ')} )`
|
||||
),
|
||||
];
|
||||
|
||||
return {
|
||||
|
@ -347,7 +343,7 @@ export const fleetGetPackagePoliciesListHttpMock =
|
|||
id: 'packagePolicies',
|
||||
path: PACKAGE_POLICY_API_ROUTES.LIST_PATTERN,
|
||||
method: 'get',
|
||||
handler: ({ query }) => {
|
||||
handler: () => {
|
||||
const generator = new EndpointDocGenerator('seed');
|
||||
const fleetPackagePolicyGenerator = new FleetPackagePolicyGenerator('seed');
|
||||
const endpointMetadata = generator.generateHostMetadata();
|
||||
|
@ -365,13 +361,6 @@ export const fleetGetPackagePoliciesListHttpMock =
|
|||
// FIXME: remove hard-coded IDs below and get them from the new FleetPackagePolicyGenerator (#2262)
|
||||
'ddf6570b-9175-4a6d-b288-61a09771c647',
|
||||
'b8e616ae-44fc-4be7-846c-ce8fa5c082dd',
|
||||
|
||||
// And finally, include any kql filters for package policies ids
|
||||
...getPackagePoliciesFromKueryString(
|
||||
`${AGENT_POLICY_SAVED_OBJECT_TYPE}.package_policies: (${(query?.ids as string[]).join(
|
||||
' or '
|
||||
)} )`
|
||||
),
|
||||
];
|
||||
|
||||
return {
|
||||
|
|
|
@ -23,3 +23,33 @@ export const getDeferred = function <T = void>(): DeferredInterface<T> {
|
|||
// @ts-ignore
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
interface TestSubjGenerator {
|
||||
(suffix?: string): string;
|
||||
|
||||
/**
|
||||
* Compose a new `TestSubjGenerator` that includes the previously provided prefix as well*/
|
||||
withPrefix: (prefix: string) => TestSubjGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* A testing utility for creating lists of tests iDs that can be used for testing.
|
||||
* This utility goes along with the `useTestIdGenerator()` hook and can be useful for large
|
||||
* reusable components if wanting to expose a list of tests ids that component supports.
|
||||
*
|
||||
* @param testSubjPrefix
|
||||
*/
|
||||
export const createTestSubjGenerator = (testSubjPrefix: string): TestSubjGenerator => {
|
||||
const testSubjGenerator: TestSubjGenerator = (suffix) => {
|
||||
if (suffix) {
|
||||
return `${testSubjPrefix}-${suffix}`;
|
||||
}
|
||||
return testSubjPrefix;
|
||||
};
|
||||
|
||||
testSubjGenerator.withPrefix = (prefix: string): TestSubjGenerator => {
|
||||
return createTestSubjGenerator(testSubjGenerator(prefix));
|
||||
};
|
||||
|
||||
return testSubjGenerator;
|
||||
};
|
||||
|
|
|
@ -22,7 +22,6 @@ import type { AppContextTestRender } from '../../../../../common/mock/endpoint';
|
|||
import { createAppRootMockRenderer } from '../../../../../common/mock/endpoint';
|
||||
import { ERRORS } from '../../translations';
|
||||
import { licenseService } from '../../../../../common/hooks/use_license';
|
||||
import type { PolicyData } from '../../../../../../common/endpoint/types';
|
||||
import { GLOBAL_ARTIFACT_TAG } from '../../../../../../common/endpoint/service/artifacts';
|
||||
import { ListOperatorEnum, ListOperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
|
||||
import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants';
|
||||
|
@ -128,8 +127,6 @@ describe('blocklist form', () => {
|
|||
): ArtifactFormComponentProps {
|
||||
const defaults: ArtifactFormComponentProps = {
|
||||
item: createItem(),
|
||||
policies: [],
|
||||
policiesIsLoading: false,
|
||||
onChange: onChangeSpy,
|
||||
mode: 'create' as ArtifactFormComponentProps['mode'],
|
||||
disabled: false,
|
||||
|
@ -492,57 +489,6 @@ describe('blocklist form', () => {
|
|||
expect(screen.getByTestId('blocklist-form-effectedPolicies-global')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('should correctly edit policies and retain all other tags', async () => {
|
||||
const policies: PolicyData[] = [
|
||||
{
|
||||
id: 'policy-id-123',
|
||||
name: 'some-policy-123',
|
||||
},
|
||||
{
|
||||
id: 'policy-id-456',
|
||||
name: 'some-policy-456',
|
||||
},
|
||||
] as PolicyData[];
|
||||
render(createProps({ policies, item: createItem({ tags: ['some:random_tag'] }) }));
|
||||
const byPolicyButton = screen.getByTestId('blocklist-form-effectedPolicies-perPolicy');
|
||||
await user.click(byPolicyButton);
|
||||
expect(byPolicyButton).toBeEnabled();
|
||||
|
||||
await user.click(screen.getByText(policies[1].name));
|
||||
const expected = createOnChangeArgs({
|
||||
item: createItem({
|
||||
tags: ['some:random_tag', `policy:${policies[1].id}`],
|
||||
}),
|
||||
});
|
||||
expect(onChangeSpy).toHaveBeenLastCalledWith(expected);
|
||||
});
|
||||
|
||||
it('should correctly retain selected policies when toggling between global/by policy', async () => {
|
||||
const policies: PolicyData[] = [
|
||||
{
|
||||
id: 'policy-id-123',
|
||||
name: 'some-policy-123',
|
||||
},
|
||||
{
|
||||
id: 'policy-id-456',
|
||||
name: 'some-policy-456',
|
||||
},
|
||||
] as PolicyData[];
|
||||
render(createProps({ policies, item: createItem({ tags: [`policy:${policies[1].id}`] }) }));
|
||||
expect(screen.getByTestId('blocklist-form-effectedPolicies-global')).toBeEnabled();
|
||||
|
||||
const byPolicyButton = screen.getByTestId('blocklist-form-effectedPolicies-perPolicy');
|
||||
await user.click(byPolicyButton);
|
||||
expect(byPolicyButton).toBeEnabled();
|
||||
await user.click(screen.getByText(policies[0].name));
|
||||
const expected = createOnChangeArgs({
|
||||
item: createItem({
|
||||
tags: policies.map((policy) => `policy:${policy.id}`),
|
||||
}),
|
||||
});
|
||||
expect(onChangeSpy).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
|
||||
it('should be valid if all required inputs complete', async () => {
|
||||
const validItem: ArtifactFormComponentProps['item'] = {
|
||||
list_id: ENDPOINT_ARTIFACT_LISTS.blocklists.id,
|
||||
|
|
|
@ -109,7 +109,7 @@ function isValid(itemValidation: ItemValidation): boolean {
|
|||
|
||||
// eslint-disable-next-line react/display-name
|
||||
export const BlockListForm = memo<ArtifactFormComponentProps>(
|
||||
({ item, policies, policiesIsLoading, onChange, mode, error: submitError }) => {
|
||||
({ item, onChange, mode, error: submitError }) => {
|
||||
const [nameVisited, setNameVisited] = useState(false);
|
||||
const [valueVisited, setValueVisited] = useState({ value: false }); // Use object to trigger re-render
|
||||
const warningsRef = useRef<ItemValidation>({ name: {}, value: {} });
|
||||
|
@ -669,9 +669,7 @@ export const BlockListForm = memo<ArtifactFormComponentProps>(
|
|||
<EuiFormRow fullWidth>
|
||||
<EffectedPolicySelect
|
||||
item={item}
|
||||
options={policies}
|
||||
onChange={handleEffectedPolicyOnChange}
|
||||
isLoading={policiesIsLoading}
|
||||
description={POLICY_SELECT_DESCRIPTION}
|
||||
data-test-subj={getTestId('effectedPolicies')}
|
||||
/>
|
||||
|
|
|
@ -36,8 +36,6 @@ import { EventFiltersForm } from './form';
|
|||
|
||||
import { getInitialExceptionFromEvent } from '../utils';
|
||||
import { useHttp, useKibana, useToasts } from '../../../../../common/lib/kibana';
|
||||
import { useGetEndpointSpecificPolicies } from '../../../../services/policies/hooks';
|
||||
import { getLoadPoliciesError } from '../../../../common/translations';
|
||||
|
||||
import { EventFiltersApiClient } from '../../service/api_client';
|
||||
import { getCreationSuccessMessage, getCreationErrorMessage } from '../translations';
|
||||
|
@ -63,14 +61,6 @@ export const EventFiltersFlyout: React.FC<EventFiltersFlyoutProps> = memo(
|
|||
data: { search },
|
||||
} = useKibana().services;
|
||||
|
||||
// load the list of policies>
|
||||
const policiesRequest = useGetEndpointSpecificPolicies({
|
||||
perPage: 1000,
|
||||
onError: (error) => {
|
||||
toasts.addWarning(getLoadPoliciesError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const [exception, setException] = useState<ArtifactFormComponentProps['item']>(
|
||||
getInitialExceptionFromEvent(data)
|
||||
);
|
||||
|
@ -81,11 +71,6 @@ export const EventFiltersFlyout: React.FC<EventFiltersFlyoutProps> = memo(
|
|||
|
||||
const [showConfirmModal, setShowConfirmModal] = useState<boolean>(false);
|
||||
|
||||
const policiesIsLoading = useMemo<boolean>(
|
||||
() => policiesRequest.isLoading || policiesRequest.isRefetching,
|
||||
[policiesRequest]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const enrichEvent = async () => {
|
||||
if (!data || !data._index) return;
|
||||
|
@ -126,9 +111,9 @@ export const EventFiltersFlyout: React.FC<EventFiltersFlyoutProps> = memo(
|
|||
}, []);
|
||||
|
||||
const handleOnClose = useCallback(() => {
|
||||
if (policiesIsLoading || isSubmittingData) return;
|
||||
if (isSubmittingData) return;
|
||||
onClose();
|
||||
}, [isSubmittingData, policiesIsLoading, onClose]);
|
||||
}, [isSubmittingData, onClose]);
|
||||
|
||||
const submitEventFilter = useCallback(() => {
|
||||
return submitData(exception, {
|
||||
|
@ -155,11 +140,8 @@ export const EventFiltersFlyout: React.FC<EventFiltersFlyoutProps> = memo(
|
|||
<EuiButton
|
||||
data-test-subj="add-exception-confirm-button"
|
||||
fill
|
||||
disabled={
|
||||
!isFormValid || isSubmittingData || (!!data && !enrichedData) || policiesIsLoading
|
||||
}
|
||||
disabled={!isFormValid || isSubmittingData || (!!data && !enrichedData)}
|
||||
onClick={handleOnSubmit}
|
||||
isLoading={policiesIsLoading}
|
||||
>
|
||||
{data ? (
|
||||
<FormattedMessage
|
||||
|
@ -174,7 +156,7 @@ export const EventFiltersFlyout: React.FC<EventFiltersFlyoutProps> = memo(
|
|||
)}
|
||||
</EuiButton>
|
||||
),
|
||||
[data, enrichedData, handleOnSubmit, isFormValid, isSubmittingData, policiesIsLoading]
|
||||
[data, enrichedData, handleOnSubmit, isFormValid, isSubmittingData]
|
||||
);
|
||||
|
||||
// update flyout state with form state
|
||||
|
@ -248,8 +230,6 @@ export const EventFiltersFlyout: React.FC<EventFiltersFlyoutProps> = memo(
|
|||
item={exception}
|
||||
mode="create"
|
||||
onChange={onChange}
|
||||
policies={policiesRequest?.data?.items ?? []}
|
||||
policiesIsLoading={policiesIsLoading}
|
||||
/>
|
||||
</EuiFlyoutBody>
|
||||
|
||||
|
|
|
@ -17,21 +17,16 @@ import userEvent from '@testing-library/user-event';
|
|||
import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
|
||||
|
||||
import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '@kbn/securitysolution-list-constants';
|
||||
import type {
|
||||
ArtifactFormComponentOnChangeCallbackProps,
|
||||
ArtifactFormComponentProps,
|
||||
} from '../../../../components/artifact_list_page';
|
||||
import type { ArtifactFormComponentProps } from '../../../../components/artifact_list_page';
|
||||
import { OperatingSystem } from '@kbn/securitysolution-utils';
|
||||
import { EventFiltersForm } from './form';
|
||||
import { EndpointDocGenerator } from '../../../../../../common/endpoint/generate_data';
|
||||
import type { PolicyData } from '../../../../../../common/endpoint/types';
|
||||
import { MAX_COMMENT_LENGTH } from '../../../../../../common/constants';
|
||||
import {
|
||||
BY_POLICY_ARTIFACT_TAG_PREFIX,
|
||||
FILTER_PROCESS_DESCENDANTS_TAG,
|
||||
GLOBAL_ARTIFACT_TAG,
|
||||
} from '../../../../../../common/endpoint/service/artifacts/constants';
|
||||
import type { IHttpFetchError } from '@kbn/core-http-browser';
|
||||
import { buildPerPolicyTag } from '../../../../../../common/endpoint/service/artifacts/utils';
|
||||
|
||||
jest.mock('../../../../../common/lib/kibana');
|
||||
jest.mock('../../../../../common/containers/source');
|
||||
|
@ -83,7 +78,6 @@ const TestComponentWrapper: typeof EventFiltersForm = (formProps: ArtifactFormCo
|
|||
|
||||
describe('Event filter form', () => {
|
||||
const formPrefix = 'eventFilters-form';
|
||||
const generator = new EndpointDocGenerator('effected-policy-select');
|
||||
|
||||
let formProps: jest.Mocked<ArtifactFormComponentProps>;
|
||||
let mockedContext: AppContextTestRender;
|
||||
|
@ -136,32 +130,6 @@ describe('Event filter form', () => {
|
|||
};
|
||||
}
|
||||
|
||||
function createOnChangeArgs(
|
||||
overrides: Partial<ArtifactFormComponentOnChangeCallbackProps>
|
||||
): ArtifactFormComponentOnChangeCallbackProps {
|
||||
const defaults = {
|
||||
item: createItem(),
|
||||
isValid: false,
|
||||
};
|
||||
return {
|
||||
...defaults,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPolicies(): PolicyData[] {
|
||||
const policies = [
|
||||
generator.generatePolicyPackagePolicy(),
|
||||
generator.generatePolicyPackagePolicy(),
|
||||
];
|
||||
policies.map((p, i) => {
|
||||
p.id = `id-${i}`;
|
||||
p.name = `some-policy-${Math.random().toString(36).split('.').pop()}`;
|
||||
return p;
|
||||
});
|
||||
return policies;
|
||||
}
|
||||
|
||||
const setValidItemForEditing = () => {
|
||||
formProps.mode = 'edit';
|
||||
formProps.item.name = 'item name';
|
||||
|
@ -195,12 +163,10 @@ describe('Event filter form', () => {
|
|||
mode: 'create',
|
||||
disabled: false,
|
||||
error: undefined,
|
||||
policiesIsLoading: false,
|
||||
onChange: jest.fn((updates) => {
|
||||
latestUpdatedItem = updates.item;
|
||||
isLatestUpdatedItemValid = updates.isValid;
|
||||
}),
|
||||
policies: [],
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -209,29 +175,12 @@ describe('Event filter form', () => {
|
|||
});
|
||||
|
||||
describe('Details and Conditions', () => {
|
||||
it('should render correctly without data', () => {
|
||||
formProps.policies = createPolicies();
|
||||
formProps.policiesIsLoading = true;
|
||||
formProps.item.tags = [
|
||||
formProps.policies.map((p) => `${BY_POLICY_ARTIFACT_TAG_PREFIX}${p.id}`)[0],
|
||||
];
|
||||
formProps.item.entries = [];
|
||||
render();
|
||||
expect(renderResult.getByTestId('loading-spinner')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should render correctly with data', async () => {
|
||||
formProps.policies = createPolicies();
|
||||
render();
|
||||
expect(renderResult.queryByTestId('loading-spinner')).toBeNull();
|
||||
expect(renderResult.getByTestId('exceptionsBuilderWrapper')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should display sections', async () => {
|
||||
render();
|
||||
expect(renderResult.queryByText('Details')).not.toBeNull();
|
||||
expect(renderResult.queryByText('Conditions')).not.toBeNull();
|
||||
expect(renderResult.queryByText('Comments')).not.toBeNull();
|
||||
expect(renderResult.getByTestId('eventFilters-form-effectedPolicies')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display name error only when on blur and empty name', async () => {
|
||||
|
@ -328,233 +277,6 @@ describe('Event filter form', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Policy section', () => {
|
||||
beforeEach(() => {
|
||||
formProps.policies = createPolicies();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('should display loader when policies are still loading', () => {
|
||||
formProps.policiesIsLoading = true;
|
||||
formProps.item.tags = [
|
||||
formProps.policies.map((p) => `${BY_POLICY_ARTIFACT_TAG_PREFIX}${p.id}`)[0],
|
||||
];
|
||||
render();
|
||||
expect(renderResult.getByTestId('loading-spinner')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should display the policy list when "per policy" is selected', async () => {
|
||||
render();
|
||||
await userEvent.click(
|
||||
renderResult.getByTestId('eventFilters-form-effectedPolicies-perPolicy')
|
||||
);
|
||||
rerenderWithLatestProps();
|
||||
// policy selector should show up
|
||||
expect(
|
||||
renderResult.getByTestId(`${formPrefix}-effectedPolicies-policiesSelectable`)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call onChange when a policy is selected from the policy selection', async () => {
|
||||
formProps.item.tags = [
|
||||
formProps.policies.map((p) => `${BY_POLICY_ARTIFACT_TAG_PREFIX}${p.id}`)[0],
|
||||
];
|
||||
render();
|
||||
const policyId = formProps.policies[0].id;
|
||||
await userEvent.click(renderResult.getByTestId(`${formPrefix}-effectedPolicies-perPolicy`));
|
||||
await userEvent.click(renderResult.getByTestId(`policy-${policyId}`));
|
||||
formProps.item.tags = formProps.onChange.mock.calls[0][0].item.tags;
|
||||
rerender();
|
||||
const expected = createOnChangeArgs({
|
||||
item: {
|
||||
...formProps.item,
|
||||
tags: [`${BY_POLICY_ARTIFACT_TAG_PREFIX}${policyId}`],
|
||||
},
|
||||
});
|
||||
expect(formProps.onChange).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
|
||||
it('should have global policy by default', async () => {
|
||||
render();
|
||||
expect(renderResult.getByTestId('eventFilters-form-effectedPolicies-global')).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
expect(
|
||||
renderResult.getByTestId('eventFilters-form-effectedPolicies-perPolicy')
|
||||
).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('should retain the previous policy selection when switching from per-policy to global', async () => {
|
||||
formProps.item.tags = [
|
||||
formProps.policies.map((p) => `${BY_POLICY_ARTIFACT_TAG_PREFIX}${p.id}`)[0],
|
||||
];
|
||||
render();
|
||||
const policyId = formProps.policies[0].id;
|
||||
// move to per-policy and select the first
|
||||
await userEvent.click(
|
||||
renderResult.getByTestId('eventFilters-form-effectedPolicies-perPolicy')
|
||||
);
|
||||
await userEvent.click(renderResult.getByTestId(`policy-${policyId}`));
|
||||
formProps.item.tags = formProps.onChange.mock.calls[0][0].item.tags;
|
||||
rerender();
|
||||
expect(
|
||||
renderResult.queryByTestId(`${formPrefix}-effectedPolicies-policiesSelectable`)
|
||||
).toBeTruthy();
|
||||
expect(formProps.item.tags).toEqual([`${BY_POLICY_ARTIFACT_TAG_PREFIX}${policyId}`]);
|
||||
|
||||
// move back to global
|
||||
await userEvent.click(renderResult.getByTestId('eventFilters-form-effectedPolicies-global'));
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
formProps.item.tags = [GLOBAL_ARTIFACT_TAG];
|
||||
rerenderWithLatestProps();
|
||||
expect(formProps.item.tags).toEqual([GLOBAL_ARTIFACT_TAG]);
|
||||
expect(
|
||||
renderResult.queryByTestId(`${formPrefix}-effectedPolicies-policiesSelectable`)
|
||||
).toBeFalsy();
|
||||
|
||||
// move back to per-policy
|
||||
await userEvent.click(
|
||||
renderResult.getByTestId('eventFilters-form-effectedPolicies-perPolicy')
|
||||
);
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
formProps.item.tags = [`${BY_POLICY_ARTIFACT_TAG_PREFIX}${policyId}`];
|
||||
rerender();
|
||||
// on change called with the previous policy
|
||||
expect(formProps.item.tags).toEqual([`${BY_POLICY_ARTIFACT_TAG_PREFIX}${policyId}`]);
|
||||
// the previous selected policy should be selected
|
||||
// expect(renderResult.getByTestId(`policy-${policyId}`)).toHaveAttribute(
|
||||
// 'data-test-selected',
|
||||
// 'true'
|
||||
// );
|
||||
});
|
||||
|
||||
it('should preserve other tags when updating artifact assignment', async () => {
|
||||
formProps.item.tags = ['some:random_tag'];
|
||||
render();
|
||||
const policyId = formProps.policies[0].id;
|
||||
// move to per-policy and select the first
|
||||
await userEvent.click(
|
||||
renderResult.getByTestId('eventFilters-form-effectedPolicies-perPolicy')
|
||||
);
|
||||
await userEvent.click(renderResult.getByTestId(`policy-${policyId}`));
|
||||
|
||||
expect(formProps.onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({
|
||||
tags: ['some:random_tag', `${BY_POLICY_ARTIFACT_TAG_PREFIX}id-0`],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('when opened for editing', () => {
|
||||
beforeEach(() => {
|
||||
setValidItemForEditing();
|
||||
});
|
||||
|
||||
it('item should be valid after changing to global assignment', async () => {
|
||||
formProps.item.tags = [];
|
||||
render();
|
||||
expect(isLatestUpdatedItemValid).toBe(false);
|
||||
|
||||
await userEvent.click(
|
||||
renderResult.getByTestId('eventFilters-form-effectedPolicies-global')
|
||||
);
|
||||
|
||||
expect(isLatestUpdatedItemValid).toBe(true);
|
||||
expect(latestUpdatedItem.tags).toEqual([GLOBAL_ARTIFACT_TAG]);
|
||||
});
|
||||
|
||||
it('item should be valid after changing to per policy assignment', async () => {
|
||||
formProps.item.tags = [GLOBAL_ARTIFACT_TAG];
|
||||
render();
|
||||
expect(isLatestUpdatedItemValid).toBe(false);
|
||||
|
||||
await userEvent.click(
|
||||
renderResult.getByTestId('eventFilters-form-effectedPolicies-perPolicy')
|
||||
);
|
||||
|
||||
expect(isLatestUpdatedItemValid).toBe(true);
|
||||
expect(latestUpdatedItem.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('item should be valid after changing policy assignment', async () => {
|
||||
formProps.item.tags = [];
|
||||
render();
|
||||
expect(isLatestUpdatedItemValid).toBe(false);
|
||||
|
||||
const policyId = formProps.policies[0].id;
|
||||
await userEvent.click(renderResult.getByTestId(`policy-${policyId}`));
|
||||
|
||||
expect(isLatestUpdatedItemValid).toBe(true);
|
||||
expect(latestUpdatedItem.tags).toEqual([`${BY_POLICY_ARTIFACT_TAG_PREFIX}${policyId}`]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Policy section with downgraded license', () => {
|
||||
beforeEach(() => {
|
||||
const policies = createPolicies();
|
||||
formProps.policies = policies;
|
||||
formProps.item.tags = [policies.map((p) => `${BY_POLICY_ARTIFACT_TAG_PREFIX}${p.id}`)[0]];
|
||||
formProps.mode = 'edit';
|
||||
// downgrade license
|
||||
(licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should hide assignment section when no license', () => {
|
||||
render();
|
||||
formProps.item.tags = [GLOBAL_ARTIFACT_TAG];
|
||||
rerender();
|
||||
expect(renderResult.queryByTestId(`${formPrefix}-effectedPolicies`)).toBeNull();
|
||||
});
|
||||
|
||||
it('should hide assignment section when create mode and no license even with by policy', () => {
|
||||
render();
|
||||
formProps.mode = 'create';
|
||||
rerender();
|
||||
expect(renderResult.queryByTestId(`${formPrefix}-effectedPolicies`)).toBeNull();
|
||||
});
|
||||
|
||||
it('should show disabled assignment section when edit mode and no license with by policy', async () => {
|
||||
render();
|
||||
formProps.item.tags = [`${BY_POLICY_ARTIFACT_TAG_PREFIX}id-0`];
|
||||
rerender();
|
||||
|
||||
expect(
|
||||
renderResult.queryByTestId('eventFilters-form-effectedPolicies-perPolicy')
|
||||
).not.toBeNull();
|
||||
expect(renderResult.getByTestId('policy-id-0').getAttribute('aria-disabled')).toBe('true');
|
||||
});
|
||||
|
||||
it("allows the user to set the event filter entry to 'Global' in the edit option", async () => {
|
||||
render();
|
||||
const globalButtonInput = renderResult.getByTestId(
|
||||
'eventFilters-form-effectedPolicies-global'
|
||||
) as HTMLButtonElement;
|
||||
await userEvent.click(globalButtonInput);
|
||||
formProps.item.tags = [GLOBAL_ARTIFACT_TAG];
|
||||
rerender();
|
||||
const expected = createOnChangeArgs({
|
||||
item: {
|
||||
...formProps.item,
|
||||
tags: [GLOBAL_ARTIFACT_TAG],
|
||||
},
|
||||
});
|
||||
expect(formProps.onChange).toHaveBeenCalledWith(expected);
|
||||
|
||||
const policyItem = formProps.onChange.mock.calls[0][0].item.tags
|
||||
? formProps.onChange.mock.calls[0][0].item.tags[0]
|
||||
: '';
|
||||
|
||||
expect(policyItem).toBe(GLOBAL_ARTIFACT_TAG);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filter process descendants', () => {
|
||||
beforeEach(() => {
|
||||
mockedContext.setExperimentalFlag({ filterProcessDescendantsForEventFiltersEnabled: true });
|
||||
|
@ -631,20 +353,14 @@ describe('Event filter form', () => {
|
|||
});
|
||||
|
||||
it('should add the tag always after policy assignment tags', async () => {
|
||||
formProps.policies = createPolicies();
|
||||
const perPolicyTags = formProps.policies.map(
|
||||
(p) => `${BY_POLICY_ARTIFACT_TAG_PREFIX}${p.id}`
|
||||
);
|
||||
formProps.item.tags = perPolicyTags;
|
||||
const perPolicyTag = buildPerPolicyTag('foo');
|
||||
formProps.item.tags = [perPolicyTag];
|
||||
render();
|
||||
|
||||
await userEvent.click(
|
||||
renderResult.getByTestId(`${formPrefix}-filterProcessDescendantsButton`)
|
||||
);
|
||||
expect(latestUpdatedItem.tags).toStrictEqual([
|
||||
...perPolicyTags,
|
||||
FILTER_PROCESS_DESCENDANTS_TAG,
|
||||
]);
|
||||
expect(latestUpdatedItem.tags).toStrictEqual([perPolicyTag, FILTER_PROCESS_DESCENDANTS_TAG]);
|
||||
|
||||
rerenderWithLatestProps();
|
||||
await userEvent.click(renderResult.getByTestId(`${formPrefix}-effectedPolicies-global`));
|
||||
|
@ -670,10 +386,7 @@ describe('Event filter form', () => {
|
|||
await userEvent.click(
|
||||
renderResult.getByTestId('eventFilters-form-effectedPolicies-perPolicy')
|
||||
);
|
||||
expect(latestUpdatedItem.tags).toStrictEqual([
|
||||
FILTER_PROCESS_DESCENDANTS_TAG,
|
||||
...perPolicyTags,
|
||||
]);
|
||||
expect(latestUpdatedItem.tags).toStrictEqual([FILTER_PROCESS_DESCENDANTS_TAG, perPolicyTag]);
|
||||
});
|
||||
|
||||
it('should display a tooltip to the user', async () => {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -44,7 +44,7 @@ interface ExceptionIpEntry {
|
|||
}
|
||||
|
||||
export const HostIsolationExceptionsForm = memo<ArtifactFormComponentProps>(
|
||||
({ item: exception, onChange, policies, disabled, mode, error }) => {
|
||||
({ item: exception, onChange, disabled, mode, error }) => {
|
||||
const ipEntry = useMemo(() => {
|
||||
return (exception.entries[0] || {
|
||||
field: 'destination.ip',
|
||||
|
@ -278,7 +278,6 @@ export const HostIsolationExceptionsForm = memo<ArtifactFormComponentProps>(
|
|||
>
|
||||
<EffectedPolicySelect
|
||||
item={exception}
|
||||
options={policies}
|
||||
onChange={handleEffectedPolicyOnChange}
|
||||
data-test-subj={getTestId('effectedPolicies')}
|
||||
disabled={disabled}
|
||||
|
|
|
@ -16,14 +16,11 @@ import { createAppRootMockRenderer } from '../../../../../../common/mock/endpoin
|
|||
import { HostIsolationExceptionsList } from '../../host_isolation_exceptions_list';
|
||||
import { act, waitFor } from '@testing-library/react';
|
||||
import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../../../common/constants';
|
||||
import {
|
||||
exceptionsListAllHttpMocks,
|
||||
fleetGetEndpointPackagePolicyListHttpMock,
|
||||
} from '../../../../../mocks';
|
||||
import { isEffectedPolicySelected } from '../../../../../components/effected_policy_select/test_utils';
|
||||
import { BY_POLICY_ARTIFACT_TAG_PREFIX } from '../../../../../../../common/endpoint/service/artifacts';
|
||||
import { allFleetHttpMocks, exceptionsListAllHttpMocks } from '../../../../../mocks';
|
||||
import type { HttpFetchOptionsWithPath, IHttpFetchError } from '@kbn/core/public';
|
||||
import { testIdPrefix } from '../form';
|
||||
import { buildPerPolicyTag } from '../../../../../../../common/endpoint/service/artifacts/utils';
|
||||
import { policySelectorMocks } from '../../../../../components/policy_selector/mocks';
|
||||
|
||||
jest.mock('../../../../../../common/components/user_privileges');
|
||||
|
||||
|
@ -33,7 +30,7 @@ describe('When on the host isolation exceptions entry form', () => {
|
|||
let history: AppContextTestRender['history'];
|
||||
let mockedContext: AppContextTestRender;
|
||||
let exceptionsApiMock: ReturnType<typeof exceptionsListAllHttpMocks>;
|
||||
let fleetApiMock: ReturnType<typeof fleetGetEndpointPackagePolicyListHttpMock>;
|
||||
let fleetApiMock: ReturnType<typeof allFleetHttpMocks>;
|
||||
|
||||
const formRowHasError = (testId: string): boolean => {
|
||||
const formRow = renderResult.getByTestId(testId);
|
||||
|
@ -67,13 +64,13 @@ describe('When on the host isolation exceptions entry form', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(renderResult.getByTestId('hostIsolationExceptions-form')).toBeTruthy();
|
||||
expect(fleetApiMock.responseProvider.endpointPackagePolicyList).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
return renderResult;
|
||||
};
|
||||
|
||||
exceptionsApiMock = exceptionsListAllHttpMocks(mockedContext.coreStart.http);
|
||||
fleetApiMock = fleetGetEndpointPackagePolicyListHttpMock(mockedContext.coreStart.http);
|
||||
fleetApiMock = allFleetHttpMocks(mockedContext.coreStart.http);
|
||||
|
||||
act(() => {
|
||||
history.push(HOST_ISOLATION_EXCEPTIONS_PATH);
|
||||
|
@ -156,31 +153,32 @@ describe('When on the host isolation exceptions entry form', () => {
|
|||
const generateExceptionsFindResponse =
|
||||
exceptionsApiMock.responseProvider.exceptionsFind.getMockImplementation()!;
|
||||
|
||||
exceptionsApiMock.responseProvider.exceptionsFind.mockImplementation((options) => {
|
||||
const response: FoundExceptionListItemSchema = generateExceptionsFindResponse(options);
|
||||
|
||||
Object.assign(response.data[0], {
|
||||
name: 'name edit me',
|
||||
description: 'initial description',
|
||||
item_id: '123-321',
|
||||
tags: ['policy:all'],
|
||||
entries: [
|
||||
{
|
||||
field: 'destination.ip',
|
||||
operator: 'included',
|
||||
type: 'match',
|
||||
value: '10.0.0.1',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
existingException = exceptionsApiMock.responseProvider.exceptionsFind({
|
||||
query: {},
|
||||
} as HttpFetchOptionsWithPath).data[0];
|
||||
|
||||
Object.assign(existingException, {
|
||||
name: 'name edit me',
|
||||
description: 'initial description',
|
||||
item_id: '123-321',
|
||||
tags: ['policy:all'],
|
||||
entries: [
|
||||
{
|
||||
field: 'destination.ip',
|
||||
operator: 'included',
|
||||
type: 'match',
|
||||
value: '10.0.0.1',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
exceptionsApiMock.responseProvider.exceptionsFind.mockImplementation((options) => {
|
||||
const response: FoundExceptionListItemSchema = generateExceptionsFindResponse(options);
|
||||
response.data[0] = existingException;
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
exceptionsApiMock.responseProvider.exceptionGetOne.mockImplementation(() => {
|
||||
return existingException;
|
||||
});
|
||||
|
@ -226,19 +224,26 @@ describe('When on the host isolation exceptions entry form', () => {
|
|||
});
|
||||
|
||||
it('should show pre-selected policies', async () => {
|
||||
const policyApiResponse = fleetApiMock.responseProvider.endpointPackagePolicyList();
|
||||
const policyApiResponse = fleetApiMock.responseProvider.packagePolicies();
|
||||
const policyId1 = policyApiResponse.items[0].id;
|
||||
const policyId2 = policyApiResponse.items[3].id;
|
||||
const policyId2 = policyApiResponse.items[1].id;
|
||||
|
||||
existingException.tags = [
|
||||
`${BY_POLICY_ARTIFACT_TAG_PREFIX}${policyId1}`,
|
||||
`${BY_POLICY_ARTIFACT_TAG_PREFIX}${policyId2}`,
|
||||
];
|
||||
existingException.tags = [buildPerPolicyTag(policyId1), buildPerPolicyTag(policyId2)];
|
||||
|
||||
await render();
|
||||
const policySelector = policySelectorMocks.getTestHelpers(
|
||||
`${testIdPrefix}-effectedPolicies-policiesSelector`,
|
||||
renderResult
|
||||
);
|
||||
|
||||
await expect(isEffectedPolicySelected(renderResult, testIdPrefix, 0)).resolves.toBe(true);
|
||||
await expect(isEffectedPolicySelected(renderResult, testIdPrefix, 3)).resolves.toBe(true);
|
||||
await waitFor(() => {
|
||||
expect(renderResult.getByTestId('hostIsolationExceptionsListPage-flyout'));
|
||||
});
|
||||
|
||||
await policySelector.waitForDataToLoad();
|
||||
|
||||
expect(policySelector.isPolicySelected(policyId1)).toEqual(true);
|
||||
expect(policySelector.isPolicySelected(policyId2)).toEqual(true);
|
||||
});
|
||||
|
||||
it('should show the policies selector when no policy is selected', async () => {
|
||||
|
@ -246,16 +251,7 @@ describe('When on the host isolation exceptions entry form', () => {
|
|||
await render();
|
||||
|
||||
expect(
|
||||
renderResult.queryByTestId(`${testIdPrefix}-effectedPolicies-policiesSelectable`)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show the policies selector when no policy is selected and there are previous tags', async () => {
|
||||
existingException.tags = ['non-a-policy-tag'];
|
||||
await render();
|
||||
|
||||
expect(
|
||||
renderResult.queryByTestId(`${testIdPrefix}-effectedPolicies-policiesSelectable`)
|
||||
renderResult.queryByTestId(`${testIdPrefix}-effectedPolicies-policiesSelector`)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
|
|
|
@ -6,32 +6,13 @@
|
|||
*/
|
||||
|
||||
import { set } from '@kbn/safer-lodash-set';
|
||||
import { createTestSubjGenerator } from '../../../../mocks/utils';
|
||||
import type { PolicyConfig } from '../../../../../../common/endpoint/types';
|
||||
import {
|
||||
AntivirusRegistrationModes,
|
||||
ProtectionModes,
|
||||
} from '../../../../../../common/endpoint/types';
|
||||
|
||||
interface TestSubjGenerator {
|
||||
(suffix?: string): string;
|
||||
withPrefix: (prefix: string) => TestSubjGenerator;
|
||||
}
|
||||
|
||||
export const createTestSubjGenerator = (testSubjPrefix: string): TestSubjGenerator => {
|
||||
const testSubjGenerator: TestSubjGenerator = (suffix) => {
|
||||
if (suffix) {
|
||||
return `${testSubjPrefix}-${suffix}`;
|
||||
}
|
||||
return testSubjPrefix;
|
||||
};
|
||||
|
||||
testSubjGenerator.withPrefix = (prefix: string): TestSubjGenerator => {
|
||||
return createTestSubjGenerator(testSubjGenerator(prefix));
|
||||
};
|
||||
|
||||
return testSubjGenerator;
|
||||
};
|
||||
|
||||
export const getPolicySettingsFormTestSubjects = (
|
||||
formTopLevelTestSubj: string = 'endpointPolicyForm'
|
||||
) => {
|
||||
|
|
|
@ -23,9 +23,7 @@ import { createAppRootMockRenderer } from '../../../../../common/mock/endpoint';
|
|||
import { INPUT_ERRORS } from '../translations';
|
||||
import { licenseService } from '../../../../../common/hooks/use_license';
|
||||
import { forceHTMLElementOffsetWidth } from '../../../../components/effected_policy_select/test_utils';
|
||||
import type { PolicyData, TrustedAppConditionEntry } from '../../../../../../common/endpoint/types';
|
||||
|
||||
import { EndpointDocGenerator } from '../../../../../../common/endpoint/generate_data';
|
||||
import type { TrustedAppConditionEntry } from '../../../../../../common/endpoint/types';
|
||||
import type { IHttpFetchError } from '@kbn/core-http-browser';
|
||||
|
||||
jest.mock('../../../../../common/hooks/use_license', () => {
|
||||
|
@ -42,7 +40,6 @@ jest.mock('../../../../../common/hooks/use_license', () => {
|
|||
|
||||
describe('Trusted apps form', () => {
|
||||
const formPrefix = 'trustedApps-form';
|
||||
const generator = new EndpointDocGenerator('effected-policy-select');
|
||||
let resetHTMLElementOffsetWidth: ReturnType<typeof forceHTMLElementOffsetWidth>;
|
||||
|
||||
let formProps: jest.Mocked<ArtifactFormComponentProps>;
|
||||
|
@ -104,19 +101,6 @@ describe('Trusted apps form', () => {
|
|||
};
|
||||
}
|
||||
|
||||
function createPolicies(): PolicyData[] {
|
||||
const policies = [
|
||||
generator.generatePolicyPackagePolicy(),
|
||||
generator.generatePolicyPackagePolicy(),
|
||||
];
|
||||
policies.map((p, i) => {
|
||||
p.id = `id-${i}`;
|
||||
p.name = `some-policy-${Math.random().toString(36).split('.').pop()}`;
|
||||
return p;
|
||||
});
|
||||
return policies;
|
||||
}
|
||||
|
||||
// Some helpers
|
||||
const setTextFieldValue = (textField: HTMLInputElement | HTMLTextAreaElement, value: string) => {
|
||||
act(() => {
|
||||
|
@ -182,11 +166,9 @@ describe('Trusted apps form', () => {
|
|||
mode: 'create',
|
||||
disabled: false,
|
||||
error: undefined,
|
||||
policiesIsLoading: false,
|
||||
onChange: jest.fn((updates) => {
|
||||
latestUpdatedItem = updates.item;
|
||||
}),
|
||||
policies: [],
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -379,106 +361,17 @@ describe('Trusted apps form', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('the Policy Selection area', () => {
|
||||
beforeEach(() => {
|
||||
formProps.policies = createPolicies();
|
||||
});
|
||||
it('should display effective scope options', () => {
|
||||
render();
|
||||
const globalButton = renderResult.getByTestId(
|
||||
`${formPrefix}-effectedPolicies-global`
|
||||
) as HTMLButtonElement;
|
||||
|
||||
it('should have `global` switch on if effective scope is global and policy options hidden', () => {
|
||||
render();
|
||||
const globalButton = renderResult.getByTestId(
|
||||
`${formPrefix}-effectedPolicies-global`
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(globalButton.classList.contains('euiButtonGroupButton-isSelected')).toEqual(true);
|
||||
expect(
|
||||
renderResult.queryByTestId(`${formPrefix}-effectedPolicies-policiesSelectable`)
|
||||
).toBeNull();
|
||||
expect(renderResult.queryByTestId('policy-id-0')).toBeNull();
|
||||
});
|
||||
|
||||
it('should have policy options visible and specific policies checked if scope is per-policy', () => {
|
||||
formProps.item.tags = [formProps.policies.map((p) => `policy:${p.id}`)[0]];
|
||||
render();
|
||||
|
||||
const perPolicyButton = renderResult.getByTestId(
|
||||
`${formPrefix}-effectedPolicies-perPolicy`
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(perPolicyButton.classList.contains('euiButtonGroupButton-isSelected')).toEqual(true);
|
||||
expect(renderResult.getByTestId('policy-id-0').getAttribute('aria-disabled')).toEqual(
|
||||
'false'
|
||||
);
|
||||
expect(renderResult.getByTestId('policy-id-0-checkbox')).toBeChecked();
|
||||
});
|
||||
|
||||
it('should show loader when setting `policies.isLoading` to true and scope is per-policy', () => {
|
||||
formProps.policiesIsLoading = true;
|
||||
formProps.item.tags = [formProps.policies.map((p) => `policy:${p.id}`)[0]];
|
||||
render();
|
||||
expect(renderResult.queryByTestId('loading-spinner')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should preserve other tags when policies are updated', async () => {
|
||||
formProps.item.tags = ['some:unknown_tag'];
|
||||
const policyId = formProps.policies[0].id;
|
||||
render();
|
||||
await userEvent.click(renderResult.getByTestId(`${formPrefix}-effectedPolicies-perPolicy`));
|
||||
await userEvent.click(renderResult.getByTestId(`policy-${policyId}`));
|
||||
|
||||
expect(formProps.onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({
|
||||
tags: ['some:unknown_tag', `policy:${policyId}`],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Policy Selection area when the license downgrades to gold or below', () => {
|
||||
beforeEach(() => {
|
||||
const policies = createPolicies();
|
||||
formProps.policies = policies;
|
||||
formProps.item.tags = [policies.map((p) => `policy:${p.id}`)[0]];
|
||||
formProps.mode = 'edit';
|
||||
// downgrade license
|
||||
(licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false);
|
||||
render();
|
||||
});
|
||||
|
||||
it('maintains policy configuration but does not allow the user to edit add/remove individual policies in edit mode', () => {
|
||||
const perPolicyButton = renderResult.getByTestId(
|
||||
`${formPrefix}-effectedPolicies-perPolicy`
|
||||
) as HTMLButtonElement;
|
||||
expect(perPolicyButton.classList.contains('euiButtonGroupButton-isSelected')).toEqual(true);
|
||||
expect(renderResult.getByTestId('policy-id-0').getAttribute('aria-disabled')).toEqual('true');
|
||||
expect(renderResult.getByTestId('policy-id-0-checkbox')).toBeChecked();
|
||||
});
|
||||
it("allows the user to set the trusted app entry to 'Global' in the edit option", () => {
|
||||
const globalButtonInput = renderResult.getByTestId(
|
||||
'trustedApps-form-effectedPolicies-global'
|
||||
) as HTMLButtonElement;
|
||||
act(() => {
|
||||
fireEvent.click(globalButtonInput);
|
||||
});
|
||||
|
||||
const policyItem = formProps.onChange.mock.calls[0][0].item.tags
|
||||
? formProps.onChange.mock.calls[0][0].item.tags[0]
|
||||
: '';
|
||||
expect(policyItem).toBe('policy:all');
|
||||
});
|
||||
|
||||
it('hides the policy assignment section if the TA is set to global', () => {
|
||||
formProps.item.tags = ['policy:all'];
|
||||
rerender();
|
||||
expect(renderResult.queryByTestId(`${formPrefix}-effectedPolicies`)).toBeNull();
|
||||
});
|
||||
it('hides the policy assignment section if the user is adding a new TA', () => {
|
||||
formProps.mode = 'create';
|
||||
rerender();
|
||||
expect(renderResult.queryByTestId(`${formPrefix}-effectedPolicies`)).toBeNull();
|
||||
});
|
||||
expect(globalButton.classList.contains('euiButtonGroupButton-isSelected')).toEqual(true);
|
||||
expect(
|
||||
renderResult.queryByTestId(`${formPrefix}-effectedPolicies-policiesSelectable`)
|
||||
).toBeNull();
|
||||
expect(renderResult.queryByTestId('policy-id-0')).toBeNull();
|
||||
});
|
||||
|
||||
describe('and the user visits required fields but does not fill them out', () => {
|
||||
|
|
|
@ -230,7 +230,7 @@ const defaultConditionEntry = (): TrustedAppConditionEntry<ConditionEntryField.H
|
|||
});
|
||||
|
||||
export const TrustedAppsForm = memo<ArtifactFormComponentProps>(
|
||||
({ item, policies, policiesIsLoading, onChange, mode, error: submitError }) => {
|
||||
({ item, onChange, mode, error: submitError }) => {
|
||||
const getTestId = useTestIdGenerator('trustedApps-form');
|
||||
const [visited, setVisited] = useState<
|
||||
Partial<{
|
||||
|
@ -543,10 +543,8 @@ export const TrustedAppsForm = memo<ArtifactFormComponentProps>(
|
|||
<EuiFormRow fullWidth data-test-subj={getTestId('policySelection')}>
|
||||
<EffectedPolicySelect
|
||||
item={item}
|
||||
options={policies}
|
||||
description={POLICY_SELECT_DESCRIPTION}
|
||||
data-test-subj={getTestId('effectedPolicies')}
|
||||
isLoading={policiesIsLoading}
|
||||
onChange={handleEffectedPolicyOnChange}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
|
|
@ -19,7 +19,9 @@ import type { GetPolicyListResponse } from '../../pages/policy/types';
|
|||
import { sendGetEndpointSpecificPackagePolicies } from './policies';
|
||||
import type { ServerApiError } from '../../../common/types';
|
||||
|
||||
// FIXME:PT move to `hooks` folder
|
||||
/**
|
||||
* @deprecated use `useFetchIntegrationPolicyList()` hook instead
|
||||
*/
|
||||
export function useGetEndpointSpecificPolicies(
|
||||
{
|
||||
onError,
|
||||
|
|
|
@ -189,7 +189,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
|||
|
||||
if (options?.policyId) {
|
||||
await testSubjects.click(`${actions.pageObject}-form-effectedPolicies-perPolicy`);
|
||||
await testSubjects.click(`policy-${options.policyId}-checkbox`);
|
||||
await testSubjects.click(
|
||||
`${actions.pageObject}-form-effectedPolicies-policiesSelector-policy-${options.policyId}-checkbox`
|
||||
);
|
||||
}
|
||||
|
||||
// Submit create artifact form
|
||||
|
@ -208,7 +210,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
|||
|
||||
if (options?.policyId) {
|
||||
await testSubjects.click(`${actions.pageObject}-form-effectedPolicies-perPolicy`);
|
||||
await testSubjects.click(`policy-${options.policyId}-checkbox`);
|
||||
await testSubjects.click(
|
||||
`${actions.pageObject}-form-effectedPolicies-policiesSelector-policy-${options.policyId}-checkbox`
|
||||
);
|
||||
}
|
||||
|
||||
// Submit edit artifact form
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue