From 8c1f7d915e7d989738f1e15333c3226f45a6ce24 Mon Sep 17 00:00:00 2001 From: Bryan Ramos Date: Wed, 18 Dec 2024 16:37:43 -0500 Subject: [PATCH] fix(KONFLUX-5886): allow unselecting default context The user should be able to unselect the default context during the creation of an integration test. Other changes: - Contexts are now required (at least one) Signed-off-by: Bryan Ramos --- .../IntegrationTests/ContextSelectList.tsx | 10 +-- .../IntegrationTests/ContextsField.tsx | 20 ++---- .../IntegrationTests/EditContextsModal.tsx | 8 ++- .../IntegrationTestSection.tsx | 2 +- .../IntegrationTestView.tsx | 19 ++++-- .../__tests__/IntegrationTestSection.spec.tsx | 9 +++ .../utils/__tests__/create-utils.spec.ts | 7 ++ .../utils/__tests__/validation-utils.spec.ts | 36 ++++++++++ .../IntegrationTestForm/utils/create-utils.ts | 18 +---- .../utils/validation-utils.ts | 10 +++ .../__tests__/ContextSelectList.spec.tsx | 53 ++++++++++----- .../__tests__/ContextsField.spec.tsx | 40 +++++------- .../__tests__/EditContextsModal.spec.tsx | 65 ++++++++++++++++--- .../{utils.tsx => utils/creation-utils.tsx} | 7 +- .../IntegrationTests/utils/test-utils.ts | 17 +++++ .../utils/validation-utils.tsx | 14 ++++ 16 files changed, 241 insertions(+), 94 deletions(-) rename src/components/IntegrationTests/{utils.tsx => utils/creation-utils.tsx} (95%) create mode 100644 src/components/IntegrationTests/utils/test-utils.ts create mode 100644 src/components/IntegrationTests/utils/validation-utils.tsx 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 /> - + { - if (!contexts?.length) return []; + const getFormContextValues = ( + integrateTest: IntegrationTestScenarioKind | null | undefined, + ): FormContext[] => { + 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; @@ -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/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/__tests__/ContextSelectList.spec.tsx b/src/components/IntegrationTests/__tests__/ContextSelectList.spec.tsx index bce05ca..85a6781 100644 --- a/src/components/IntegrationTests/__tests__/ContextSelectList.spec.tsx +++ b/src/components/IntegrationTests/__tests__/ContextSelectList.spec.tsx @@ -1,7 +1,8 @@ import { render, fireEvent, screen, act } from '@testing-library/react'; import '@testing-library/jest-dom'; import { ContextSelectList } from '../ContextSelectList'; -import { ContextOption } from '../utils'; +import { ContextOption } from '../utils/creation-utils'; +import { openDropdown, getContextOptionButton } from '../utils/test-utils'; describe('ContextSelectList Component', () => { const defaultProps = { @@ -19,33 +20,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 +77,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 +87,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' } }); }); @@ -99,6 +104,7 @@ describe('ContextSelectList Component', () => { render(); await openDropdown(); const groupOption = getContextOptionButton('group'); + /* eslint-disable-next-line @typescript-eslint/require-await */ await act(async () => { fireEvent.click(groupOption); }); @@ -108,6 +114,7 @@ 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(); }); @@ -116,6 +123,7 @@ describe('ContextSelectList Component', () => { render(); await openDropdown(); const componentOption = getContextOptionButton('component'); + /* eslint-disable-next-line @typescript-eslint/require-await */ await act(async () => fireEvent.click(componentOption)); expect(defaultProps.onSelect).toHaveBeenCalledWith('component'); }); @@ -123,6 +131,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 +148,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 +161,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..d5d5e86 100644 --- a/src/components/IntegrationTests/__tests__/ContextsField.spec.tsx +++ b/src/components/IntegrationTests/__tests__/ContextsField.spec.tsx @@ -6,7 +6,12 @@ import { useComponents } from '../../../hooks/useComponents'; import { ComponentKind } from '../../../types'; import { useWorkspaceInfo } from '../../Workspace/useWorkspaceInfo'; import ContextsField from '../ContextsField'; -import { contextOptions, mapContextsWithSelection, addComponentContexts } from '../utils'; +import { + contextOptions, + mapContextsWithSelection, + addComponentContexts, +} from '../utils/creation-utils'; +import { openDropdown } from '../utils/test-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(); 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,7 +114,7 @@ describe('ContextsField', () => { ]; setupMocks([], mockComponents); - render(); + render(); await openDropdown(); @@ -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. + // Unselecting the drop down value should be possible when creating a new integration test. await openDropdown(); 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..cfee356 100644 --- a/src/components/IntegrationTests/__tests__/EditContextsModal.spec.tsx +++ b/src/components/IntegrationTests/__tests__/EditContextsModal.spec.tsx @@ -1,4 +1,4 @@ -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'; @@ -6,7 +6,8 @@ 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'; +import { contextOptions } from '../utils/creation-utils'; +import { openDropdown, getContextOptionButton } from '../utils/test-utils'; // Mock external dependencies jest.mock('../../../k8s/k8s-fetch', () => ({ @@ -28,7 +29,7 @@ const initialValues: IntegrationTestFormValues = { name: intTest.metadata.name, url: 'test-url', optional: true, - contexts: intTest.spec.contexts, + contexts: contextOptions, }; const setup = () => @@ -74,10 +75,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 openDropdown(); + const groupOption = getContextOptionButton('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 +106,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 +135,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 openDropdown(); + const groupOption = getContextOptionButton('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/test-utils.ts b/src/components/IntegrationTests/utils/test-utils.ts new file mode 100644 index 0000000..62b2b91 --- /dev/null +++ b/src/components/IntegrationTests/utils/test-utils.ts @@ -0,0 +1,17 @@ +import { fireEvent, screen, act } from '@testing-library/react'; + +// Ignore this check for the tests. +// If not, the test will throw an error. +/* eslint-disable @typescript-eslint/require-await */ +export 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'); +}; + +export const getContextOptionButton = (name: string) => { + return screen.getByTestId(`context-option-${name}`).childNodes[0]; +}; 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), +});