-
Notifications
You must be signed in to change notification settings - Fork 29
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(1055): Added TypeaheadEditor and reusing it with DataFormatEditor…
…, ExpressionEditor, and LoadBalancerEditor
- Loading branch information
1 parent
5108ddf
commit 7d83698
Showing
9 changed files
with
489 additions
and
698 deletions.
There are no files selected for viewing
113 changes: 113 additions & 0 deletions
113
packages/ui/src/components/Form/customField/TypeaheadEditor.test.tsx
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,113 @@ | ||
import { SelectOptionProps } from '@patternfly/react-core'; | ||
import { fireEvent, render, screen } from '@testing-library/react'; | ||
import { act } from 'react-dom/test-utils'; | ||
import { TypeaheadEditor } from './TypeaheadEditor'; | ||
|
||
describe('TypeaheadField', () => { | ||
const initialDataFormatOptions: SelectOptionProps[] = [ | ||
{ | ||
value: 'asn1', | ||
children: 'ASN.1 File', | ||
description: 'Encode and decode data structures using Abstract Syntax Notation One (ASN.1).', | ||
}, | ||
{ | ||
value: 'avro', | ||
children: 'Avro', | ||
description: 'Serialize and deserialize messages using Apache Avro binary data format.', | ||
}, | ||
{ | ||
value: 'barcode', | ||
children: 'Barcode', | ||
description: 'Transform strings to various 1D/2D barcode bitmap formats and back.', | ||
}, | ||
{ value: 'base64', children: 'Base64', description: 'Encode and decode data using Base64.' }, | ||
]; | ||
|
||
const mockOnChange = jest.fn(); | ||
|
||
beforeEach(() => { | ||
mockOnChange.mockClear(); | ||
}); | ||
|
||
it('should render the component', () => { | ||
render( | ||
<TypeaheadEditor | ||
selectOptions={initialDataFormatOptions} | ||
title="Test" | ||
selected={undefined} | ||
selectedModel={undefined} | ||
selectedSchema={undefined} | ||
selectionOnChange={mockOnChange} | ||
/>, | ||
); | ||
const inputElement = screen.getByRole('combobox'); | ||
expect(inputElement).toBeInTheDocument(); | ||
}); | ||
|
||
it('should display the options when the input is clicked', async () => { | ||
render( | ||
<TypeaheadEditor | ||
selectOptions={initialDataFormatOptions} | ||
title="Test" | ||
selected={undefined} | ||
selectedModel={undefined} | ||
selectedSchema={undefined} | ||
selectionOnChange={mockOnChange} | ||
/>, | ||
); | ||
const inputElement = screen.getByRole('combobox'); | ||
await act(async () => { | ||
fireEvent.click(inputElement); | ||
}); | ||
const optionElements = screen.getAllByRole('option'); | ||
expect(optionElements).toHaveLength(4); | ||
}); | ||
|
||
it('should select an option when clicked', async () => { | ||
render( | ||
<TypeaheadEditor | ||
selectOptions={initialDataFormatOptions} | ||
title="Test" | ||
selected={undefined} | ||
selectedModel={undefined} | ||
selectedSchema={undefined} | ||
selectionOnChange={mockOnChange} | ||
/>, | ||
); | ||
const inputElement = screen.getByRole('combobox'); | ||
await act(async () => { | ||
fireEvent.click(inputElement); | ||
}); | ||
const optionElement = screen.getByText('Avro'); | ||
await act(() => { | ||
fireEvent.click(optionElement); | ||
}); | ||
expect(mockOnChange).toHaveBeenCalledWith({ name: 'avro', title: 'Avro' }, {}); | ||
}); | ||
|
||
it('should clear the input value when the clear button is clicked', async () => { | ||
const selected = { name: 'avro', title: 'Avro' }; | ||
const selectedModel = {}; | ||
const selectedSchema = {}; | ||
render( | ||
<TypeaheadEditor | ||
selectOptions={initialDataFormatOptions} | ||
title="Test" | ||
selected={selected} | ||
selectedModel={selectedModel} | ||
selectedSchema={selectedSchema} | ||
selectionOnChange={mockOnChange} | ||
/>, | ||
); | ||
const inputElement = screen.getByRole('combobox'); | ||
await act(async () => { | ||
fireEvent.change(inputElement, { target: { value: 'customValue' } }); | ||
}); | ||
const clearButton = screen.getByLabelText('Clear input value'); | ||
await act(async () => { | ||
fireEvent.click(clearButton); | ||
}); | ||
expect(inputElement).toHaveValue(''); | ||
expect(mockOnChange).toHaveBeenCalledWith(undefined, {}); | ||
}); | ||
}); |
252 changes: 252 additions & 0 deletions
252
packages/ui/src/components/Form/customField/TypeaheadEditor.tsx
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,252 @@ | ||
import React from 'react'; | ||
import { | ||
Select, | ||
SelectOption, | ||
SelectList, | ||
SelectOptionProps, | ||
MenuToggle, | ||
MenuToggleElement, | ||
TextInputGroup, | ||
TextInputGroupMain, | ||
TextInputGroupUtilities, | ||
Button, | ||
} from '@patternfly/react-core'; | ||
import { FunctionComponent, Ref, useCallback, useEffect, useRef, useState } from 'react'; | ||
import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; | ||
import { SchemaService } from '../schema.service'; | ||
import { MetadataEditor } from '../../MetadataEditor'; | ||
import { JSONSchema4 } from 'json-schema'; | ||
|
||
interface TypeaheadEditorProps { | ||
selectOptions: SelectOptionProps[]; | ||
title: string; | ||
selected: { name: string; title: string } | undefined; | ||
selectedSchema: JSONSchema4 | undefined; | ||
selectedModel: Record<string, unknown> | undefined; | ||
selectionOnChange: ( | ||
selectedItem: { name: string; title: string } | undefined, | ||
newItemModel: Record<string, unknown>, | ||
) => void; | ||
} | ||
|
||
export const TypeaheadEditor: FunctionComponent<TypeaheadEditorProps> = (props) => { | ||
const [isOpen, setIsOpen] = useState(false); | ||
const [selected, setSelected] = useState<string>(props.selected?.name || ''); | ||
const [inputValue, setInputValue] = useState<string>(props.selected?.title || ''); | ||
const [filterValue, setFilterValue] = useState<string>(''); | ||
const [selectOptions, setSelectOptions] = useState<SelectOptionProps[]>(props.selectOptions); | ||
const [focusedItemIndex, setFocusedItemIndex] = useState<number | null>(null); | ||
const [activeItem, setActiveItem] = useState<string | null>(null); | ||
const textInputRef = useRef<HTMLInputElement>(); | ||
|
||
useEffect(() => { | ||
props.selected ? setSelected(props.selected.name) : setSelected(''); | ||
}, [props.selected]); | ||
|
||
useEffect(() => { | ||
let newSelectOptions: SelectOptionProps[] = props.selectOptions; | ||
|
||
// Filter menu items based on the text input value when one exists | ||
if (filterValue) { | ||
const lowerFilterValue = filterValue.toLowerCase(); | ||
newSelectOptions = props.selectOptions.filter((menuItem) => { | ||
return ( | ||
String(menuItem.value).toLowerCase().includes(lowerFilterValue) || | ||
String(menuItem.children).toLowerCase().includes(lowerFilterValue) || | ||
String(menuItem.description).toLowerCase().includes(lowerFilterValue) | ||
); | ||
}); | ||
// When no options are found after filtering, display 'No results found' | ||
if (!newSelectOptions.length) { | ||
newSelectOptions = [ | ||
{ isDisabled: false, children: `No results found for "${filterValue}"`, value: 'no results' }, | ||
]; | ||
} | ||
// Open the menu when the input value changes and the new value is not empty | ||
if (!isOpen) { | ||
setIsOpen(true); | ||
} | ||
} | ||
|
||
setSelectOptions(newSelectOptions); | ||
setActiveItem(null); | ||
setFocusedItemIndex(null); | ||
}, [filterValue, props.selectOptions, isOpen]); | ||
|
||
const onToggleClick = useCallback(() => { | ||
setIsOpen(!isOpen); | ||
}, [isOpen]); | ||
|
||
const onSelect = useCallback( | ||
(_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => { | ||
const option = selectOptions.find((option) => option.children === value); | ||
if (option && value !== 'no results') { | ||
setInputValue(value as string); | ||
setFilterValue(''); | ||
props.selectionOnChange({ name: option!.value as string, title: option!.children as string }, {}); | ||
setSelected(option!.children as string); | ||
} | ||
setIsOpen(false); | ||
setFocusedItemIndex(null); | ||
setActiveItem(null); | ||
}, | ||
[selectOptions, props.selectionOnChange], | ||
); | ||
|
||
const onTextInputChange = (_event: React.FormEvent<HTMLInputElement>, value: string) => { | ||
setInputValue(value); | ||
setFilterValue(value); | ||
}; | ||
|
||
const handleMenuArrowKeys = (key: string) => { | ||
let indexToFocus; | ||
|
||
if (isOpen) { | ||
if (key === 'ArrowUp') { | ||
// When no index is set or at the first index, focus to the last, otherwise decrement focus index | ||
if (focusedItemIndex === null || focusedItemIndex === 0) { | ||
indexToFocus = selectOptions.length - 1; | ||
} else { | ||
indexToFocus = focusedItemIndex - 1; | ||
} | ||
} | ||
|
||
if (key === 'ArrowDown') { | ||
// When no index is set or at the last index, focus to the first, otherwise increment focus index | ||
if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) { | ||
indexToFocus = 0; | ||
} else { | ||
indexToFocus = focusedItemIndex + 1; | ||
} | ||
} | ||
|
||
setFocusedItemIndex(indexToFocus!); | ||
const focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus!]; | ||
setActiveItem(`select-typeahead-${focusedItem.value.replace(' ', '-')}`); | ||
} | ||
}; | ||
|
||
const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { | ||
const enabledMenuItems = selectOptions.filter((option) => !option.isDisabled); | ||
const [firstMenuItem] = enabledMenuItems; | ||
const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; | ||
|
||
switch (event.key) { | ||
// Select the first available option | ||
case 'Enter': | ||
if (isOpen && focusedItem.value !== 'no results') { | ||
setInputValue(String(focusedItem.children)); | ||
setFilterValue(''); | ||
setSelected(String(focusedItem.children)); | ||
} | ||
|
||
setIsOpen((prevIsOpen) => !prevIsOpen); | ||
setFocusedItemIndex(null); | ||
setActiveItem(null); | ||
break; | ||
case 'Tab': | ||
case 'Escape': | ||
setIsOpen(false); | ||
setActiveItem(null); | ||
break; | ||
case 'ArrowUp': | ||
case 'ArrowDown': | ||
event.preventDefault(); | ||
handleMenuArrowKeys(event.key); | ||
break; | ||
} | ||
}; | ||
|
||
const toggle = (toggleRef: Ref<MenuToggleElement>) => ( | ||
<MenuToggle | ||
ref={toggleRef} | ||
variant="typeahead" | ||
aria-label="Typeahead menu toggle" | ||
onClick={onToggleClick} | ||
isExpanded={isOpen} | ||
isFullWidth | ||
> | ||
<TextInputGroup isPlain> | ||
<TextInputGroupMain | ||
data-testid="typeahead-select-input" | ||
value={inputValue} | ||
onClick={onToggleClick} | ||
onChange={onTextInputChange} | ||
onKeyDown={onInputKeyDown} | ||
id="typeahead-select-input" | ||
autoComplete="off" | ||
innerRef={textInputRef} | ||
placeholder={SchemaService.DROPDOWN_PLACEHOLDER} | ||
{...(activeItem && { 'aria-activedescendant': activeItem })} | ||
role="combobox" | ||
isExpanded={isOpen} | ||
aria-controls="select-typeahead-listbox" | ||
/> | ||
|
||
<TextInputGroupUtilities> | ||
{!!inputValue && ( | ||
<Button | ||
data-testid="clear-input-value" | ||
variant="plain" | ||
onClick={() => { | ||
setSelected(''); | ||
setInputValue(''); | ||
setFilterValue(''); | ||
props.selectionOnChange(undefined, {}); | ||
textInputRef?.current?.focus(); | ||
}} | ||
aria-label="Clear input value" | ||
> | ||
<TimesIcon aria-hidden /> | ||
</Button> | ||
)} | ||
</TextInputGroupUtilities> | ||
</TextInputGroup> | ||
</MenuToggle> | ||
); | ||
|
||
return ( | ||
props.selectOptions && ( | ||
<> | ||
<Select | ||
id="typeahead-select" | ||
isOpen={isOpen} | ||
selected={selected} | ||
onSelect={onSelect} | ||
onOpenChange={() => { | ||
setIsOpen(false); | ||
}} | ||
toggle={toggle} | ||
> | ||
<SelectList id="select-typeahead-listbox"> | ||
{selectOptions.map((option, index) => ( | ||
<SelectOption | ||
key={option.value as string} | ||
description={option.description} | ||
isFocused={focusedItemIndex === index} | ||
className={option.className} | ||
data-testid={`${props.title}-dropdownitem-${option.value}`} | ||
onClick={() => setSelected(option.children as string)} | ||
id={`select-typeahead-${option.value.replace(' ', '-')}`} | ||
{...option} | ||
value={option.children} | ||
/> | ||
))} | ||
</SelectList> | ||
</Select> | ||
{props.selected && ( | ||
<MetadataEditor | ||
key={props.selected!.name} | ||
data-testid={`${props.title}-editor`} | ||
name={`${props.title}`} | ||
schema={props.selectedSchema} | ||
metadata={props.selectedModel} | ||
onChangeModel={(model) => | ||
props.selectionOnChange({ name: props.selected!.name, title: props.selected!.title }, model) | ||
} | ||
/> | ||
)} | ||
</> | ||
) | ||
); | ||
}; |
Oops, something went wrong.