diff --git a/src/components/IntegrationTest/ContextsField.tsx b/src/components/IntegrationTest/ContextsField.tsx index 63e770602..29ab4119d 100644 --- a/src/components/IntegrationTest/ContextsField.tsx +++ b/src/components/IntegrationTest/ContextsField.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { useParams } from 'react-router-dom'; -import { FormGroup } from '@patternfly/react-core'; +import { Bullseye, FormGroup, Spinner } from '@patternfly/react-core'; import { FieldArray, useField, FieldArrayRenderProps } from 'formik'; import { getFieldId } from '../../../src/shared/components/formik-fields/field-utils'; import { useComponents } from '../../hooks/useComponents'; @@ -106,7 +106,9 @@ const ContextsField: React.FC = ({ heading, fieldNa )} /> ) : ( - 'Loading Additional Component Context options' + + + )} ); diff --git a/src/components/IntegrationTest/EditContextsModal.tsx b/src/components/IntegrationTest/EditContextsModal.tsx new file mode 100644 index 000000000..1c52a08e0 --- /dev/null +++ b/src/components/IntegrationTest/EditContextsModal.tsx @@ -0,0 +1,122 @@ +import * as React from 'react'; +import { k8sPatchResource } from '@openshift/dynamic-plugin-sdk-utils'; +import { + Alert, + AlertVariant, + Button, + ButtonType, + ButtonVariant, + ModalVariant, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { Formik, FormikValues } from 'formik'; +import { IntegrationTestScenarioModel } from '../../models'; +import { IntegrationTestScenarioKind, Context } from '../../types/coreBuildService'; +import { ComponentProps, createModalLauncher } from '../modal/createModalLauncher'; +import ContextsField from './ContextsField'; +import { formatContexts } from './IntegrationTestForm/utils/create-utils'; + +type EditContextsModalProps = ComponentProps & { + intTest: IntegrationTestScenarioKind; +}; + +export const EditContextsModal: React.FC> = ({ + intTest, + onClose, +}) => { + const [error, setError] = React.useState(); + + const getFormContextValues = (contexts: Context[] = []) => { + return contexts.map(({ name, description }) => ({ name, description })); + }; + + const updateIntegrationTest = async (values: FormikValues) => { + try { + await k8sPatchResource({ + model: IntegrationTestScenarioModel, + queryOptions: { + name: intTest.metadata.name, + ns: intTest.metadata.namespace, + }, + patches: [ + { op: 'replace', path: '/spec/contexts', value: formatContexts(values.contexts) }, + ], + }); + onClose(null, { submitClicked: true }); + } catch (e) { + setError(e.message || e.toString()); + } + }; + + const onReset = () => { + onClose(null, { submitClicked: false }); + }; + + const initialContexts = getFormContextValues(intTest?.spec?.contexts); + + // When a user presses enter, make sure the form doesn't submit. + // Enter should be used to select values from the drop down, + // when using the keyboard, not submit the form. + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); // Prevent form submission on Enter key + } + }; + + return ( + + {({ handleSubmit, handleReset, isSubmitting, values }) => { + const isChanged = values.contexts !== initialContexts; + const showConfirmation = isChanged && values.strategy === 'Automatic'; + const isValid = isChanged && (showConfirmation ? values.confirm : true); + + return ( +
+ + + + + + {error && ( + + {error} + + )} + + + + +
+ ); + }} +
+ ); +}; + +export const createEditContextsModal = createModalLauncher(EditContextsModal, { + 'data-testid': `edit-its-contexts`, + variant: ModalVariant.medium, + title: `Edit contexts`, +}); diff --git a/src/components/IntegrationTest/__tests__/EditContextsModal.spec.tsx b/src/components/IntegrationTest/__tests__/EditContextsModal.spec.tsx new file mode 100644 index 000000000..9c871d563 --- /dev/null +++ b/src/components/IntegrationTest/__tests__/EditContextsModal.spec.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { k8sPatchResource } from '@openshift/dynamic-plugin-sdk-utils'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { useComponents } from '../../../hooks/useComponents'; +import { formikRenderer } from '../../../utils/test-utils'; +import { EditContextsModal } from '../EditContextsModal'; +import { IntegrationTestFormValues } from '../IntegrationTestForm/types'; +import { MockIntegrationTests } from '../IntegrationTestsListView/__data__/mock-integration-tests'; +import { contextOptions } from '../utils'; + +// Mock external dependencies +jest.mock('@openshift/dynamic-plugin-sdk-utils', () => ({ + k8sPatchResource: jest.fn(), +})); +jest.mock('../../../hooks/useComponents', () => ({ + useComponents: jest.fn(), +})); +jest.mock('../../../utils/workspace-context-utils', () => ({ + useWorkspaceInfo: jest.fn(() => ({ namespace: 'test-ns', workspace: 'test-ws' })), +})); + +const useComponentsMock = useComponents as jest.Mock; +const patchResourceMock = k8sPatchResource as jest.Mock; +const onCloseMock = jest.fn(); + +const intTest = MockIntegrationTests[0]; +const initialValues: IntegrationTestFormValues = { + name: intTest.metadata.name, + url: 'test-url', + optional: true, + contexts: intTest.spec.contexts, +}; + +const setup = () => + formikRenderer(, initialValues); + +beforeEach(() => { + jest.clearAllMocks(); + useComponentsMock.mockReturnValue([[], true]); +}); + +describe('EditContextsModal', () => { + it('should render correct contexts', () => { + setup(); + const contextOptionNames = contextOptions.map((ctx) => ctx.name); + + screen.getByText('Contexts'); + contextOptionNames.forEach((ctxName) => screen.queryByText(ctxName)); + }); + + it('should show Save and Cancel buttons', () => { + setup(); + // Save + screen.getByTestId('update-contexts'); + // Cancel + screen.getByTestId('cancel-update-contexts'); + }); + + it('should call onClose callback when cancel button is clicked', () => { + setup(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCloseMock).toHaveBeenCalledWith(null, { submitClicked: false }); + }); + + it('prevents form submission when pressing Enter', async () => { + setup(); + const form = screen.getByTestId('edit-contexts-modal'); + fireEvent.keyDown(form, { key: 'Enter', code: 'Enter' }); + expect(k8sPatchResource).not.toHaveBeenCalled(); + }); + + it('calls updateIntegrationTest and onClose on form submission', async () => { + patchResourceMock.mockResolvedValue({}); + + setup(); + const clearButton = screen.getByTestId('clear-button'); + // Clear all selections + fireEvent.click(clearButton); + // Save button should now be active + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(patchResourceMock).toHaveBeenCalledTimes(1); + }); + + expect(patchResourceMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryOptions: { name: 'test-app-test-1', ns: 'test-namespace' }, + patches: [{ op: 'replace', path: '/spec/contexts', value: null }], + }), + ); + expect(onCloseMock).toHaveBeenCalledWith(null, { submitClicked: true }); + }); + + it('displays an error message if k8sPatchResource fails', async () => { + patchResourceMock.mockRejectedValue('Failed to update contexts'); + setup(); + + const clearButton = screen.getByTestId('clear-button'); + // Clear all selections + fireEvent.click(clearButton); + // Click Save button + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + // wait for the error message to appear + await waitFor(() => { + expect(patchResourceMock).toHaveBeenCalledTimes(1); + expect(screen.getByText('An error occurred')).toBeInTheDocument(); + expect(screen.queryByText('Failed to update contexts')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/IntegrationTest/tabs/IntegrationTestOverviewTab.tsx b/src/components/IntegrationTest/tabs/IntegrationTestOverviewTab.tsx index ea3cabe72..fc674dee7 100644 --- a/src/components/IntegrationTest/tabs/IntegrationTestOverviewTab.tsx +++ b/src/components/IntegrationTest/tabs/IntegrationTestOverviewTab.tsx @@ -20,6 +20,7 @@ import { IntegrationTestScenarioKind } from '../../../types/coreBuildService'; import { useWorkspaceInfo } from '../../../utils/workspace-context-utils'; import { useModalLauncher } from '../../modal/ModalProvider'; import MetadataList from '../../PipelineRunDetailsView/MetadataList'; +import { createEditContextsModal } from '../EditContextsModal'; import { createEditParamsModal } from '../EditParamsModal'; import { IntegrationTestLabels } from '../IntegrationTestForm/types'; import { @@ -42,6 +43,7 @@ const IntegrationTestOverviewTab: React.FC< const showModal = useModalLauncher(); const params = integrationTest?.spec?.params; + const contexts = integrationTest?.spec?.contexts; return ( <> @@ -138,6 +140,36 @@ const IntegrationTestOverviewTab: React.FC< })} )} + {contexts && ( + + + Contexts{' '} + + + + + + {pluralize(contexts.length, 'context')} +
+ {' '} + +
+
+
+ )} {params && (