-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
1 parent
4d0c39a
commit c6f1dba
Showing
12 changed files
with
827 additions
and
11 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.