Skip to content

Commit

Permalink
fix(1055): Added TypeaheadEditor and reusing it with DataFormatEditor…
Browse files Browse the repository at this point in the history
…, ExpressionEditor, and LoadBalancerEditor
  • Loading branch information
shivamG640 committed Jun 4, 2024
1 parent 5108ddf commit 7d83698
Show file tree
Hide file tree
Showing 9 changed files with 489 additions and 698 deletions.
113 changes: 113 additions & 0 deletions packages/ui/src/components/Form/customField/TypeaheadEditor.test.tsx
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 packages/ui/src/components/Form/customField/TypeaheadEditor.tsx
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)
}
/>
)}
</>
)
);
};
Loading

0 comments on commit 7d83698

Please sign in to comment.