Skip to content

Commit

Permalink
feat(KFLUXUI-191): allow users to set context
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
CryptoRodeo committed Dec 2, 2024
1 parent 850c442 commit c4fe4cc
Show file tree
Hide file tree
Showing 12 changed files with 827 additions and 11 deletions.
216 changes: 216 additions & 0 deletions src/components/IntegrationTests/ContextSelectList.tsx
Original file line number Diff line number Diff line change
@@ -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;

Check warning on line 25 in src/components/IntegrationTests/ContextSelectList.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/IntegrationTests/ContextSelectList.tsx#L25

Added line #L25 was not covered by tests
};

export const ContextSelectList: React.FC<ContextSelectListProps> = ({

Check warning on line 28 in src/components/IntegrationTests/ContextSelectList.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/IntegrationTests/ContextSelectList.tsx#L28

Added line #L28 was not covered by tests
allContexts,
filteredContexts,
onSelect,
onRemoveAll,
inputValue,
onInputValueChange,

Check warning on line 34 in src/components/IntegrationTests/ContextSelectList.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/IntegrationTests/ContextSelectList.tsx#L34

Added line #L34 was not covered by tests
editing,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [focusedItemIndex, setFocusedItemIndex] = useState<number | null>(null);
const [activeItemId, setActiveItemId] = React.useState<string | null>(null);
const textInputRef = React.useRef<HTMLInputElement>();

const NO_RESULTS = 'No results found';

// Open the dropdown if the input value changes

Check warning on line 44 in src/components/IntegrationTests/ContextSelectList.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/IntegrationTests/ContextSelectList.tsx#L44

Added line #L44 was not covered by tests
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);
};

Check warning on line 65 in src/components/IntegrationTests/ContextSelectList.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/IntegrationTests/ContextSelectList.tsx#L65

Added line #L65 was not covered by tests

// 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,

Check warning on line 86 in src/components/IntegrationTests/ContextSelectList.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/IntegrationTests/ContextSelectList.tsx#L85-L86

Added lines #L85 - L86 were not covered by tests
direction: 'up' | 'down',
) => {
if (direction === 'up') {
return currentIndex === null || currentIndex === 0 ? length - 1 : currentIndex - 1;
}
return currentIndex === null || currentIndex === length - 1 ? 0 : currentIndex + 1;
};

Check warning on line 93 in src/components/IntegrationTests/ContextSelectList.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/IntegrationTests/ContextSelectList.tsx#L93

Added line #L93 was not covered by tests

// 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<HTMLInputElement>) => {
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);
}
};

Check warning on line 118 in src/components/IntegrationTests/ContextSelectList.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/IntegrationTests/ContextSelectList.tsx#L118

Added line #L118 was not covered by tests
// 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<HTMLInputElement>, value: string) => {
// Update input value
onInputValueChange(value);
resetActiveAndFocusedItem();
};

const renderToggle = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
variant="typeahead"
aria-label="Multi typeahead menu toggle"
onClick={onToggleClick}
innerRef={toggleRef}
isExpanded={isOpen}
style={{ minWidth: '750px' } as React.CSSProperties}
data-test="context-dropdown-toggle"
>
<TextInputGroup isPlain>
<TextInputGroupMain
value={inputValue}
onChange={onTextInputChange}
onClick={onInputClick}
onKeyDown={onInputKeyDown}
data-test="multi-typeahead-select-input"
id="multi-typeahead-select-input"
autoComplete="off"
innerRef={textInputRef}
placeholder="Select a context"
{...(activeItemId && { 'aria-activedescendant': activeItemId })}
role="combobox"
isExpanded={isOpen}
aria-controls="select-multi-typeahead-listbox"
>
<ChipGroup>
{allContexts
.filter((ctx) => ctx.selected)
.map((ctx) => (
<Chip
key={ctx.name}
onClick={() => handleSelect(ctx.name)}
data-test={`context-chip-${ctx.name}`}
>
{ctx.name}
</Chip>
))}
</ChipGroup>
</TextInputGroupMain>
{filteredContexts.some((ctx) => ctx.selected) && (
<TextInputGroupUtilities>
<Button variant="plain" onClick={onRemoveAll} data-test={'clear-button'}>
<TimesIcon aria-hidden />
</Button>
</TextInputGroupUtilities>
)}
</TextInputGroup>
</MenuToggle>
);

