Skip to content

Commit 810bda0

Browse files
authored
[Automatic Import] Fix bulk delete and done button (elastic#262686)
Disables bulk delete if any unapproved integrations are selected in the checklist. Disables done button if no edits were made to the integration details form when in edit mode
1 parent afc6407 commit 810bda0

7 files changed

Lines changed: 160 additions & 24 deletions

File tree

x-pack/platform/plugins/shared/automatic_import/public/components/integration_management/forms/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,5 @@ export const DEFAULT_INTEGRATION_VALUES = {
2323
logo: undefined,
2424
connectorId: '',
2525
};
26+
27+
export const INTEGRATION_DETAILS_UNTRACKED_FIELDS = ['connectorId', 'integrationId'];

x-pack/platform/plugins/shared/automatic_import/public/components/integration_management/forms/integration_form.test.tsx

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ jest.mock('../../../common/lib/api', () => ({
2222
items: mockExistingPackageNames.map((id) => ({ id })),
2323
})
2424
),
25+
getAllIntegrations: jest.fn(() => Promise.resolve([])),
2526
}));
2627

2728
const mockServices = coreMock.createStart();
@@ -30,7 +31,7 @@ const mockServices = coreMock.createStart();
3031
const FormTestConsumer: React.FC<{ onSubmitResult?: (data: IntegrationFormData) => void }> = ({
3132
onSubmitResult,
3233
}) => {
33-
const { isValid, submit } = useIntegrationForm();
34+
const { isValid, isFormModified, submit } = useIntegrationForm();
3435

3536
const handleSubmit = async () => {
3637
const result = await submit();
@@ -69,6 +70,15 @@ const FormTestConsumer: React.FC<{ onSubmitResult?: (data: IntegrationFormData)
6970
/>
7071
)}
7172
</UseField>
73+
<UseField<string | undefined> path="logo">
74+
{(field) => (
75+
<input
76+
data-test-subj="logoInput"
77+
value={(field.value as string) || ''}
78+
onChange={(e) => field.setValue(e.target.value || undefined)}
79+
/>
80+
)}
81+
</UseField>
7282
<UseField path="dataStreamTitle">
7383
{(field) => (
7484
<input
@@ -115,6 +125,7 @@ const FormTestConsumer: React.FC<{ onSubmitResult?: (data: IntegrationFormData)
115125
)}
116126
</UseField>
117127
<span data-test-subj="isValid">{String(isValid)}</span>
128+
<span data-test-subj="isFormModified">{String(isFormModified)}</span>
118129
<button type="button" data-test-subj="submitButton" onClick={handleSubmit}>
119130
{'Submit'}
120131
</button>
@@ -225,6 +236,97 @@ describe('IntegrationFormProvider', () => {
225236
});
226237
});
227238

