From 5781cd1c3893b502cfa07d70328dcf831941e57d Mon Sep 17 00:00:00 2001 From: Bryan Ramos Date: Fri, 15 Nov 2024 13:24:16 -0500 Subject: [PATCH] feat(KFLUXUI-191): allow users to set context Allows users to set context values, both in the edit and create integration page. By default the 'application' context should be selected when creating a new integration test. --- .../IntegrationTests/ContextSelectList.tsx | 216 ++++++++++++++++++ .../IntegrationTests/ContextsField.tsx | 115 ++++++++++ .../IntegrationTestSection.tsx | 2 + .../IntegrationTestView.tsx | 16 +- .../__tests__/IntegrationTestSection.spec.tsx | 4 + .../__tests__/IntegrationTestView.spec.tsx | 9 + .../IntegrationTestForm/types.ts | 3 +- .../utils/__tests__/create-utils.spec.ts | 2 +- .../IntegrationTestForm/utils/create-utils.ts | 30 ++- .../__tests__/ContextsField.spec.tsx | 190 +++++++++++++++ .../__tests__/ContextsSelectList.spec.tsx | 154 +++++++++++++ src/components/IntegrationTests/utils.tsx | 97 ++++++++ 12 files changed, 827 insertions(+), 11 deletions(-) create mode 100644 src/components/IntegrationTests/ContextSelectList.tsx create mode 100644 src/components/IntegrationTests/ContextsField.tsx create mode 100644 src/components/IntegrationTests/__tests__/ContextsField.spec.tsx create mode 100644 src/components/IntegrationTests/__tests__/ContextsSelectList.spec.tsx create mode 100644 src/components/IntegrationTests/utils.tsx diff --git a/src/components/IntegrationTests/ContextSelectList.tsx b/src/components/IntegrationTests/ContextSelectList.tsx new file mode 100644 index 0000000..3c0a5f1 --- /dev/null +++ b/src/components/IntegrationTests/ContextSelectList.tsx @@ -0,0 +1,216 @@ +import React, { useState } from 'react'; +import { + Select, + SelectOption, + SelectList, + MenuToggle, + MenuToggleElement, + ChipGroup, + Chip, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button, +} from '@patternfly/react-core'; +import { TimesIcon } from '@patternfly/react-icons/dist/esm/icons/times-icon'; +import { ContextOption } from './utils'; + +type ContextSelectListProps = { + allContexts: ContextOption[]; + filteredContexts: ContextOption[]; + onSelect: (contextName: string) => void; + inputValue: string; + onInputValueChange: (value: string) => void; + onRemoveAll: () => void; + editing: boolean; +}; + +export const ContextSelectList: React.FC = ({ + allContexts, + filteredContexts, + onSelect, + onRemoveAll, + inputValue, + onInputValueChange, + editing, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [focusedItemIndex, setFocusedItemIndex] = useState(null); + const [activeItemId, setActiveItemId] = React.useState(null); + const textInputRef = React.useRef(); + + const NO_RESULTS = 'No results found'; + + // Open the dropdown if the input value changes + React.useEffect(() => { + if (inputValue) { + setIsOpen(true); + } + }, [inputValue]); + + // Utility function to create a unique item ID based on the context value + const createItemId = (value: string) => `select-multi-typeahead-${value.replace(' ', '-')}`; + + // Set both the focused and active item for keyboard navigation + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = filteredContexts[itemIndex]; + setActiveItemId(createItemId(focusedItem.name)); + }; + + // Reset focused and active items when the dropdown is closed or input is cleared + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + // Close the dropdown menu and reset focus states + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + // Handle the input field click event to toggle the dropdown + const onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputValue) { + closeMenu(); + } + }; + + // Gets the index of the next element we want to focus on, based on the length of + // the filtered contexts and the arrow key direction. + const getNextFocusedIndex = ( + currentIndex: number | null, + length: number, + direction: 'up' | 'down', + ) => { + if (direction === 'up') { + return currentIndex === null || currentIndex === 0 ? length - 1 : currentIndex - 1; + } + return currentIndex === null || currentIndex === length - 1 ? 0 : currentIndex + 1; + }; + + // Handle up/down arrow key navigation for the dropdown + const handleMenuArrowKeys = (key: string) => { + // If we're pressing the arrow keys, make sure the list is open. + if (!isOpen) { + setIsOpen(true); + } + const direction = key === 'ArrowUp' ? 'up' : 'down'; + const indexToFocus = getNextFocusedIndex(focusedItemIndex, filteredContexts.length, direction); + setActiveAndFocusedItem(indexToFocus); + }; + + // Handle keydown events in the input field (e.g., Enter, Arrow keys) + const onInputKeyDown = (event: React.KeyboardEvent) => { + const focusedItem = focusedItemIndex !== null ? filteredContexts[focusedItemIndex] : null; + + if (event.key === 'Enter' && focusedItem && focusedItem.name !== NO_RESULTS) { + onSelect(focusedItem.name); + } + + if (['ArrowUp', 'ArrowDown'].includes(event.key)) { + handleMenuArrowKeys(event.key); + } + }; + + // Handle selection of a context from the dropdown + const handleSelect = (value: string) => { + onSelect(value); + textInputRef.current?.focus(); + }; + + // Toggle the dropdown open/closed + const onToggleClick = () => { + setIsOpen(!isOpen); + textInputRef?.current?.focus(); + }; + + // Handle changes to the input field value + const onTextInputChange = (_event: React.FormEvent, value: string) => { + // Update input value + onInputValueChange(value); + resetActiveAndFocusedItem(); + }; + + const renderToggle = (toggleRef: React.Ref) => ( + + + + + {allContexts + .filter((ctx) => ctx.selected) + .map((ctx) => ( + handleSelect(ctx.name)} + data-test={`context-chip-${ctx.name}`} + > + {ctx.name} + + ))} + + + {filteredContexts.some((ctx) => ctx.selected) && ( + + + + )} + + + ); + + return ( + + ); +}; diff --git a/src/components/IntegrationTests/ContextsField.tsx b/src/components/IntegrationTests/ContextsField.tsx new file mode 100644 index 0000000..c940e1e --- /dev/null +++ b/src/components/IntegrationTests/ContextsField.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { useParams } from 'react-router-dom'; +import { FormGroup } 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'; +import { useWorkspaceInfo } from '../Workspace/useWorkspaceInfo'; +import { ContextSelectList } from './ContextSelectList'; +import { + ContextOption, + contextOptions, + mapContextsWithSelection, + addComponentContexts, +} from './utils'; + +interface IntegrationTestContextProps { + heading?: React.ReactNode; + fieldName: string; + editing: boolean; +} + +const ContextsField: React.FC = ({ heading, fieldName, editing }) => { + const { namespace, workspace } = useWorkspaceInfo(); + const { appName } = useParams(); + const [components, componentsLoaded] = useComponents(namespace, workspace, appName); + const [, { value: contexts }] = useField(fieldName); + const fieldId = getFieldId(fieldName, 'dropdown'); + const [inputValue, setInputValue] = React.useState(''); + + // The names of the existing selected contexts. + 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; + }); + } + + // 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]); + + // This holds the contexts that are filtered using the user input value. + const filteredContexts = React.useMemo(() => { + if (inputValue) { + const filtered = allContexts.filter((ctx) => + ctx.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + return filtered.length + ? filtered + : [{ name: 'No results found', description: 'Please try another value.', selected: false }]; + } + return allContexts; + }, [inputValue, allContexts]); + + /** + * React callback that is used to select or deselect a context option using Formik FieldArray array helpers. + * If the context exists and it's been selected, remove from array. + * Else push to the Formik FieldArray array. + */ + const handleSelect = React.useCallback( + (arrayHelpers: FieldArrayRenderProps, contextName: string) => { + const currentContext: ContextOption = allContexts.find( + (ctx: ContextOption) => ctx.name === contextName, + ); + const isSelected = currentContext && currentContext.selected; + const index: number = contexts.findIndex((c: ContextOption) => c.name === contextName); + + if (isSelected && index !== -1) { + arrayHelpers.remove(index); // Deselect + } else if (!isSelected) { + // Select, add necessary data + arrayHelpers.push({ name: contextName, description: currentContext.description }); + } + }, + [contexts, allContexts], + ); + + // Handles unselecting all the contexts + const handleRemoveAll = async (arrayHelpers: FieldArrayRenderProps) => { + // Clear all selections + await arrayHelpers.form.setFieldValue(fieldName, []); + }; + + return ( + + {componentsLoaded && components ? ( + ( + handleSelect(arrayHelpers, contextName)} + inputValue={inputValue} + onInputValueChange={setInputValue} + onRemoveAll={() => handleRemoveAll(arrayHelpers)} + editing={editing} + /> + )} + /> + ) : ( + 'Loading Additional Component Context options' + )} + + ); +}; + +export default ContextsField; diff --git a/src/components/IntegrationTests/IntegrationTestForm/IntegrationTestSection.tsx b/src/components/IntegrationTests/IntegrationTestForm/IntegrationTestSection.tsx index 9d7ad01..28f5d9a 100644 --- a/src/components/IntegrationTests/IntegrationTestForm/IntegrationTestSection.tsx +++ b/src/components/IntegrationTests/IntegrationTestForm/IntegrationTestSection.tsx @@ -9,6 +9,7 @@ import { import { useField } from 'formik'; import { CheckboxField, InputField } from 'formik-pf'; import { RESOURCE_NAME_REGEX_MSG } from '../../../utils/validation-utils'; +import ContextsField from '../ContextsField'; import FormikParamsField from '../FormikParamsField'; type Props = { isInPage?: boolean; edit?: boolean }; @@ -68,6 +69,7 @@ const IntegrationTestSection: React.FC> = ({ isIn data-test="git-path-repo" required /> + { + 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 ?? '', @@ -59,6 +72,7 @@ const IntegrationTestView: React.FunctionComponent< revision: revision?.value ?? '', path: path?.value ?? '', params: getFormParamValues(integrationTest?.spec?.params), + contexts: getFormContextValus(integrationTest?.spec?.contexts), 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 dff17d6..4ea139d 100644 --- a/src/components/IntegrationTests/IntegrationTestForm/__tests__/IntegrationTestSection.spec.tsx +++ b/src/components/IntegrationTests/IntegrationTestForm/__tests__/IntegrationTestSection.spec.tsx @@ -7,6 +7,10 @@ const navigateMock = jest.fn(); jest.mock('react-router-dom', () => ({ Link: (props) => {props.children}, useNavigate: () => navigateMock, + // Used in ContextsField + useParams: jest.fn(() => ({ + appName: 'test-app', + })), })); jest.mock('react-i18next', () => ({ diff --git a/src/components/IntegrationTests/IntegrationTestForm/__tests__/IntegrationTestView.spec.tsx b/src/components/IntegrationTests/IntegrationTestForm/__tests__/IntegrationTestView.spec.tsx index 50d8b96..476458f 100644 --- a/src/components/IntegrationTests/IntegrationTestForm/__tests__/IntegrationTestView.spec.tsx +++ b/src/components/IntegrationTests/IntegrationTestForm/__tests__/IntegrationTestView.spec.tsx @@ -1,8 +1,10 @@ import { fireEvent, RenderResult } from '@testing-library/react'; import '@testing-library/jest-dom'; import { useApplications } from '../../../../hooks/useApplications'; +import { useComponents } from '../../../../hooks/useComponents'; import { createK8sWatchResourceMock, renderWithQueryClient } from '../../../../utils/test-utils'; 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'; @@ -39,11 +41,17 @@ jest.mock('../../../../hooks/useApplications', () => ({ useApplications: jest.fn(), })); +jest.mock('../../../../hooks/useComponents', () => ({ + // Used in ContextsField + useComponents: jest.fn(), +})); + jest.mock('../../../../utils/rbac', () => ({ useAccessReviewForModel: jest.fn(() => [true, true]), })); const createIntegrationTestMock = createIntegrationTest as jest.Mock; +const mockUseComponents = useComponents as jest.Mock; class MockResizeObserver { observe() { @@ -82,6 +90,7 @@ describe('IntegrationTestView', () => { beforeEach(() => { useApplicationsMock.mockReturnValue([[mockApplication], true]); watchResourceMock.mockReturnValue([[], true]); + mockUseComponents.mockReturnValue([MockComponents, true]); }); const fillIntegrationTestForm = (wrapper: RenderResult) => { fireEvent.input(wrapper.getByLabelText(/Integration test name/), { diff --git a/src/components/IntegrationTests/IntegrationTestForm/types.ts b/src/components/IntegrationTests/IntegrationTestForm/types.ts index 07812ed..17943c2 100644 --- a/src/components/IntegrationTests/IntegrationTestForm/types.ts +++ b/src/components/IntegrationTests/IntegrationTestForm/types.ts @@ -1,4 +1,4 @@ -import { Param } from '../../../types/coreBuildService'; +import { Param, Context } from '../../../types/coreBuildService'; import { K8sResourceCommon } from '../../../types/k8s'; export type IntegrationTestFormValues = { @@ -11,6 +11,7 @@ export type IntegrationTestFormValues = { environmentName?: string; environmentType?: string; params?: Param[]; + contexts?: Context[]; }; export enum IntegrationTestAnnotations { 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 58dbe1e..61d98a1 100644 --- a/src/components/IntegrationTests/IntegrationTestForm/utils/__tests__/create-utils.spec.ts +++ b/src/components/IntegrationTests/IntegrationTestForm/utils/__tests__/create-utils.spec.ts @@ -38,8 +38,8 @@ const integrationTestData = { }, contexts: [ { - description: 'Application testing', name: 'application', + description: 'execute the integration test in all cases - this would be the default state', }, ], }, diff --git a/src/components/IntegrationTests/IntegrationTestForm/utils/create-utils.ts b/src/components/IntegrationTests/IntegrationTestForm/utils/create-utils.ts index d603c7c..df7e9d6 100644 --- a/src/components/IntegrationTests/IntegrationTestForm/utils/create-utils.ts +++ b/src/components/IntegrationTests/IntegrationTestForm/utils/create-utils.ts @@ -7,6 +7,7 @@ import { import { IntegrationTestScenarioKind, Param, + Context, ResolverParam, ResolverType, } from '../../../../types/coreBuildService'; @@ -45,12 +46,29 @@ export const formatParams = (params): Param[] => { return newParams.length > 0 ? newParams : null; }; +export const formatContexts = (contexts = [], setDefault = 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)); + 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; +}; + export const editIntegrationTest = ( integrationTest: IntegrationTestScenarioKind, integrationTestValues: IntegrationTestFormValues, dryRun?: boolean, ): Promise => { - const { url, revision, path, optional, environmentName, environmentType, params } = + const { url, revision, path, optional, environmentName, environmentType, params, contexts } = integrationTestValues; const integrationTestResource: IntegrationTestScenarioKind = { ...integrationTest, @@ -79,6 +97,7 @@ export const editIntegrationTest = ( ], }, params: formatParams(params), + contexts: formatContexts(contexts), }, }; @@ -109,7 +128,7 @@ export const createIntegrationTest = ( namespace: string, dryRun?: boolean, ): Promise => { - const { name, url, revision, path, optional, params } = integrationTestValues; + const { name, url, revision, path, optional, params, contexts } = integrationTestValues; const isEC = url === EC_INTEGRATION_TEST_URL && revision === EC_INTEGRATION_TEST_REVISION && @@ -134,12 +153,7 @@ export const createIntegrationTest = ( ], }, params: formatParams(params), - contexts: [ - { - description: 'Application testing', - name: 'application', - }, - ], + contexts: formatContexts(contexts, true), }, }; diff --git a/src/components/IntegrationTests/__tests__/ContextsField.spec.tsx b/src/components/IntegrationTests/__tests__/ContextsField.spec.tsx new file mode 100644 index 0000000..694e1ee --- /dev/null +++ b/src/components/IntegrationTests/__tests__/ContextsField.spec.tsx @@ -0,0 +1,190 @@ +import '@testing-library/jest-dom'; +import { useParams } from 'react-router-dom'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { FieldArray, useField } from 'formik'; +import { useComponents } from '../../../hooks/useComponents'; +import { ComponentKind } from '../../../types'; +import { useWorkspaceInfo } from '../../Workspace/useWorkspaceInfo'; +import ContextsField from '../ContextsField'; +import { contextOptions, mapContextsWithSelection, addComponentContexts } from '../utils'; + +// Mock the hooks used in the component +jest.mock('react-router-dom', () => ({ + useParams: jest.fn(), +})); +jest.mock('../../Workspace/useWorkspaceInfo', () => ({ + useWorkspaceInfo: jest.fn(), +})); +jest.mock('../../../hooks/useComponents', () => ({ + useComponents: jest.fn(), +})); +jest.mock('formik', () => ({ + useField: jest.fn(), + FieldArray: jest.fn(), +})); + +describe('ContextsField', () => { + const mockUseParams = useParams as jest.Mock; + const mockUseWorkspaceInfo = useWorkspaceInfo as jest.Mock; + const mockUseComponents = useComponents as jest.Mock; + const mockUseField = useField as jest.Mock; + const mockUseFieldArray = FieldArray as jest.Mock; + + const fieldName = 'it.contexts'; + + const setupMocks = (selectedContexts = [], components = []) => { + mockUseParams.mockReturnValue({ appName: 'test-app' }); + mockUseWorkspaceInfo.mockReturnValue({ + namespace: 'test-namespace', + workspace: 'test-workspace', + }); + mockUseComponents.mockReturnValue([components, true]); + mockUseField.mockReturnValue([{}, { value: selectedContexts }]); + mockUseFieldArray.mockImplementation((props) => { + const renderFunction = props.render; + return
{renderFunction({ push: jest.fn(), remove: jest.fn() })}
; + }); + }; + + // 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(); + }; + + beforeEach(() => { + // Reset the mocks with no selected contexts or components + setupMocks(); + }); + + it('should render custom header if passed', () => { + mockUseField.mockReturnValue([{}, { value: [] }]); + render(); + expect(screen.getByText('Test Heading')).toBeInTheDocument(); + }); + + it('should allow selecting and deselecting a context', async () => { + const pushMock = jest.fn(); + const removeMock = jest.fn(); + mockUseFieldArray.mockImplementation((props) => { + const renderFunction = props.render; + return
{renderFunction({ push: pushMock, remove: removeMock })}
; + }); + + // This will be our previously selected contextOption. + const selectedContext = { + name: contextOptions[0].name, + description: contextOptions[0].description, + }; + mockUseField.mockReturnValue([{}, { value: [selectedContext] }]); + + render(); + + await openDropdown(); + testContextOption(contextOptions[0].name); + + await act(async () => { + fireEvent.click(screen.getByTestId(`context-option-${contextOptions[0].name}`).childNodes[0]); + }); + + // Ensure deselecting + expect(removeMock).toHaveBeenCalledWith(0); + + // Simulate selecting another context + await act(async () => { + fireEvent.click(screen.getByTestId(`context-option-${contextOptions[1].name}`).childNodes[0]); + }); + + // Ensure selecting + expect(pushMock).toHaveBeenCalledWith({ + name: contextOptions[1].name, + description: contextOptions[1].description, + }); + }); + + it('should render additional component contexts when components are loaded', async () => { + const mockComponents = [ + { metadata: { name: 'componentA' } }, + { metadata: { name: 'componentB' } }, + ]; + setupMocks([], mockComponents); + + render(); + + await openDropdown(); + + // Names should be visible as a menu option + ['componentA', 'componentB'].forEach((componentName) => { + testContextOption(`component_${componentName}`); + }); + // Descriptions should also be visible + expect(screen.getByText('execute the integration test when component componentA updates')); + expect(screen.getByText('execute the integration test when component componentB updates')); + }); + + it('should have applications pre-set as a default context when creating a new integration test', async () => { + mockUseField.mockReturnValue([{}, { value: [] }]); + 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(); + testContextOption('application'); + expect(screen.getByTestId('context-option-application').childNodes[0]).toBeDisabled(); + }); +}); + +describe('mapContextsWithSelection', () => { + it('gets the previously selected contexts and marks them selected', () => { + const testNames = new Set(['group', 'component', 'push']); + const selectedNames = contextOptions + .filter((ctx) => testNames.has(ctx.name)) + .map((ctx) => ctx.name); + + const result = mapContextsWithSelection(selectedNames, contextOptions); + selectedNames.forEach((name) => { + const selectedContext = result.find((ctx) => ctx.name === name); + expect(selectedContext.selected).toBeTruthy(); + }); + }); +}); + +describe('addComponentContexts', () => { + it('gets the previously selected components, adds them to context options and updates selected value', () => { + const selectedComponentNames = ['component_componentA', 'component_componentC']; + + const components = [ + { metadata: { name: 'componentA' } }, + { metadata: { name: 'componentB' } }, + { metadata: { name: 'componentC' } }, + ]; + + const result = addComponentContexts( + contextOptions, + selectedComponentNames, + components as ComponentKind[], + ); + selectedComponentNames.forEach((name) => { + const selectedComponentContext = result.find((ctx) => ctx.name === name); + expect(selectedComponentContext.selected).toBeTruthy(); + }); + + // This component was not previously selected or saved, even with the 'component_' prefix added + // so it should not be marked as selected. + const unselectedComponent = result.find((ctx) => ctx.name === 'component_componentB'); + expect(unselectedComponent.selected).toBeFalsy(); + }); +}); diff --git a/src/components/IntegrationTests/__tests__/ContextsSelectList.spec.tsx b/src/components/IntegrationTests/__tests__/ContextsSelectList.spec.tsx new file mode 100644 index 0000000..bce05ca --- /dev/null +++ b/src/components/IntegrationTests/__tests__/ContextsSelectList.spec.tsx @@ -0,0 +1,154 @@ +import { render, fireEvent, screen, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ContextSelectList } from '../ContextSelectList'; +import { ContextOption } from '../utils'; + +describe('ContextSelectList Component', () => { + const defaultProps = { + allContexts: [ + { name: 'application', description: 'Test context application', selected: true }, + { 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: true }, + { 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(), + editing: true, + }; + + 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}`); + }; + + const getChipButton = (name: string) => { + return screen.getByTestId(`context-chip-${name}`).childNodes[1].childNodes[0]; + }; + + it('renders correctly', () => { + render(); + expect(screen.getByPlaceholderText('Select a context')).toBeInTheDocument(); + expect(screen.getByTestId('context-dropdown-toggle')).toBeInTheDocument(); + }); + + it('displays selected contexts as chips', () => { + render(); + const appChip = getChip('application'); + expect(appChip).toBeInTheDocument(); + expect(appChip).toHaveTextContent('application'); + // If the context was not previously selected and saved, + // it should not have a chip. + expect(screen.queryByTestId('context-chip-component')).not.toBeInTheDocument(); + expect(screen.queryByTestId('context-chip-group')).not.toBeInTheDocument(); + }); + + it('calls onSelect when a chip is clicked', async () => { + render(); + const appChip = getChipButton('application'); + await act(async () => { + fireEvent.click(appChip); + }); + expect(defaultProps.onSelect).toHaveBeenCalledWith('application'); + }); + + it('updates input value on typing', async () => { + render(); + const input = screen.getByPlaceholderText('Select a context'); + await act(async () => { + fireEvent.change(input, { target: { value: 'new context' } }); + }); + expect(defaultProps.onInputValueChange).toHaveBeenCalledWith('new context'); + }); + + it('opens the dropdown when clicking the toggle', async () => { + render(); + await openDropdown(); + expect(getContextOption('application')).toBeInTheDocument(); + }); + + it('calls onSelect when a context option is clicked', async () => { + render(); + await openDropdown(); + const groupOption = getContextOptionButton('group'); + await act(async () => { + fireEvent.click(groupOption); + }); + expect(defaultProps.onSelect).toHaveBeenCalledWith('group'); + }); + + it('calls onRemoveAll when clear button is clicked', async () => { + render(); + const clearButton = screen.getByTestId('clear-button'); + 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 act(async () => fireEvent.click(componentOption)); + expect(defaultProps.onSelect).toHaveBeenCalledWith('component'); + }); + + it('should focus on the next item when ArrowDown is pressed', async () => { + render(); + const input = screen.getByTestId('multi-typeahead-select-input'); + await act(async () => { + fireEvent.keyDown(input, { key: 'ArrowDown' }); + }); + // We should expect the focus class to have been appended to this context option + expect(getContextOption('application')).toHaveClass('pf-m-focus'); + // All other values should not be in focus + defaultProps.filteredContexts + .filter((ctx) => ctx.name !== 'application') + .forEach((ctx) => { + expect(getContextOption(ctx.name)).not.toHaveClass('pf-m-focus'); + }); + }); + + it('should focus on the previous item when ArrowUp is pressed', async () => { + render(); + const input = screen.getByTestId('multi-typeahead-select-input'); + await act(async () => { + fireEvent.keyDown(input, { key: 'ArrowUp' }); + }); + // Since we're going up the list, the last value should be focused. + expect(getContextOption('group')).toHaveClass('pf-m-focus'); + // All other values should not be in focus + defaultProps.filteredContexts + .filter((ctx) => ctx.name !== 'group') + .forEach((ctx) => { + expect(getContextOption(ctx.name)).not.toHaveClass('pf-m-focus'); + }); + }); +}); diff --git a/src/components/IntegrationTests/utils.tsx b/src/components/IntegrationTests/utils.tsx new file mode 100644 index 0000000..70a31cc --- /dev/null +++ b/src/components/IntegrationTests/utils.tsx @@ -0,0 +1,97 @@ +import { ComponentKind } from '../../types'; + +export interface ContextOption { + name: string; + description: string; + selected: boolean; +} + +// Base context options list. +export const contextOptions: ContextOption[] = [ + { + name: 'application', + description: 'execute the integration test in all cases - this would be the default state', + selected: false, + }, + { + name: 'group', + description: 'execute the integration test for a Snapshot of the `group` type', + selected: false, + }, + { + name: 'override', + description: 'execute the integration test for a Snapshot of the `override` type', + selected: false, + }, + { + name: 'component', + description: 'execute the integration test for a Snapshot of the `component` type', + selected: false, + }, + { + name: 'pull_request', + description: 'execute the integration test for a Snapshot created for a `pull request` event', + selected: false, + }, + { + name: 'push', + description: 'execute the integration test for a Snapshot created for a `push` event', + selected: false, + }, + { + name: 'disabled', + description: + 'disables the execution of the given integration test if it’s the only context that’s defined', + selected: false, + }, +]; + +/** + * 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. + * + * @param {string[]} selectedContextNames - Array of context names that should be marked as selected. + * @param {ContextOption[]} contextSelectOptions - Array of context objects that include details such as name and description. + * @returns {ContextOption[]} - An array of context objects where each context has an additional `selected` field indicating if it's selected. + */ +export const mapContextsWithSelection = ( + selectedContextNames: string[], + contextSelectOptions: ContextOption[], +): ContextOption[] => { + return contextSelectOptions.map((ctx) => { + return { ...ctx, selected: selectedContextNames.some((name) => name === ctx.name) }; + }); +}; + +/** + * Combines existing selected contexts with new component-based contexts. + * + * This function creates new context options for each component, including a description and + * a `selected` flag based on whether the component is present in the selected context names. + * It then merges these component contexts with the provided base selected contexts. + * + * @param {ContextOption[]} baseSelectedContexts - Array of already selected context objects to be combined with new component contexts. + * @param {string[]} selectedContextNames - Array of names that represent the currently selected contexts, including component contexts. + * @param {ComponentKind[]} components - Array of components from which new context options will be created. + * + * @returns {ContextOption[]} - An array combining the base selected contexts and the newly generated component contexts, each having a `selected` field. + */ +export const addComponentContexts = ( + baseSelectedContexts: ContextOption[], + selectedContextNames: string[], + components: ComponentKind[], +) => { + const selectedContextNameSet = new Set(selectedContextNames); // Faster lookups + const componentContexts = components.map((component) => { + const componentName = component.metadata.name; + return { + // if a component context has been previously selected, it should be prefixed with 'component_' + // after it's been selected and saved. + name: `component_${componentName}`, + description: `execute the integration test when component ${componentName} updates`, + selected: selectedContextNameSet.has(`component_${componentName}`), // Check if it's selected + }; + }); + + return [...baseSelectedContexts, ...componentContexts]; +};