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 Nov 19, 2024
1 parent 4d0c39a commit c6f1dba
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;
};

export const ContextSelectList: React.FC<ContextSelectListProps> = ({
allContexts,
filteredContexts,
onSelect,
onRemoveAll,
inputValue,
onInputValueChange,
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
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<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);
}
};

// 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);
// 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 (
<FormGroup fieldId={fieldId} label={heading ?? 'Contexts'} style={{ maxWidth: '750px' }}>
{componentsLoaded && components ? (
<FieldArray
name={fieldName}
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;
}

const getFormContextValus = (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: getFormContextValus(integrationTest?.spec?.contexts),
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(() => ({
appName: 'test-app',
})),
}));

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

0 comments on commit c6f1dba

Please sign in to comment.