239+
describe('useIntegrationForm hook - isFormModified state', () => {
240+
it('should return isFormModified=false when no fields have changed from initial values', async () => {
241+
const { getByTestId } = renderForm({
242+
initialValue: { title: 'My Integration', description: 'My Description' },
243+
});
244+
245+
await advancePastDebounce();
246+
247+
expect(getByTestId('isFormModified').textContent).toBe('false');
248+
});
249+
250+
it('should return isFormModified=true when title is changed', async () => {
251+
const { getByTestId } = renderForm({
252+
initialValue: { title: 'My Integration', description: 'My Description' },
253+
});
254+
255+
await act(async () => {
256+
fireEvent.change(getByTestId('titleInput'), { target: { value: 'Updated Title' } });
257+
});
258+
await advancePastDebounce();
259+
260+
expect(getByTestId('isFormModified').textContent).toBe('true');
261+
});
262+
263+
it('should return isFormModified=true when description is changed', async () => {
264+
const { getByTestId } = renderForm({
265+
initialValue: { title: 'My Integration', description: 'My Description' },
266+
});
267+
268+
await act(async () => {
269+
fireEvent.change(getByTestId('descriptionInput'), {
270+
target: { value: 'Updated Description' },
271+
});
272+
});
273+
await advancePastDebounce();
274+
275+
expect(getByTestId('isFormModified').textContent).toBe('true');
276+
});
277+
278+
it('should return isFormModified=true when logo is changed', async () => {
279+
const { getByTestId } = renderForm({
280+
initialValue: { title: 'My Integration', description: 'My Description' },
281+
});
282+
283+
await act(async () => {
284+
fireEvent.change(getByTestId('logoInput'), { target: { value: 'base64logodata' } });
285+
});
286+
await advancePastDebounce();
287+
288+
expect(getByTestId('isFormModified').textContent).toBe('true');
289+
});
290+
291+
it('should return isFormModified=false when title is changed back to initial value', async () => {
292+
const { getByTestId } = renderForm({
293+
initialValue: { title: 'My Integration', description: 'My Description' },
294+
});
295+
296+
await act(async () => {
297+
fireEvent.change(getByTestId('titleInput'), { target: { value: 'Updated Title' } });
298+
});
299+
await advancePastDebounce();
300+
expect(getByTestId('isFormModified').textContent).toBe('true');
301+
302+
await act(async () => {
303+
fireEvent.change(getByTestId('titleInput'), { target: { value: 'My Integration' } });
304+
});
305+
await advancePastDebounce();
306+
307+
expect(getByTestId('isFormModified').textContent).toBe('false');
308+
});
309+
310+
it('should return isFormModified=false when only connectorId is changed', async () => {
311+
const { getByTestId } = renderForm({
312+
initialValue: {
313+
title: 'My Integration',
314+
description: 'My Description',
315+
connectorId: 'connector-original',
316+
},
317+
});
318+
319+
await act(async () => {
320+
fireEvent.change(getByTestId('connectorIdInput'), {
321+
target: { value: 'connector-updated' },
322+
});
323+
});
324+
await advancePastDebounce();
325+
326+
expect(getByTestId('isFormModified').textContent).toBe('false');
327+
});
328+
});
329+
228330
describe('form submission', () => {
229331
it('should call onSubmit with form data when all required fields are filled', async () => {
230332
const onSubmit = jest.fn().mockResolvedValue(undefined);

x-pack/platform/plugins/shared/automatic_import/public/components/integration_management/forms/integration_form.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
useForm,
1010
useFormContext,
1111
useFormData,
12+
useFormIsModified,
1213
Form,
1314
type FormConfig,
1415
type FormHook,
@@ -17,7 +18,11 @@ import { createFormSchema, REQUIRED_FIELDS } from './integration_form_validation
1718
import type { IntegrationFormData } from './types';
1819
import { useKibana, getInstalledPackages, getAllIntegrations } from '../../../common';
1920
import * as i18n from './translations';
20-
import { DEFAULT_DATA_STREAM_VALUES, DEFAULT_INTEGRATION_VALUES } from './constants';
21+
import {
22+
DEFAULT_DATA_STREAM_VALUES,
23+
DEFAULT_INTEGRATION_VALUES,
24+
INTEGRATION_DETAILS_UNTRACKED_FIELDS,
25+
} from './constants';
2126
import { normalizeTitleName } from '../../../common/lib/helper_functions';
2227

2328
export interface IntegrationFormProviderProps {
@@ -132,6 +137,7 @@ export const IntegrationFormProvider: React.FC<IntegrationFormProviderProps> = (
132137
export const useIntegrationForm = () => {
133138
const form = useFormContext<IntegrationFormData>();
134139
const [formData] = useFormData<IntegrationFormData>();
140+
const isFormModified = useFormIsModified({ discard: INTEGRATION_DETAILS_UNTRACKED_FIELDS });
135141

136142
// Check if all required fields for the current context are filled
137143
const isValid = useMemo(() => {
@@ -161,6 +167,7 @@ export const useIntegrationForm = () => {
161167
form: form as FormHook<IntegrationFormData>,
162168
formData,
163169
isValid,
170+
isFormModified,
164171
submit: () => form.submit(),
165172
reset: () => form.reset(),
166173
validate: () => form.validate(),

x-pack/platform/plugins/shared/automatic_import/public/components/integration_management/integration_management.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ jest.mock('../../common/components/connector_selector', () => ({
9797

9898
jest.mock('./forms/integration_form', () => ({
9999
IntegrationFormProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
100-
useIntegrationForm: () => ({ formData: {}, form: {}, submit: mockSubmit }),
100+
useIntegrationForm: () => ({ formData: {}, form: {}, submit: mockSubmit, isFormModified: true }),
101101
}));
102102

103103
jest.mock('../../common/components/button_footer', () => ({

x-pack/platform/plugins/shared/automatic_import/public/components/integration_management/integration_management.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* 2.0.
66
*/
77
import React, { useCallback, useMemo, useState } from 'react';
8-
import { useParams } from 'react-router-dom';
8+
import { useParams, useLocation } from 'react-router-dom';
99
import useObservable from 'react-use/lib/useObservable';
1010
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
1111
import {
@@ -44,9 +44,11 @@ const IntegrationManagementContents: React.FC<IntegrationManagementContentsProps
4444
navigateToManage,
4545
}) => {
4646
const { integrationId } = useParams<{ integrationId?: string }>();
47+
const { state: locationState } = useLocation<{ isNew?: boolean }>();
48+
const isNewlyCreated = locationState?.isNew === true;
4749
const { integration } = useGetIntegrationById(integrationId);
4850
const { deleteIntegrationMutation } = useDeleteIntegration();
49-
const { submit } = useIntegrationForm();
51+
const { submit, isFormModified } = useIntegrationForm();
5052
const hasDataStreams = (integration?.dataStreams?.length ?? 0) > 0;
5153
const isDeletingDataStream =
5254
integration?.dataStreams?.some((ds) => ds.status === 'deleting') ?? false;
@@ -107,7 +109,11 @@ const IntegrationManagementContents: React.FC<IntegrationManagementContentsProps
107109
</KibanaPageTemplate>
108110
<ButtonsFooter
109111
onAction={handleDone}
110-
isActionDisabled={!hasDataStreams || isDeletingDataStream}
112+
isActionDisabled={
113+
!hasDataStreams ||
114+
isDeletingDataStream ||
115+
(Boolean(integrationId) && !isNewlyCreated && !isFormModified)
116+
}
111117
isCancelDisabled={isDeletingDataStream}
112118
onCancel={handleCancel}
113119
/>

x-pack/platform/plugins/shared/automatic_import/public/components/integration_management/management_contents/data_streams/create_data_stream_flyout.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,10 @@ export const CreateDataStreamFlyout: React.FC<CreateDataStreamFlyoutProps> = ({
501501
onClose();
502502

503503
if (!currentIntegrationId && result.integration_id) {
504-
application.navigateToApp(PLUGIN_ID, { path: `/edit/${result.integration_id}` });
504+
application.navigateToApp(PLUGIN_ID, {
505+
path: `/edit/${result.integration_id}`,
506+
state: { isNew: true },
507+
});
505508
}
506509
} catch (error) {
507510
notifications.toasts.addError(error instanceof Error ? error : new Error('Unknown error'), {

x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/browse_integrations/components/manage_integrations_table.tsx

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
EuiSelectable,
2323
EuiInMemoryTable,
2424
EuiText,
25+
EuiToolTip,
2526
useEuiTheme,
2627
EuiCallOut,
2728
} from '@elastic/eui';
@@ -422,7 +423,7 @@ export const ManageIntegrationsTable: React.FC<{
422423
}
423424
}, [selectedItems, installToCluster, installedPackageVersions]);
424425

425-
const hasApprovedSelected = selectedItems.some((item) => item.status === 'approved');
426+
const canBulkInstall = selectedItems.every((item) => item.status === 'approved');
426427

427428
const columns = useMemo<Array<EuiBasicTableColumn<CreatedIntegrationRow>>>(
428429
() => [
@@ -816,22 +817,37 @@ export const ManageIntegrationsTable: React.FC<{
816817
/>
817818
</EuiButton>
818819
</EuiFlexItem>
819-
{hasApprovedSelected && (
820-
<EuiFlexItem grow={false}>
821-
<EuiButton
822-
size="s"
823-
iconType="exportAction"
824-
isLoading={isBulkInstalling}
825-
onClick={handleBulkInstall}
826-
data-test-subj="manageIntegrationsBulkInstallBtn"
827-
>
828-
<FormattedMessage
829-
id="xpack.fleet.epmList.manageIntegrations.bulkInstall"
830-
defaultMessage="Install"
831-
/>
832-
</EuiButton>
833-
</EuiFlexItem>
834-
)}
820+
<EuiFlexItem grow={false}>
821+
<EuiToolTip
822+
content={
823+
!canBulkInstall
824+
? i18n.translate(
825+
'xpack.fleet.epmList.manageIntegrations.bulkInstallDisabledTooltip',
826+
{
827+
defaultMessage:
828+
'Not all selected integrations are approved. Deselect unapproved integrations to install.',
829+
}
830+
)
831+
: undefined
832+
}
833+
>
834+
<span>
835+
<EuiButton
836+
size="s"
837+
iconType="exportAction"
838+
isLoading={isBulkInstalling}
839+
isDisabled={!canBulkInstall}
840+
onClick={handleBulkInstall}
841+
data-test-subj="manageIntegrationsBulkInstallBtn"
842+
>
843+
<FormattedMessage
844+
id="xpack.fleet.epmList.manageIntegrations.bulkInstall"
845+
defaultMessage="Install"
846+
/>
847+
</EuiButton>
848+
</span>
849+
</EuiToolTip>
850+
</EuiFlexItem>
835851
</EuiFlexGroup>
836852
) : (
837853
<EuiText size="s">

0 commit comments

Comments
 (0)