Skip to content

Commit

Permalink
Merge pull request #25 from CryptoRodeo/kfluxui-191
Browse files Browse the repository at this point in the history
feat(KFLUXUI-191): allow users to set context
  • Loading branch information
sahil143 authored Dec 3, 2024
2 parents 8297d54 + 9ab84de commit e7df589
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 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),
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 e7df589

Please sign in to comment.