Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ import { useMemoizedRouteState } from '../../common/hooks';
import { BackToExternalAppSecondaryButton } from '../back_to_external_app_secondary_button';
import { BackToExternalAppButton } from '../back_to_external_app_button';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { ArtifactImportFlyout } from './components/artifact_import_flyout';
import { useIsImportFlyoutOpened } from './hooks/use_is_import_flyout_opened';

type ArtifactEntryCardType = typeof ArtifactEntryCard;

Expand Down Expand Up @@ -96,21 +98,26 @@ export const ArtifactListPage = memo<ArtifactListPageProps>(
allowCardDeleteAction = true,
CardDecorator,
}) => {
const areEndpointExceptionsMovedUnderManagementFFEnabled = useIsExperimentalFeatureEnabled(
'endpointExceptionsMovedUnderManagement'
);
const { services } = useKibana();
const { http } = services;
const { state: routeState } = useLocation<ListPageRouteState | undefined>();
const getTestId = useTestIdGenerator(dataTestSubj);
const toasts = useToasts();
const isMounted = useIsMounted();

const isFlyoutOpened = useIsFlyoutOpened(allowCardEditAction, allowCardCreateAction);
const isImportFlyoutOpened =
useIsImportFlyoutOpened(allowCardCreateAction) &&
areEndpointExceptionsMovedUnderManagementFFEnabled;

const setUrlParams = useSetUrlParams();
const {
urlParams: { filter, includedPolicies },
} = useUrlParams<ArtifactListPageUrlParams>();
const { exportExceptionList } = useApi(http);
const areEndpointExceptionsMovedUnderManagementFFEnabled = useIsExperimentalFeatureEnabled(
'endpointExceptionsMovedUnderManagement'
);

const {
isPageInitializing,
Expand Down Expand Up @@ -277,6 +284,15 @@ export const ArtifactListPage = memo<ArtifactListPageProps>(

const handleOnDownload = useCallback(() => setExportedData(undefined), []);

const handleImport = useCallback(() => setUrlParams({ show: 'import' }), [setUrlParams]);

const closeImportFlyout = useCallback(() => setUrlParams({ show: undefined }), [setUrlParams]);

const handleImportFlyoutOnSuccess = useCallback(() => {
closeImportFlyout();
refetchListData();
}, [closeImportFlyout, refetchListData]);

const description = useMemo(() => {
const subtitleText = labels.pageAboutInfo ? (
<span data-test-subj="header-panel-subtitle">{labels.pageAboutInfo}</span>
Expand Down Expand Up @@ -328,7 +344,7 @@ export const ArtifactListPage = memo<ArtifactListPageProps>(
key: 'ImportButton',
icon: 'importAction',
label: labels.pageImportButtonTitle,
onClick: () => {},
onClick: handleImport,
disabled: !allowCardCreateAction,
},
{
Expand Down Expand Up @@ -365,6 +381,15 @@ export const ArtifactListPage = memo<ArtifactListPageProps>(
/>
)}

{isImportFlyoutOpened && (
<ArtifactImportFlyout
onCancel={closeImportFlyout}
onSuccess={handleImportFlyoutOnSuccess}
apiClient={apiClient}
labels={labels}
/>
)}

{selectedItemForDelete && (
<ArtifactDeleteModal
apiClient={apiClient}
Expand All @@ -379,10 +404,12 @@ export const ArtifactListPage = memo<ArtifactListPageProps>(
{!doesDataExist ? (
<NoDataEmptyState
onAdd={handleOpenCreateFlyoutClick}
onImport={handleImport}
titleNoEntriesLabel={labels.emptyStateTitleNoEntries}
titleLabel={labels.emptyStateTitle}
aboutInfo={labels.emptyStateInfo}
primaryButtonLabel={labels.emptyStatePrimaryButtonLabel}
importButtonLabel={labels.emptyStateImportButtonLabel}
backComponent={backButtonEmptyComponent}
data-test-subj={getTestId('emptyState')}
secondaryAboutInfo={secondaryPageInfo}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import type { ArtifactListPageProps } from '../artifact_list_page';
import { waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { HttpFetchOptionsWithPath } from '@kbn/core/public';
import {
createAppRootMockRenderer,
type AppContextTestRender,
} from '../../../../common/mock/endpoint';
import { trustedAppsAllHttpMocks } from '../../../mocks';
import { getDeferred } from '../../../mocks/utils';
import { TrustedAppsApiClient } from '../../../pages/trusted_apps/service';
import type { ArtifactImportFlyoutProps } from './artifact_import_flyout';
import { ArtifactImportFlyout } from './artifact_import_flyout';
import { artifactListPageLabels } from '../translations';
import { getArtifactImportFlyoutUiMocks } from '../mocks';

describe('When the flyout is opened in the ArtifactListPage component', () => {
let render: (
props?: Partial<ArtifactListPageProps>
) => Promise<ReturnType<AppContextTestRender['render']>>;
let renderResult: ReturnType<AppContextTestRender['render']>;
let coreStart: AppContextTestRender['coreStart'];
let mockedTrustedAppApi: ReturnType<typeof trustedAppsAllHttpMocks>;
let props: ArtifactImportFlyoutProps;
let ui: ReturnType<typeof getArtifactImportFlyoutUiMocks>;

beforeEach(() => {
const mockedContext = createAppRootMockRenderer();
({ coreStart } = mockedContext);

mockedTrustedAppApi = trustedAppsAllHttpMocks(coreStart.http);

props = {
labels: artifactListPageLabels,
apiClient: new TrustedAppsApiClient(coreStart.http),
onCancel: jest.fn(),
onSuccess: jest.fn(),
};

render = async () => {
renderResult = mockedContext.render(
<ArtifactImportFlyout {...props} data-test-subj="testFlyout" />
);
ui = getArtifactImportFlyoutUiMocks(renderResult, 'testFlyout');

await waitFor(async () => expect(renderResult.getByTestId('testFlyout')).toBeInTheDocument());

return renderResult;
};
});

it('should display `Cancel` button enabled', async () => {
await render();

expect(ui.getCancelButton()).toBeEnabled();
});

it('should call onCancel when `Cancel` button is clicked', async () => {
await render();

await userEvent.click(ui.getCancelButton());

expect(props.onCancel).toHaveBeenCalled();
});

it('should display `Import` button disabled', async () => {
await render();

expect(ui.getImportButton()).toBeDisabled();
});

it('should enable `Import` button when a file is selected', async () => {
await render();

await ui.uploadFile();

expect(ui.getImportButton()).toBeEnabled();
});

it('should call the import API when `Import` button is clicked', async () => {
await render();

await ui.uploadFile();
await userEvent.click(ui.getImportButton());

expect(mockedTrustedAppApi.responseProvider.trustedAppImportList).toHaveBeenCalledWith(
expect.objectContaining({
version: '2023-10-31',
query: { overwrite: false } as HttpFetchOptionsWithPath['query'],
})
);
});

it('should disable `Import` button while the import is in progress', async () => {
const deferrable = getDeferred();
mockedTrustedAppApi.responseProvider.trustedAppImportList.mockDelay.mockReturnValue(
deferrable.promise
);

await render();

await ui.uploadFile();
await userEvent.click(ui.getImportButton());

expect(ui.getImportButton()).toBeDisabled();
});

it('should show a success toast and call `onSuccess` after a successful import', async () => {
await render();

await ui.uploadFile();
await userEvent.click(ui.getImportButton());

expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith({
text: '2 items imported',
title: 'Artifact list imported successfully',
});
expect(props.onSuccess).toHaveBeenCalled();
});

it('should show an error toast if the import API fails', async () => {
mockedTrustedAppApi.responseProvider.trustedAppImportList.mockImplementation(() => {
throw new Error('Import failed');
});

await render();

await ui.uploadFile();
await userEvent.click(ui.getImportButton());

expect(coreStart.notifications.toasts.addError).toHaveBeenCalledWith(
expect.objectContaining(new Error('Import failed')),
{ title: 'Artifact list import failed' }
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* 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 { EuiFilePickerProps } from '@elastic/eui';
import {
EuiButton,
EuiButtonEmpty,
EuiFilePicker,
EuiFlexGroup,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiSpacer,
EuiText,
EuiTitle,
useGeneratedHtmlId,
} from '@elastic/eui';
import React, { useCallback } from 'react';
import { useToasts } from '../../../../common/lib/kibana';
import type { ArtifactListPageLabels } from '../translations';
import { useImportArtifactList } from '../../../hooks/artifacts/use_import_artifact_list';
import type { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client';
import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';

export interface ArtifactImportFlyoutProps {
onCancel: () => void;
onSuccess: () => void;
apiClient: ExceptionsListApiClient;
labels: ArtifactListPageLabels;
'data-test-subj'?: string;
}

export const ArtifactImportFlyout: React.FC<ArtifactImportFlyoutProps> = ({
onCancel,
onSuccess,
apiClient,
labels,
'data-test-subj': dataTestSubj = 'artifactImportFlyout',
}) => {
const toasts = useToasts();
const getTestId = useTestIdGenerator(dataTestSubj);

const [file, setFile] = React.useState<File | null>(null);

const { isLoading, mutate } = useImportArtifactList(apiClient);

const handleOnSubmit = useCallback(() => {
if (file !== null) {
mutate(
{ file },
{
onError: (error) => {
toasts.addError(error, { title: labels.pageImportErrorToastTitle });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a chance we can show more specific error from API?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toasts.addError() actually shows the message property of the passed error, so this should be okay. and also generic, as @ashokaditya noted in his review

},
onSuccess: (response) => {
toasts.addSuccess({
title: labels.pageImportSuccessToastTitle,
text: labels.getPageImportSuccessToastText?.(
response.success_count_exception_list_items
),
});
onSuccess();
},
}
);
}
}, [file, labels, mutate, onSuccess, toasts]);

const handleOnFileChange: EuiFilePickerProps['onChange'] = useCallback(
(files: FileList | null) => {
if (files && files.length > 0) {
setFile(files[0]);
} else {
setFile(null);
}
},
[]
);

const importEndpointArtifactListFlyoutTitleId = useGeneratedHtmlId({
prefix: 'importEndpointArtifactListFlyoutTitleId',
});

return (
<EuiFlyout
ownFocus
size="s"
onClose={onCancel}
aria-labelledby={importEndpointArtifactListFlyoutTitleId}
data-test-subj={getTestId()}
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2 id={importEndpointArtifactListFlyoutTitleId}>{labels.pageImportButtonTitle}</h2>
</EuiTitle>
</EuiFlyoutHeader>

<EuiFlyoutBody>
<EuiText color="subdued" size="s">
<p>{labels.importFlyoutDetails}</p>
</EuiText>

<EuiSpacer size="m" />

<EuiFilePicker
onChange={handleOnFileChange}
disabled={isLoading}
data-test-subj={getTestId('filePicker')}
/>
</EuiFlyoutBody>

<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiButtonEmpty onClick={onCancel} data-test-subj={getTestId('cancelButton')}>
{labels.flyoutCancelButtonLabel}
</EuiButtonEmpty>

<EuiButton
onClick={handleOnSubmit}
disabled={file === null}
isLoading={isLoading}
data-test-subj={getTestId('importButton')}
>
{labels.importFlyoutImportSubmitButtonLabel}
</EuiButton>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};
Loading
Loading