Skip to content

Commit e3df816

Browse files
committed
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.
1 parent 4d0c39a commit e3df816

12 files changed

+827
-11
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import React, { useState } from 'react';
2+
import {
3+
Select,
4+
SelectOption,
5+
SelectList,
6+
MenuToggle,
7+
MenuToggleElement,
8+
ChipGroup,
9+
Chip,
10+
TextInputGroup,
11+
TextInputGroupMain,
12+
TextInputGroupUtilities,
13+
Button,
14+
} from '@patternfly/react-core';
15+
import { TimesIcon } from '@patternfly/react-icons/dist/esm/icons/times-icon';
16+
import { ContextOption } from './utils';
17+
18+
type ContextSelectListProps = {
19+
allContexts: ContextOption[];
20+
filteredContexts: ContextOption[];
21+
onSelect: (contextName: string) => void;
22+
inputValue: string;
23+
onInputValueChange: (value: string) => void;
24+
onRemoveAll: () => void;
25+
editing: boolean;
26+
};
27+
28+
export const ContextSelectList: React.FC<ContextSelectListProps> = ({
29+
allContexts,
30+
filteredContexts,
31+
onSelect,
32+
onRemoveAll,
33+
inputValue,
34+
onInputValueChange,
35+
editing,
36+
}) => {
37+
const [isOpen, setIsOpen] = useState(false);
38+
const [focusedItemIndex, setFocusedItemIndex] = useState<number | null>(null);
39+
const [activeItemId, setActiveItemId] = React.useState<string | null>(null);
40+
const textInputRef = React.useRef<HTMLInputElement>();
41+
42+
const NO_RESULTS = 'No results found';
43+
44+
// Open the dropdown if the input value changes
45+
React.useEffect(() => {
46+
if (inputValue) {
47+
setIsOpen(true);
48+
}
49+
}, [inputValue]);
50+
51+
// Utility function to create a unique item ID based on the context value
52+
const createItemId = (value: string) => `select-multi-typeahead-${value.replace(' ', '-')}`;
53+
54+
// Set both the focused and active item for keyboard navigation
55+
const setActiveAndFocusedItem = (itemIndex: number) => {
56+
setFocusedItemIndex(itemIndex);
57+
const focusedItem = filteredContexts[itemIndex];
58+
setActiveItemId(createItemId(focusedItem.name));
59+
};
60+
61+
// Reset focused and active items when the dropdown is closed or input is cleared
62+
const resetActiveAndFocusedItem = () => {
63+
setFocusedItemIndex(null);
64+
setActiveItemId(null);
65+
};
66+
67+
// Close the dropdown menu and reset focus states
68+
const closeMenu = () => {
69+
setIsOpen(false);
70+
resetActiveAndFocusedItem();
71+
};
72+
73+
// Handle the input field click event to toggle the dropdown
74+
const onInputClick = () => {
75+
if (!isOpen) {
76+
setIsOpen(true);
77+
} else if (!inputValue) {
78+
closeMenu();
79+
}
80+
};
81+
82+
// Gets the index of the next element we want to focus on, based on the length of
83+
// the filtered contexts and the arrow key direction.
84+
const getNextFocusedIndex = (
85+
currentIndex: number | null,
86+
length: number,
87+
direction: 'up' | 'down',
88+
) => {
89+
if (direction === 'up') {
90+
return currentIndex === null || currentIndex === 0 ? length - 1 : currentIndex - 1;
91+
}
92+
return currentIndex === null || currentIndex === length - 1 ? 0 : currentIndex + 1;
93+
};
94+
95+
// Handle up/down arrow key navigation for the dropdown
96+
const handleMenuArrowKeys = (key: string) => {
97+
// If we're pressing the arrow keys, make sure the list is open.
98+
if (!isOpen) {
99+
setIsOpen(true);
100+
}
101+
const direction = key === 'ArrowUp' ? 'up' : 'down';
102+
const indexToFocus = getNextFocusedIndex(focusedItemIndex, filteredContexts.length, direction);
103+
setActiveAndFocusedItem(indexToFocus);
104+
};
105+
106+
// Handle keydown events in the input field (e.g., Enter, Arrow keys)
107+
const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
108+
const focusedItem = focusedItemIndex !== null ? filteredContexts[focusedItemIndex] : null;
109+
110+
if (event.key === 'Enter' && focusedItem && focusedItem.name !== NO_RESULTS) {
111+
onSelect(focusedItem.name);
112+
}
113+
114+
if (['ArrowUp', 'ArrowDown'].includes(event.key)) {
115+
handleMenuArrowKeys(event.key);
116+
}
117+
};
118+
119+
// Handle selection of a context from the dropdown
120+
const handleSelect = (value: string) => {
121+
onSelect(value);
122+
textInputRef.current?.focus();
123+
};
124+
125+
// Toggle the dropdown open/closed
126+
const onToggleClick = () => {
127+
setIsOpen(!isOpen);
128+
textInputRef?.current?.focus();
129+
};
130+
131+
// Handle changes to the input field value
132+
const onTextInputChange = (_event: React.FormEvent<HTMLInputElement>, value: string) => {
133+
// Update input value
134+
onInputValueChange(value);
135+
resetActiveAndFocusedItem();
136+
};
137+
138+
const renderToggle = (toggleRef: React.Ref<MenuToggleElement>) => (
139+
<MenuToggle
140+
variant="typeahead"
141+
aria-label="Multi typeahead menu toggle"
142+
onClick={onToggleClick}
143+
innerRef={toggleRef}
144+
isExpanded={isOpen}
145+
style={{ minWidth: '750px' } as React.CSSProperties}
146+
data-test="context-dropdown-toggle"
147+
>
148+
<TextInputGroup isPlain>
149+
<TextInputGroupMain
150+
value={inputValue}
151+
onChange={onTextInputChange}
152+
onClick={onInputClick}
153+
onKeyDown={onInputKeyDown}
154+
data-test="multi-typeahead-select-input"
155+
id="multi-typeahead-select-input"
156+
autoComplete="off"
157+
innerRef={textInputRef}
158+
placeholder="Select a context"
159+
{...(activeItemId && { 'aria-activedescendant': activeItemId })}
160+
role="combobox"
161+
isExpanded={isOpen}
162+
aria-controls="select-multi-typeahead-listbox"
163+
>
164+
<ChipGroup>
165+
{allContexts
166+
.filter((ctx) => ctx.selected)
167+
.map((ctx) => (
168+
<Chip
169+
key={ctx.name}
170+
onClick={() => handleSelect(ctx.name)}
171+
data-test={`context-chip-${ctx.name}`}
172+
>
173+
{ctx.name}
174+
</Chip>
175+
))}
176+
</ChipGroup>
177+
</TextInputGroupMain>
178+
{filteredContexts.some((ctx) => ctx.selected) && (
179+
<TextInputGroupUtilities>
180+
<Button variant="plain" onClick={onRemoveAll} data-test={'clear-button'}>
181+
<TimesIcon aria-hidden />
182+
</Button>
183+
</TextInputGroupUtilities>
184+
)}
185+
</TextInputGroup>
186+
</MenuToggle>
187+
);
188+
189+
return (
190+
<Select
191+
isOpen={isOpen}
192+
onSelect={(_event, value) => handleSelect(value as string)}
193+
onOpenChange={closeMenu}
194+
style={{ maxWidth: '750px' } as React.CSSProperties}
195+
toggle={renderToggle}
196+
>
197+
<SelectList id="select-multi-typeahead-listbox" data-test={'context-option-select-list'}>
198+
{filteredContexts.map((ctx, idx) => (
199+
<SelectOption
200+
id={ctx.name}
201+
key={ctx.name}
202+
isFocused={focusedItemIndex === idx}
203+
value={ctx.name}
204+
isSelected={ctx.selected}
205+
description={ctx.description}
206+
ref={null}
207+
isDisabled={!editing && ctx.name === 'application'}
208+
data-test={`context-option-${ctx.name}`}
209+
>
210+
{ctx.name}
211+
</SelectOption>
212+
))}
213+
</SelectList>
214+
</Select>
215+
);
216+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import * as React from 'react';
2+
import { useParams } from 'react-router-dom';
3+
import { FormGroup } from '@patternfly/react-core';
4+
import { FieldArray, useField, FieldArrayRenderProps } from 'formik';
5+
import { getFieldId } from '../../../src/shared/components/formik-fields/field-utils';
6+
import { useComponents } from '../../hooks/useComponents';
7+
import { useWorkspaceInfo } from '../Workspace/useWorkspaceInfo';
8+
import { ContextSelectList } from './ContextSelectList';
9+
import {
10+
ContextOption,
11+
contextOptions,
12+
mapContextsWithSelection,
13+
addComponentContexts,
14+
} from './utils';
15+
16+
interface IntegrationTestContextProps {
17+
heading?: React.ReactNode;
18+
fieldName: string;
19+
editing: boolean;
20+
}
21+
22+
const ContextsField: React.FC<IntegrationTestContextProps> = ({ heading, fieldName, editing }) => {
23+
const { namespace, workspace } = useWorkspaceInfo();
24+
const { applicationName } = useParams();
25+
const [components, componentsLoaded] = useComponents(namespace, workspace, applicationName);
26+
const [, { value: contexts }] = useField(fieldName);
27+
const fieldId = getFieldId(fieldName, 'dropdown');
28+
const [inputValue, setInputValue] = React.useState('');
29+
30+
// The names of the existing selected contexts.
31+
const selectedContextNames: string[] = (contexts ?? []).map((c: ContextOption) => c.name);
32+
// All the context options available to the user.
33+
const allContexts = React.useMemo(() => {
34+
let initialSelectedContexts = mapContextsWithSelection(selectedContextNames, contextOptions);
35+
// If this is a new integration test, ensure that 'application' is selected by default
36+
if (!editing && !selectedContextNames.includes('application')) {
37+
initialSelectedContexts = initialSelectedContexts.map((ctx) => {
38+
return ctx.name === 'application' ? { ...ctx, selected: true } : ctx;
39+
});
40+
}
41+
42+
// If we have components and they are loaded, add to context option list.
43+
// Else, return the base context list.
44+
return componentsLoaded && components
45+
? addComponentContexts(initialSelectedContexts, selectedContextNames, components)
46+
: initialSelectedContexts;
47+
}, [componentsLoaded, components, selectedContextNames, editing]);
48+
49+
// This holds the contexts that are filtered using the user input value.
50+
const filteredContexts = React.useMemo(() => {
51+
if (inputValue) {
52+
const filtered = allContexts.filter((ctx) =>
53+
ctx.name.toLowerCase().includes(inputValue.toLowerCase()),
54+
);
55+
return filtered.length
56+
? filtered
57+
: [{ name: 'No results found', description: 'Please try another value.', selected: false }];
58+
}
59+
return allContexts;
60+
}, [inputValue, allContexts]);
61+
62+
/**
63+
* React callback that is used to select or deselect a context option using Formik FieldArray array helpers.
64+
* If the context exists and it's been selected, remove from array.
65+
* Else push to the Formik FieldArray array.
66+
*/
67+
const handleSelect = React.useCallback(
68+
(arrayHelpers: FieldArrayRenderProps, contextName: string) => {
69+
const currentContext: ContextOption = allContexts.find(
70+
(ctx: ContextOption) => ctx.name === contextName,
71+
);
72+
const isSelected = currentContext && currentContext.selected;
73+
const index: number = contexts.findIndex((c: ContextOption) => c.name === contextName);
74+
75+
if (isSelected && index !== -1) {
76+
arrayHelpers.remove(index); // Deselect
77+
} else if (!isSelected) {
78+
// Select, add necessary data
79+
arrayHelpers.push({ name: contextName, description: currentContext.description });
80+
}
81+
},
82+
[contexts, allContexts],
83+
);
84+
85+
// Handles unselecting all the contexts
86+
const handleRemoveAll = async (arrayHelpers: FieldArrayRenderProps) => {
87+
// Clear all selections
88+
await arrayHelpers.form.setFieldValue(fieldName, []);
89+
};
90+
91+
return (
92+
<FormGroup fieldId={fieldId} label={heading ?? 'Contexts'} style={{ maxWidth: '750px' }}>
93+
{componentsLoaded && components ? (
94+
<FieldArray
95+
name={fieldName}
96+
render={(arrayHelpers) => (
97+
<ContextSelectList
98+
allContexts={allContexts}
99+
filteredContexts={filteredContexts}
100+
onSelect={(contextName: string) => handleSelect(arrayHelpers, contextName)}
101+
inputValue={inputValue}
102+
onInputValueChange={setInputValue}
103+
onRemoveAll={() => handleRemoveAll(arrayHelpers)}
104+
editing={editing}
105+
/>
106+
)}
107+
/>
108+
) : (
109+
'Loading Additional Component Context options'
110+
)}
111+
</FormGroup>
112+
);
113+
};
114+
115+
export default ContextsField;

