From 97fe74761f4bd2c1691abf018331a2bfbf2b0934 Mon Sep 17 00:00:00 2001 From: Bryan Ramos Date: Thu, 21 Nov 2024 13:41:13 -0500 Subject: [PATCH] feat(KFLUXUI-217): add contexts to show page - Add the context values to the integration show page. - Add a modal to allow easy edits. - Use spinner for loading instead of text. --- .../IntegrationTests/ContextsField.tsx | 6 +- .../IntegrationTests/EditContextsModal.tsx | 127 ++++++++++++++++++ .../tabs/IntegrationTestOverviewTab.tsx | 32 +++++ .../IntegrationTestForm/utils/create-utils.ts | 6 +- .../__tests__/EditContextsModal.spec.tsx | 112 +++++++++++++++ 5 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 src/components/IntegrationTests/EditContextsModal.tsx create mode 100644 src/components/IntegrationTests/__tests__/EditContextsModal.spec.tsx diff --git a/src/components/IntegrationTests/ContextsField.tsx b/src/components/IntegrationTests/ContextsField.tsx index 43ff375..c1c0c9f 100644 --- a/src/components/IntegrationTests/ContextsField.tsx +++ b/src/components/IntegrationTests/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/IntegrationTests/EditContextsModal.tsx b/src/components/IntegrationTests/EditContextsModal.tsx new file mode 100644 index 0000000..c92e121 --- /dev/null +++ b/src/components/IntegrationTests/EditContextsModal.tsx @@ -0,0 +1,127 @@ +import * as React from 'react'; +import { + Alert, + AlertVariant, + Button, + ButtonType, + ButtonVariant, + ModalVariant, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { Formik, FormikValues } from 'formik'; +import { k8sPatchResource } from '../../k8s/k8s-fetch'; +import { IntegrationTestScenarioModel } from '../../models'; +import { IntegrationTestScenarioKind, Context } from '../../types/coreBuildService'; +import { ComponentProps, createModalLauncher } from '../modal/createModalLauncher'; +import ContextsField from './ContextsField'; +import { UnformattedContexts, 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 as UnformattedContexts), + }, + ], + }); + onClose(null, { submitClicked: true }); + } catch (e) { + const errMsg = e.message || e.toString(); + setError(errMsg as string); + } + }; + + 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-test': `edit-its-contexts`, + variant: ModalVariant.medium, + title: `Edit contexts`, +}); diff --git a/src/components/IntegrationTests/IntegrationTestDetails/tabs/IntegrationTestOverviewTab.tsx b/src/components/IntegrationTests/IntegrationTestDetails/tabs/IntegrationTestOverviewTab.tsx index 03fb812..8fd2a63 100644 --- a/src/components/IntegrationTests/IntegrationTestDetails/tabs/IntegrationTestOverviewTab.tsx +++ b/src/components/IntegrationTests/IntegrationTestDetails/tabs/IntegrationTestOverviewTab.tsx @@ -21,6 +21,7 @@ import ExternalLink from '../../../../shared/components/links/ExternalLink'; import MetadataList from '../../../MetadataList'; import { useModalLauncher } from '../../../modal/ModalProvider'; import { useWorkspaceInfo } from '../../../Workspace/useWorkspaceInfo'; +import { createEditContextsModal } from '../../EditContextsModal'; import { createEditParamsModal } from '../../EditParamsModal'; import { IntegrationTestLabels } from '../../IntegrationTestForm/types'; import { @@ -46,6 +47,7 @@ const IntegrationTestOverviewTab: React.FC = () => { const showModal = useModalLauncher(); const params = integrationTest?.spec?.params; + const contexts = integrationTest?.spec?.contexts; return ( <> @@ -142,6 +144,36 @@ const IntegrationTestOverviewTab: React.FC = () => { })} )} + {contexts && ( + + + Contexts{' '} + + + + + + {pluralize(contexts.length, 'context')} +
+ {' '} + +
+
+
+ )} {params && ( diff --git a/src/components/IntegrationTests/IntegrationTestForm/utils/create-utils.ts b/src/components/IntegrationTests/IntegrationTestForm/utils/create-utils.ts index df7e9d6..88bb6f2 100644 --- a/src/components/IntegrationTests/IntegrationTestForm/utils/create-utils.ts +++ b/src/components/IntegrationTests/IntegrationTestForm/utils/create-utils.ts @@ -46,7 +46,11 @@ export const formatParams = (params): Param[] => { return newParams.length > 0 ? newParams : null; }; -export const formatContexts = (contexts = [], setDefault = false): Context[] | null => { +export type UnformattedContexts = { name: string; description: string }[]; +export const formatContexts = ( + contexts: UnformattedContexts = [], + setDefault: boolean = false, +): Context[] | null => { const defaultContext = { name: 'application', description: 'execute the integration test in all cases - this would be the default state', diff --git a/src/components/IntegrationTests/__tests__/EditContextsModal.spec.tsx b/src/components/IntegrationTests/__tests__/EditContextsModal.spec.tsx new file mode 100644 index 0000000..3612dc6 --- /dev/null +++ b/src/components/IntegrationTests/__tests__/EditContextsModal.spec.tsx @@ -0,0 +1,112 @@ +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { useComponents } from '../../../hooks/useComponents'; +import { k8sPatchResource } from '../../../k8s/k8s-fetch'; +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('../../../k8s/k8s-fetch', () => ({ + k8sPatchResource: jest.fn(), +})); +jest.mock('../../../hooks/useComponents', () => ({ + useComponents: jest.fn(), +})); +jest.mock('../../Workspace/useWorkspaceInfo', () => ({ + 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', () => { + 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(); + }); + }); +});