return (
<Select
isOpen={isOpen}
onSelect={(_event, value) => handleSelect(value as string)}
onOpenChange={closeMenu}
style={{ maxWidth: '750px' } as React.CSSProperties}
toggle={renderToggle}
>
<SelectList id="select-multi-typeahead-listbox" data-test={'context-option-select-list'}>
{filteredContexts.map((ctx, idx) => (
<SelectOption
id={ctx.name}
key={ctx.name}
isFocused={focusedItemIndex === idx}
value={ctx.name}
isSelected={ctx.selected}
description={ctx.description}
ref={null}
isDisabled={!editing && ctx.name === 'application'}
data-test={`context-option-${ctx.name}`}
>
{ctx.name}
</SelectOption>
))}
</SelectList>
</Select>
);
};
115 changes: 115 additions & 0 deletions src/components/IntegrationTests/ContextsField.tsx
Original file line number Diff line number Diff line change
@@ -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<IntegrationTestContextProps> = ({ heading, fieldName, editing }) => {
const { namespace, workspace } = useWorkspaceInfo();
const { applicationName } = useParams();
const [components, componentsLoaded] = useComponents(namespace, workspace, applicationName);
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);

Check warning on line 31 in src/components/IntegrationTests/ContextsField.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/IntegrationTests/ContextsField.tsx#L31

Added line #L31 was not covered by tests
// All the context options available to the user.
const allContexts = React.useMemo(() => {
let initialSelectedContexts = mapContextsWithSelection(selectedContextNames, contextOptions);

Check warning on line 34 in src/components/IntegrationTests/ContextsField.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/IntegrationTests/ContextsField.tsx#L34

Added line #L34 was not covered by tests
// 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;
});
}

Check warning on line 40 in src/components/IntegrationTests/ContextsField.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/IntegrationTests/ContextsField.tsx#L40

Added line #L40 was not covered by tests

// 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(() => {

Check warning on line 50 in src/components/IntegrationTests/ContextsField.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/IntegrationTests/ContextsField.tsx#L50

Added line #L50 was not covered by tests
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 (
<FormGroup fieldId={fieldId} label={heading ?? 'Contexts'} style={{ maxWidth: '750px' }}>
{componentsLoaded && components ? (
<FieldArray
name={fieldName}

Check warning on line 95 in src/components/IntegrationTests/ContextsField.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/IntegrationTests/ContextsField.tsx#L95

Added line #L95 was not covered by tests
render={(arrayHelpers) => (
<ContextSelectList
allContexts={allContexts}
filteredContexts={filteredContexts}
onSelect={(contextName: string) => handleSelect(arrayHelpers, contextName)}
inputValue={inputValue}
onInputValueChange={setInputValue}
onRemoveAll={() => handleRemoveAll(arrayHelpers)}
editing={editing}
/>
)}
/>
) : (
'Loading Additional Component Context options'
)}
</FormGroup>
);
};

export default ContextsField;
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -68,6 +69,7 @@ const IntegrationTestSection: React.FC<React.PropsWithChildren<Props>> = ({ isIn
data-test="git-path-repo"
required
/>
<ContextsField fieldName="integrationTest.contexts" editing={edit} />
<FormikParamsField fieldName="integrationTest.params" />

<CheckboxField
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Formik } from 'formik';
import { IntegrationTestScenarioKind } from '../../../types/coreBuildService';
import { IntegrationTestScenarioKind, Context } from '../../../types/coreBuildService';
import { useTrackEvent, TrackEvents } from '../../../utils/analytics';
import { useWorkspaceInfo } from '../../Workspace/useWorkspaceInfo';
import IntegrationTestForm from './IntegrationTestForm';
Expand Down Expand Up @@ -52,13 +52,27 @@ const IntegrationTestView: React.FunctionComponent<
return formParams;
};

interface FormContext {
name: string;
description: string;
}

Check warning on line 58 in src/components/IntegrationTests/IntegrationTestForm/IntegrationTestView.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/IntegrationTests/IntegrationTestForm/IntegrationTestView.tsx#L58

Added line #L58 was not covered by tests

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 ?? '',
url: url?.value ?? '',
revision: revision?.value ?? '',
path: path?.value ?? '',
params: getFormParamValues(integrationTest?.spec?.params),
contexts: getFormContextValues(integrationTest?.spec?.contexts),

Check warning on line 75 in src/components/IntegrationTests/IntegrationTestForm/IntegrationTestView.tsx

View check run for this annotation

Codecov / codecov/patch

src/components/IntegrationTests/IntegrationTestForm/IntegrationTestView.tsx#L75

Added line #L75 was not covered by tests
optional:
integrationTest?.metadata.labels?.[IntegrationTestLabels.OPTIONAL] === 'true' ?? false,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ const navigateMock = jest.fn();
jest.mock('react-router-dom', () => ({
Link: (props) => <a href={props.to}>{props.children}</a>,
useNavigate: () => navigateMock,
// Used in ContextsField
useParams: jest.fn(() => ({
applicationName: 'test-app',
})),
}));

jest.mock('react-i18next', () => ({
Expand Down
Loading

0 comments on commit c4fe4cc

Please sign in to comment.