src/components/IntegrationTests/IntegrationTestForm/IntegrationTestSection.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { useField } from 'formik';
1010
import { CheckboxField, InputField } from 'formik-pf';
1111
import { RESOURCE_NAME_REGEX_MSG } from '../../../utils/validation-utils';
12+
import ContextsField from '../ContextsField';
1213
import FormikParamsField from '../FormikParamsField';
1314

1415
type Props = { isInPage?: boolean; edit?: boolean };
@@ -68,6 +69,7 @@ const IntegrationTestSection: React.FC<React.PropsWithChildren<Props>> = ({ isIn
6869
data-test="git-path-repo"
6970
required
7071
/>
72+
<ContextsField fieldName="integrationTest.contexts" editing={edit} />
7173
<FormikParamsField fieldName="integrationTest.params" />
7274

7375
<CheckboxField

src/components/IntegrationTests/IntegrationTestForm/IntegrationTestView.tsx

+15-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22
import { useNavigate } from 'react-router-dom';
33
import { Formik } from 'formik';
4-
import { IntegrationTestScenarioKind } from '../../../types/coreBuildService';
4+
import { IntegrationTestScenarioKind, Context } from '../../../types/coreBuildService';
55
import { useTrackEvent, TrackEvents } from '../../../utils/analytics';
66
import { useWorkspaceInfo } from '../../Workspace/useWorkspaceInfo';
77
import IntegrationTestForm from './IntegrationTestForm';
@@ -52,13 +52,27 @@ const IntegrationTestView: React.FunctionComponent<
5252
return formParams;
5353
};
5454

55+
interface FormContext {
56+
name: string;
57+
description: string;
58+
}
59+
60+
const getFormContextValus = (contexts: Context[] | null | undefined): FormContext[] => {
61+
if (!contexts?.length) return [];
62+
63+
return contexts.map((context) => {
64+
return context.name ? { name: context.name, description: context.description } : context;
65+
});
66+
};
67+
5568
const initialValues = {
5669
integrationTest: {
5770
name: integrationTest?.metadata.name ?? '',
5871
url: url?.value ?? '',
5972
revision: revision?.value ?? '',
6073
path: path?.value ?? '',
6174
params: getFormParamValues(integrationTest?.spec?.params),
75+
contexts: getFormContextValus(integrationTest?.spec?.contexts),
6276
optional:
6377
integrationTest?.metadata.labels?.[IntegrationTestLabels.OPTIONAL] === 'true' ?? false,
6478
},

src/components/IntegrationTests/IntegrationTestForm/__tests__/IntegrationTestSection.spec.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ const navigateMock = jest.fn();
77
jest.mock('react-router-dom', () => ({
88
Link: (props) => <a href={props.to}>{props.children}</a>,
99
useNavigate: () => navigateMock,
10+
// Used in ContextsField
11+
useParams: jest.fn(() => ({
12+
applicationName: 'test-app',
13+
})),
1014
}));
1115

1216
jest.mock('react-i18next', () => ({

0 commit comments

Comments
 (0)