Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(KFLUXUI-191): allow users to set context #25

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 { 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 (
<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