diff --git a/src/components/IntegrationTests/ContextSelectList.tsx b/src/components/IntegrationTests/ContextSelectList.tsx index 3c0a5f1..16b7497 100644 --- a/src/components/IntegrationTests/ContextSelectList.tsx +++ b/src/components/IntegrationTests/ContextSelectList.tsx @@ -13,7 +13,7 @@ import { Button, } from '@patternfly/react-core'; import { TimesIcon } from '@patternfly/react-icons/dist/esm/icons/times-icon'; -import { ContextOption } from './utils'; +import { ContextOption } from './utils/creation-utils'; type ContextSelectListProps = { allContexts: ContextOption[]; @@ -22,7 +22,7 @@ type ContextSelectListProps = { inputValue: string; onInputValueChange: (value: string) => void; onRemoveAll: () => void; - editing: boolean; + error?: string; }; export const ContextSelectList: React.FC = ({ @@ -32,7 +32,7 @@ export const ContextSelectList: React.FC = ({ onRemoveAll, inputValue, onInputValueChange, - editing, + error, }) => { const [isOpen, setIsOpen] = useState(false); const [focusedItemIndex, setFocusedItemIndex] = useState(null); @@ -144,6 +144,7 @@ export const ContextSelectList: React.FC = ({ isExpanded={isOpen} style={{ minWidth: '750px' } as React.CSSProperties} data-test="context-dropdown-toggle" + status={error ? 'danger' : 'success'} > = ({ id="multi-typeahead-select-input" autoComplete="off" innerRef={textInputRef} - placeholder="Select a context" + placeholder={error ? 'You must select at least one context' : 'Select a context'} {...(activeItemId && { 'aria-activedescendant': activeItemId })} role="combobox" isExpanded={isOpen} @@ -204,7 +205,6 @@ export const ContextSelectList: React.FC = ({ isSelected={ctx.selected} description={ctx.description} ref={null} - isDisabled={!editing && ctx.name === 'application'} data-test={`context-option-${ctx.name}`} > {ctx.name} diff --git a/src/components/IntegrationTests/ContextsField.tsx b/src/components/IntegrationTests/ContextsField.tsx index c1c0c9f..4951a92 100644 --- a/src/components/IntegrationTests/ContextsField.tsx +++ b/src/components/IntegrationTests/ContextsField.tsx @@ -11,19 +11,18 @@ import { contextOptions, mapContextsWithSelection, addComponentContexts, -} from './utils'; +} from './utils/creation-utils'; interface IntegrationTestContextProps { heading?: React.ReactNode; fieldName: string; - editing: boolean; } -const ContextsField: React.FC = ({ heading, fieldName, editing }) => { +const ContextsField: React.FC = ({ heading, fieldName }) => { const { namespace, workspace } = useWorkspaceInfo(); const { applicationName } = useParams(); const [components, componentsLoaded] = useComponents(namespace, workspace, applicationName); - const [, { value: contexts }] = useField(fieldName); + const [, { value: contexts, error }] = useField(fieldName); const fieldId = getFieldId(fieldName, 'dropdown'); const [inputValue, setInputValue] = React.useState(''); @@ -31,20 +30,13 @@ const ContextsField: React.FC = ({ heading, fieldNa const selectedContextNames: string[] = (contexts ?? []).map((c: ContextOption) => c.name); // All the context options available to the user. const allContexts = React.useMemo(() => { - let initialSelectedContexts = mapContextsWithSelection(selectedContextNames, contextOptions); - // If this is a new integration test, ensure that 'application' is selected by default - if (!editing && !selectedContextNames.includes('application')) { - initialSelectedContexts = initialSelectedContexts.map((ctx) => { - return ctx.name === 'application' ? { ...ctx, selected: true } : ctx; - }); - } - + const initialSelectedContexts = mapContextsWithSelection(selectedContextNames, contextOptions); // If we have components and they are loaded, add to context option list. // Else, return the base context list. return componentsLoaded && components ? addComponentContexts(initialSelectedContexts, selectedContextNames, components) : initialSelectedContexts; - }, [componentsLoaded, components, selectedContextNames, editing]); + }, [componentsLoaded, components, selectedContextNames]); // This holds the contexts that are filtered using the user input value. const filteredContexts = React.useMemo(() => { @@ -101,7 +93,7 @@ const ContextsField: React.FC = ({ heading, fieldNa inputValue={inputValue} onInputValueChange={setInputValue} onRemoveAll={() => handleRemoveAll(arrayHelpers)} - editing={editing} + error={error} /> )} /> diff --git a/src/components/IntegrationTests/EditContextsModal.tsx b/src/components/IntegrationTests/EditContextsModal.tsx index c92e121..d7cf6a6 100644 --- a/src/components/IntegrationTests/EditContextsModal.tsx +++ b/src/components/IntegrationTests/EditContextsModal.tsx @@ -16,6 +16,7 @@ import { IntegrationTestScenarioKind, Context } from '../../types/coreBuildServi import { ComponentProps, createModalLauncher } from '../modal/createModalLauncher'; import ContextsField from './ContextsField'; import { UnformattedContexts, formatContexts } from './IntegrationTestForm/utils/create-utils'; +import { contextModalValidationSchema } from './utils/validation-utils'; type EditContextsModalProps = ComponentProps & { intTest: IntegrationTestScenarioKind; @@ -74,17 +75,18 @@ export const EditContextsModal: React.FC {({ handleSubmit, handleReset, isSubmitting, values }) => { const isChanged = values.contexts !== initialContexts; - const showConfirmation = isChanged && values.strategy === 'Automatic'; - const isValid = isChanged && (showConfirmation ? values.confirm : true); + const isPopulated = values.contexts.length > 0; + const isValid = isChanged && isPopulated; return (
- + {error && ( diff --git a/src/components/IntegrationTests/IntegrationTestForm/IntegrationTestSection.tsx b/src/components/IntegrationTests/IntegrationTestForm/IntegrationTestSection.tsx index 28f5d9a..2db1a2f 100644 --- a/src/components/IntegrationTests/IntegrationTestForm/IntegrationTestSection.tsx +++ b/src/components/IntegrationTests/IntegrationTestForm/IntegrationTestSection.tsx @@ -69,7 +69,7 @@ const IntegrationTestSection: React.FC> = ({ isIn data-test="git-path-repo" required /> - + { + const contexts = integrateTest?.spec?.contexts; + // NOTE: If this is a new integration test, + // have the 'application' context selected by default. + if (!integrateTest) { + return [defaultSelectedContextOption]; + } else if (!contexts?.length) { + return []; + } + + return contexts.map((context) => { + return context.name ? { name: context.name, description: context.description } : context; + }); +}; + const IntegrationTestView: React.FunctionComponent< React.PropsWithChildren > = ({ applicationName, integrationTest }) => { @@ -52,19 +76,6 @@ const IntegrationTestView: React.FunctionComponent< return formParams; }; - interface FormContext { - name: string; - description: string; - } - - const getFormContextValues = (contexts: Context[] | null | undefined): FormContext[] => { - if (!contexts?.length) return []; - - return contexts.map((context) => { - return context.name ? { name: context.name, description: context.description } : context; - }); - }; - const initialValues = { integrationTest: { name: integrationTest?.metadata.name ?? '', @@ -72,7 +83,7 @@ const IntegrationTestView: React.FunctionComponent< revision: revision?.value ?? '', path: path?.value ?? '', params: getFormParamValues(integrationTest?.spec?.params), - contexts: getFormContextValues(integrationTest?.spec?.contexts), + contexts: getFormContextValues(integrationTest), optional: integrationTest?.metadata.labels?.[IntegrationTestLabels.OPTIONAL] === 'true' ?? false, }, diff --git a/src/components/IntegrationTests/IntegrationTestForm/__tests__/IntegrationTestSection.spec.tsx b/src/components/IntegrationTests/IntegrationTestForm/__tests__/IntegrationTestSection.spec.tsx index 1450094..4249d67 100644 --- a/src/components/IntegrationTests/IntegrationTestForm/__tests__/IntegrationTestSection.spec.tsx +++ b/src/components/IntegrationTests/IntegrationTestForm/__tests__/IntegrationTestSection.spec.tsx @@ -58,4 +58,13 @@ describe('IntegrationTestSection', () => { screen.queryByTestId('its-param-field'); }); + + it('should render contexts section', () => { + formikRenderer(, { + source: 'test-source', + secret: null, + }); + + screen.queryByTestId('its-context-field'); + }); }); diff --git a/src/components/IntegrationTests/IntegrationTestForm/__tests__/IntegrationTestView.spec.tsx b/src/components/IntegrationTests/IntegrationTestForm/__tests__/IntegrationTestView.spec.tsx index 476458f..ea7261f 100644 --- a/src/components/IntegrationTests/IntegrationTestForm/__tests__/IntegrationTestView.spec.tsx +++ b/src/components/IntegrationTests/IntegrationTestForm/__tests__/IntegrationTestView.spec.tsx @@ -6,8 +6,11 @@ import { createK8sWatchResourceMock, renderWithQueryClient } from '../../../../u import { mockApplication } from '../../../ApplicationDetails/__data__/mock-data'; import { MockComponents } from '../../../Commits/CommitDetails/visualization/__data__/MockCommitWorkflowData'; import { WorkspaceContext } from '../../../Workspace/workspace-context'; -import { MockIntegrationTestsWithGit } from '../../IntegrationTestsListView/__data__/mock-integration-tests'; -import IntegrationTestView from '../IntegrationTestView'; +import { + MockIntegrationTests, + MockIntegrationTestsWithGit, +} from '../../IntegrationTestsListView/__data__/mock-integration-tests'; +import IntegrationTestView, { getFormContextValues } from '../IntegrationTestView'; import { createIntegrationTest } from '../utils/create-utils'; jest.mock('../../../../utils/analytics'); @@ -187,3 +190,31 @@ describe('IntegrationTestView', () => { wrapper.getByLabelText(/Integration test name/).setAttribute('value', 'new value'); }); }); + +describe('getFormContextValues', () => { + it('should return default context when creating an integration test', () => { + const result = getFormContextValues(null); + expect(result).toEqual([ + { + name: 'application', + description: 'execute the integration test in all cases - this would be the default state', + selected: true, + }, + ]); + }); + + it('should return the integration test contexts', () => { + const integrationTest = MockIntegrationTests[2]; + const result = getFormContextValues(integrationTest); + expect(result).toEqual([ + { + description: 'Application testing 3', + name: 'application', + }, + { + description: 'Group testing 3', + name: 'group', + }, + ]); + }); +}); diff --git a/src/components/IntegrationTests/IntegrationTestForm/utils/__tests__/create-utils.spec.ts b/src/components/IntegrationTests/IntegrationTestForm/utils/__tests__/create-utils.spec.ts index 1db1fd8..7cf83cf 100644 --- a/src/components/IntegrationTests/IntegrationTestForm/utils/__tests__/create-utils.spec.ts +++ b/src/components/IntegrationTests/IntegrationTestForm/utils/__tests__/create-utils.spec.ts @@ -56,6 +56,13 @@ describe('Create Utils', () => { url: 'test-url', path: 'test-path', optional: false, + contexts: [ + { + name: 'application', + description: + 'execute the integration test in all cases - this would be the default state', + }, + ], }, 'Test Application', 'test-ns', diff --git a/src/components/IntegrationTests/IntegrationTestForm/utils/__tests__/validation-utils.spec.ts b/src/components/IntegrationTests/IntegrationTestForm/utils/__tests__/validation-utils.spec.ts index 7f081b2..cbc30c0 100644 --- a/src/components/IntegrationTests/IntegrationTestForm/utils/__tests__/validation-utils.spec.ts +++ b/src/components/IntegrationTests/IntegrationTestForm/utils/__tests__/validation-utils.spec.ts @@ -9,6 +9,12 @@ describe('validation-utils', () => { url: 'test-url', path: 'test-path', revision: 'revision', + contexts: [ + { + name: 'test', + description: 'test description', + }, + ], }, }), ).not.toThrow(); @@ -21,6 +27,12 @@ describe('validation-utils', () => { url: 'test-url', path: 'test-path', revision: 'revision', + contexts: [ + { + name: 'test', + description: 'test description', + }, + ], }, }), ).rejects.toThrow('Required'); @@ -34,6 +46,12 @@ describe('validation-utils', () => { url: 'test-url', path: 'test-path', revision: 'revision', + contexts: [ + { + name: 'test', + description: 'test description', + }, + ], }, }), ).rejects.toThrow( @@ -49,6 +67,12 @@ describe('validation-utils', () => { url: 'test-url', path: 'test-path', revision: 'revision', + contexts: [ + { + name: 'test', + description: 'test description', + }, + ], }, }), ).rejects.toThrow( @@ -78,4 +102,16 @@ describe('validation-utils', () => { }), ).rejects.toThrow('Required'); }); + + it('should fail when contexts is missing', async () => { + await expect( + integrationTestValidationSchema.validate({ + integrationTest: { + name: 'test-name', + url: 'test-url', + path: 'test-path', + }, + }), + ).rejects.toThrow('Required'); + }); }); diff --git a/src/components/IntegrationTests/IntegrationTestForm/utils/create-utils.ts b/src/components/IntegrationTests/IntegrationTestForm/utils/create-utils.ts index 88bb6f2..c8fc502 100644 --- a/src/components/IntegrationTests/IntegrationTestForm/utils/create-utils.ts +++ b/src/components/IntegrationTests/IntegrationTestForm/utils/create-utils.ts @@ -47,22 +47,8 @@ export const formatParams = (params): Param[] => { }; 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', - }; - const newContextNames = new Set(contexts.map((ctx) => ctx.name)); +export const formatContexts = (contexts: UnformattedContexts = []): Context[] | null => { const newContexts = contexts.map(({ name, description }) => ({ name, description })); - // Even though this option is preselected in the context option list, - // it's not appended to the Formik field array when submitting. - // Lets set the default here so we know it will be applied. - if (setDefault && !newContextNames.has('application')) { - newContexts.push(defaultContext); - } return newContexts.length ? newContexts : null; }; @@ -157,7 +143,7 @@ export const createIntegrationTest = ( ], }, params: formatParams(params), - contexts: formatContexts(contexts, true), + contexts: formatContexts(contexts), }, }; diff --git a/src/components/IntegrationTests/IntegrationTestForm/utils/validation-utils.ts b/src/components/IntegrationTests/IntegrationTestForm/utils/validation-utils.ts index 9fe43d7..c5639de 100644 --- a/src/components/IntegrationTests/IntegrationTestForm/utils/validation-utils.ts +++ b/src/components/IntegrationTests/IntegrationTestForm/utils/validation-utils.ts @@ -12,5 +12,15 @@ export const integrationTestValidationSchema = yup.object({ .string() .required('Required') .max(2000, 'Please enter a path that is less than 2000 characters.'), + contexts: yup + .array() + .of( + yup.object().shape({ + name: yup.string(), + description: yup.string(), + }), + ) + .required('Required') + .min(1), }), }); diff --git a/src/components/IntegrationTests/IntegrationTestsListView/__data__/mock-integration-tests.ts b/src/components/IntegrationTests/IntegrationTestsListView/__data__/mock-integration-tests.ts index 9552e04..fe8033c 100644 --- a/src/components/IntegrationTests/IntegrationTestsListView/__data__/mock-integration-tests.ts +++ b/src/components/IntegrationTests/IntegrationTestsListView/__data__/mock-integration-tests.ts @@ -51,6 +51,33 @@ export const MockIntegrationTests = [ pipeline: 'pipeline-2', }, }, + { + apiVersion: 'appstudio.redhat.com/v1alpha1', + kind: 'IntegrationTestScenario', + metadata: { + annotations: { + 'app.kubernetes.io/display-name': 'Test 3', + }, + name: 'test-app-test-3', + namespace: 'test-namespace', + uid: 'ed722704-74bc-4152-b27b-bee29cc7bfd3', + }, + spec: { + application: 'test-app', + bundle: 'https://quay.io/test-rep/test-bundle:test-3', + contexts: [ + { + description: 'Application testing 3', + name: 'application', + }, + { + description: 'Group testing 3', + name: 'group', + }, + ], + pipeline: 'pipeline-3', + }, + }, ]; export const MockIntegrationTestsWithBundles: IntegrationTestScenarioKind[] = [ diff --git a/src/components/IntegrationTests/__tests__/ContextSelectList.spec.tsx b/src/components/IntegrationTests/__tests__/ContextSelectList.spec.tsx index bce05ca..ec4d5c0 100644 --- a/src/components/IntegrationTests/__tests__/ContextSelectList.spec.tsx +++ b/src/components/IntegrationTests/__tests__/ContextSelectList.spec.tsx @@ -1,7 +1,11 @@ import { render, fireEvent, screen, act } from '@testing-library/react'; import '@testing-library/jest-dom'; +import { + getIntegrationTestContextOptionButton, + openIntegrationTestContextDropdown, +} from '../../../utils/test-utils'; import { ContextSelectList } from '../ContextSelectList'; -import { ContextOption } from '../utils'; +import { ContextOption } from '../utils/creation-utils'; describe('ContextSelectList Component', () => { const defaultProps = { @@ -19,33 +23,35 @@ describe('ContextSelectList Component', () => { onInputValueChange: jest.fn(), inputValue: '', onRemoveAll: jest.fn(), - editing: true, + error: '', + }; + + const unselectedContextsProps = { + allContexts: [ + { name: 'application', description: 'Test context application', selected: false }, + { name: 'component', description: 'Test context component', selected: false }, + { name: 'group', description: 'Test context group', selected: false }, + ] as ContextOption[], + filteredContexts: [ + { name: 'application', description: 'Test context application', selected: false }, + { name: 'component', description: 'Test context component', selected: false }, + { name: 'group', description: 'Test context group', selected: false }, + ] as ContextOption[], + onSelect: jest.fn(), + onInputValueChange: jest.fn(), + inputValue: '', + onRemoveAll: jest.fn(), + error: 'integrationTest.context must contain 1 item.', }; afterEach(() => { jest.clearAllMocks(); }); - // Ignore this check for the tests. - // If not, the test will throw an error. - /* eslint-disable @typescript-eslint/require-await */ - const openDropdown = async () => { - const toggleButton = screen.getByTestId('context-dropdown-toggle').childNodes[1]; - expect(toggleButton).toHaveAttribute('aria-expanded', 'false'); - await act(async () => { - fireEvent.click(toggleButton); - }); - expect(toggleButton).toHaveAttribute('aria-expanded', 'true'); - }; - const getContextOption = (name: string) => { return screen.getByTestId(`context-option-${name}`); }; - const getContextOptionButton = (name: string) => { - return screen.getByTestId(`context-option-${name}`).childNodes[0]; - }; - const getChip = (name: string) => { return screen.getByTestId(`context-chip-${name}`); }; @@ -74,6 +80,7 @@ describe('ContextSelectList Component', () => { it('calls onSelect when a chip is clicked', async () => { render(); const appChip = getChipButton('application'); + /* eslint-disable-next-line @typescript-eslint/require-await */ await act(async () => { fireEvent.click(appChip); }); @@ -83,6 +90,7 @@ describe('ContextSelectList Component', () => { it('updates input value on typing', async () => { render(); const input = screen.getByPlaceholderText('Select a context'); + /* eslint-disable-next-line @typescript-eslint/require-await */ await act(async () => { fireEvent.change(input, { target: { value: 'new context' } }); }); @@ -91,14 +99,15 @@ describe('ContextSelectList Component', () => { it('opens the dropdown when clicking the toggle', async () => { render(); - await openDropdown(); + await openIntegrationTestContextDropdown(); expect(getContextOption('application')).toBeInTheDocument(); }); it('calls onSelect when a context option is clicked', async () => { render(); - await openDropdown(); - const groupOption = getContextOptionButton('group'); + await openIntegrationTestContextDropdown(); + const groupOption = getIntegrationTestContextOptionButton('group'); + /* eslint-disable-next-line @typescript-eslint/require-await */ await act(async () => { fireEvent.click(groupOption); }); @@ -108,14 +117,16 @@ describe('ContextSelectList Component', () => { it('calls onRemoveAll when clear button is clicked', async () => { render(); const clearButton = screen.getByTestId('clear-button'); + /* eslint-disable-next-line @typescript-eslint/require-await */ await act(async () => fireEvent.click(clearButton)); expect(defaultProps.onRemoveAll).toHaveBeenCalled(); }); it('closes the dropdown on selecting an item', async () => { render(); - await openDropdown(); - const componentOption = getContextOptionButton('component'); + await openIntegrationTestContextDropdown(); + const componentOption = getIntegrationTestContextOptionButton('component'); + /* eslint-disable-next-line @typescript-eslint/require-await */ await act(async () => fireEvent.click(componentOption)); expect(defaultProps.onSelect).toHaveBeenCalledWith('component'); }); @@ -123,6 +134,7 @@ describe('ContextSelectList Component', () => { it('should focus on the next item when ArrowDown is pressed', async () => { render(); const input = screen.getByTestId('multi-typeahead-select-input'); + /* eslint-disable-next-line @typescript-eslint/require-await */ await act(async () => { fireEvent.keyDown(input, { key: 'ArrowDown' }); }); @@ -139,6 +151,7 @@ describe('ContextSelectList Component', () => { it('should focus on the previous item when ArrowUp is pressed', async () => { render(); const input = screen.getByTestId('multi-typeahead-select-input'); + /* eslint-disable-next-line @typescript-eslint/require-await */ await act(async () => { fireEvent.keyDown(input, { key: 'ArrowUp' }); }); @@ -151,4 +164,11 @@ describe('ContextSelectList Component', () => { expect(getContextOption(ctx.name)).not.toHaveClass('pf-m-focus'); }); }); + + /* eslint-disable-next-line @typescript-eslint/require-await */ + it('should be marked as invalid when no contexts are selected', async () => { + render(); + const input = screen.getByPlaceholderText('You must select at least one context'); + expect(input).toBeInTheDocument(); + }); }); diff --git a/src/components/IntegrationTests/__tests__/ContextsField.spec.tsx b/src/components/IntegrationTests/__tests__/ContextsField.spec.tsx index dd1054a..f93c2e0 100644 --- a/src/components/IntegrationTests/__tests__/ContextsField.spec.tsx +++ b/src/components/IntegrationTests/__tests__/ContextsField.spec.tsx @@ -4,9 +4,14 @@ import { render, screen, fireEvent, act } from '@testing-library/react'; import { FieldArray, useField } from 'formik'; import { useComponents } from '../../../hooks/useComponents'; import { ComponentKind } from '../../../types'; +import { openIntegrationTestContextDropdown } from '../../../utils/test-utils'; import { useWorkspaceInfo } from '../../Workspace/useWorkspaceInfo'; import ContextsField from '../ContextsField'; -import { contextOptions, mapContextsWithSelection, addComponentContexts } from '../utils'; +import { + contextOptions, + mapContextsWithSelection, + addComponentContexts, +} from '../utils/creation-utils'; // Mock the hooks used in the component jest.mock('react-router-dom', () => ({ @@ -46,20 +51,6 @@ describe('ContextsField', () => { }); }; - // Ignore this check for the tests. - // If not, the test will throw an error. - /* eslint-disable @typescript-eslint/require-await */ - const openDropdown = async () => { - const toggleButton = screen.getByTestId('context-dropdown-toggle').childNodes[1]; - expect(toggleButton).toHaveAttribute('aria-expanded', 'false'); - - await act(async () => { - fireEvent.click(toggleButton); - }); - - expect(toggleButton).toHaveAttribute('aria-expanded', 'true'); - }; - const testContextOption = (name: string) => { expect(screen.getByTestId(`context-option-${name}`)).toBeInTheDocument(); }; @@ -71,7 +62,7 @@ describe('ContextsField', () => { it('should render custom header if passed', () => { mockUseField.mockReturnValue([{}, { value: [] }]); - render(); + render(); expect(screen.getByText('Test Heading')).toBeInTheDocument(); }); @@ -90,11 +81,12 @@ describe('ContextsField', () => { }; mockUseField.mockReturnValue([{}, { value: [selectedContext] }]); - render(); + render(); - await openDropdown(); + await openIntegrationTestContextDropdown(); testContextOption(contextOptions[0].name); + /* eslint-disable-next-line @typescript-eslint/require-await */ await act(async () => { fireEvent.click(screen.getByTestId(`context-option-${contextOptions[0].name}`).childNodes[0]); }); @@ -103,6 +95,7 @@ describe('ContextsField', () => { expect(removeMock).toHaveBeenCalledWith(0); // Simulate selecting another context + /* eslint-disable-next-line @typescript-eslint/require-await */ await act(async () => { fireEvent.click(screen.getByTestId(`context-option-${contextOptions[1].name}`).childNodes[0]); }); @@ -121,9 +114,9 @@ describe('ContextsField', () => { ]; setupMocks([], mockComponents); - render(); + render(); - await openDropdown(); + await openIntegrationTestContextDropdown(); // Names should be visible as a menu option ['componentA', 'componentB'].forEach((componentName) => { @@ -135,15 +128,18 @@ describe('ContextsField', () => { }); it('should have applications pre-set as a default context when creating a new integration test', async () => { - mockUseField.mockReturnValue([{}, { value: [] }]); - render(); + const defaultContext = { ...contextOptions[0], selected: true }; + // The default context should be coming from the parent components 'initialValues' + mockUseField.mockReturnValue([{}, { value: [defaultContext] }]); + render(); const chip = screen.getByTestId('context-chip-application'); // Check that context option already has a chip expect(chip).toBeInTheDocument(); - // Unselecting the drop down value should not be possible when creating a new integration test. - await openDropdown(); + // Unselecting the drop down value should be possible when creating a new integration test. + await openIntegrationTestContextDropdown(); testContextOption('application'); - expect(screen.getByTestId('context-option-application').childNodes[0]).toBeDisabled(); + // The user should be free to unselect the default application context + expect(screen.getByTestId('context-option-application').childNodes[0]).not.toBeDisabled(); }); }); diff --git a/src/components/IntegrationTests/__tests__/EditContextsModal.spec.tsx b/src/components/IntegrationTests/__tests__/EditContextsModal.spec.tsx index 3612dc6..83f1c0f 100644 --- a/src/components/IntegrationTests/__tests__/EditContextsModal.spec.tsx +++ b/src/components/IntegrationTests/__tests__/EditContextsModal.spec.tsx @@ -1,12 +1,16 @@ -import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { screen, fireEvent, waitFor, act } 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 { + formikRenderer, + openIntegrationTestContextDropdown, + getIntegrationTestContextOptionButton, +} 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'; +import { contextOptions } from '../utils/creation-utils'; // Mock external dependencies jest.mock('../../../k8s/k8s-fetch', () => ({ @@ -28,7 +32,7 @@ const initialValues: IntegrationTestFormValues = { name: intTest.metadata.name, url: 'test-url', optional: true, - contexts: intTest.spec.contexts, + contexts: contextOptions, }; const setup = () => @@ -74,10 +78,30 @@ describe('EditContextsModal', () => { setup(); const clearButton = screen.getByTestId('clear-button'); + const submitButton = screen.getByTestId('update-contexts'); // Clear all selections fireEvent.click(clearButton); - // Save button should now be active - fireEvent.click(screen.getByRole('button', { name: 'Save' })); + // Save button should not be active + // if no context values are selected. + expect(submitButton).toBeDisabled(); + fireEvent.click(submitButton); + + // The user should not be able to update the contexts. + await waitFor(() => { + expect(patchResourceMock).toHaveBeenCalledTimes(0); + }); + + // Select a context + await openIntegrationTestContextDropdown(); + const groupOption = getIntegrationTestContextOptionButton('group'); + /* eslint-disable-next-line @typescript-eslint/require-await */ + await act(async () => { + fireEvent.click(groupOption); + }); + + // Submission should be available now + expect(submitButton).not.toBeDisabled(); + fireEvent.click(submitButton); await waitFor(() => { expect(patchResourceMock).toHaveBeenCalledTimes(1); @@ -85,8 +109,26 @@ describe('EditContextsModal', () => { expect(patchResourceMock).toHaveBeenCalledWith( expect.objectContaining({ + model: { + apiGroup: 'appstudio.redhat.com', + apiVersion: 'v1beta1', + kind: 'IntegrationTestScenario', + namespaced: true, + plural: 'integrationtestscenarios', + }, + patches: [ + { + op: 'replace', + path: '/spec/contexts', + value: [ + { + description: 'execute the integration test for a Snapshot of the `group` type', + name: 'group', + }, + ], + }, + ], queryOptions: { name: 'test-app-test-1', ns: 'test-namespace' }, - patches: [{ op: 'replace', path: '/spec/contexts', value: null }], }), ); expect(onCloseMock).toHaveBeenCalledWith(null, { submitClicked: true }); @@ -96,11 +138,17 @@ describe('EditContextsModal', () => { patchResourceMock.mockRejectedValue('Failed to update contexts'); setup(); - const clearButton = screen.getByTestId('clear-button'); - // Clear all selections - fireEvent.click(clearButton); + const submitButton = screen.getByTestId('update-contexts'); + // Select a context + await openIntegrationTestContextDropdown(); + const groupOption = getIntegrationTestContextOptionButton('group'); + /* eslint-disable-next-line @typescript-eslint/require-await */ + await act(async () => { + fireEvent.click(groupOption); + }); + // Click Save button - fireEvent.click(screen.getByRole('button', { name: 'Save' })); + fireEvent.click(submitButton); // wait for the error message to appear await waitFor(() => { diff --git a/src/components/IntegrationTests/utils.tsx b/src/components/IntegrationTests/utils/creation-utils.tsx similarity index 95% rename from src/components/IntegrationTests/utils.tsx rename to src/components/IntegrationTests/utils/creation-utils.tsx index 70a31cc..8e4eb99 100644 --- a/src/components/IntegrationTests/utils.tsx +++ b/src/components/IntegrationTests/utils/creation-utils.tsx @@ -1,4 +1,4 @@ -import { ComponentKind } from '../../types'; +import { ComponentKind } from '../../../types'; export interface ContextOption { name: string; @@ -46,6 +46,11 @@ export const contextOptions: ContextOption[] = [ }, ]; +export const defaultSelectedContextOption = { + ...contextOptions.find((ctx) => ctx.name === 'application'), + selected: true, +}; + /** * Maps over the provided context options and assigns a `selected` property to each context * based on whether its `name` is present in the array of selected context names. diff --git a/src/components/IntegrationTests/utils/validation-utils.tsx b/src/components/IntegrationTests/utils/validation-utils.tsx new file mode 100644 index 0000000..7b09a58 --- /dev/null +++ b/src/components/IntegrationTests/utils/validation-utils.tsx @@ -0,0 +1,14 @@ +import * as yup from 'yup'; + +export const contextModalValidationSchema = yup.object({ + contexts: yup + .array() + .of( + yup.object().shape({ + name: yup.string(), + description: yup.string(), + }), + ) + .required('Required') + .min(1), +}); diff --git a/src/utils/test-utils.tsx b/src/utils/test-utils.tsx index 929b266..ce159bc 100644 --- a/src/utils/test-utils.tsx +++ b/src/utils/test-utils.tsx @@ -2,7 +2,14 @@ import * as React from 'react'; import * as ReactRouterDom from 'react-router-dom'; import { Form } from '@patternfly/react-core'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { RenderOptions, render, screen, waitForElementToBeRemoved } from '@testing-library/react'; +import { + RenderOptions, + render, + screen, + waitForElementToBeRemoved, + fireEvent, + act, +} from '@testing-library/react'; import { FormikValues, Formik } from 'formik'; import * as WorkspaceHook from '../components/Workspace/useWorkspaceInfo'; import * as WorkspaceUtils from '../components/Workspace/workspace-context'; @@ -236,3 +243,19 @@ export const WithTestWorkspaceContext = export const waitForLoadingToFinish = async () => await waitForElementToBeRemoved(() => screen.getByRole('progressbar')); + +// Ignore this check for the tests. +// If not, the test will throw an error. +/* eslint-disable @typescript-eslint/require-await */ +export const openIntegrationTestContextDropdown = async () => { + const toggleButton = screen.getByTestId('context-dropdown-toggle').childNodes[1]; + expect(toggleButton).toHaveAttribute('aria-expanded', 'false'); + await act(async () => { + fireEvent.click(toggleButton); + }); + expect(toggleButton).toHaveAttribute('aria-expanded', 'true'); +}; + +export const getIntegrationTestContextOptionButton = (name: string) => { + return screen.getByTestId(`context-option-${name}`).childNodes[0]; +};