Skip to content

Commit

Permalink
fix(677) enable users to enter text into drop down boxes
Browse files Browse the repository at this point in the history
  • Loading branch information
tplevko committed Jan 26, 2024
1 parent 5a2e90a commit 33b3b2b
Show file tree
Hide file tree
Showing 5 changed files with 352 additions and 10 deletions.
4 changes: 2 additions & 2 deletions packages/ui/src/components/Form/CustomAutoField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ jest.mock('uniforms', () => {
import { BoolField, DateField, ListField, NestField, RadioField, TextField } from '@kaoto-next/uniforms-patternfly';
import { AutoFieldProps } from 'uniforms';
import { CustomAutoField } from './CustomAutoField';
import { CustomSelectField } from './customField/CustomSelectField';
import { DisabledField } from './customField/DisabledField';
import TypeaheadField from './customField/TypeaheadField';

describe('CustomAutoField', () => {
it('should return `RadioField` if `props.options` & `props.checkboxes` are defined and `props.fieldType` is not `Array`', () => {
Expand All @@ -38,7 +38,7 @@ describe('CustomAutoField', () => {

const result = CustomAutoField(props);

expect(result).toBe(CustomSelectField);
expect(result).toBe(TypeaheadField);
});

it('should return `DisabledField` if `props.name` ends with `steps`', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/components/Form/CustomAutoField.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DateField, ListField, NestField, RadioField, TextField, BoolField } from '@kaoto-next/uniforms-patternfly';
import { createAutoField } from 'uniforms';
import { CustomSelectField } from './customField/CustomSelectField';
import TypeaheadField from './customField/TypeaheadField';
import { DisabledField } from './customField/DisabledField';
import { BeanReferenceField } from './bean/BeanReferenceField';
import { ExpressionAwareNestField } from './expression/ExpressionAwareNestField';
Expand All @@ -13,7 +13,7 @@ import { PropertiesField } from './properties/PropertiesField';
*/
export const CustomAutoField = createAutoField((props) => {
if (props.options) {
return props.checkboxes && props.fieldType !== Array ? RadioField : CustomSelectField;
return props.checkboxes && props.fieldType !== Array ? RadioField : TypeaheadField;
}

const comment = props['$comment'] as string;
Expand Down

This file was deleted.

109 changes: 109 additions & 0 deletions packages/ui/src/components/Form/customField/TypeaheadField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { AutoField } from '@kaoto-next/uniforms-patternfly';
import { fireEvent, render, screen } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { AutoForm } from 'uniforms';
import { SchemaService } from '..';
import { CustomAutoFieldDetector } from '../CustomAutoField';
import TypeaheadField from './TypeaheadField';

describe('TypeaheadField', () => {
const mockSchema = {
title: 'Threads',
description: 'Specifies that all steps after this node are processed asynchronously',
type: 'object',
additionalProperties: false,
properties: {
rejectedPolicy: {
type: 'string',
title: 'Rejected Policy',
description: 'Sets the handler for tasks which cannot be executed by the thread pool.',
enum: ['option1', 'option2', 'option3'],
},
},
};
const mockOnChange = jest.fn();
const schemaService = new SchemaService();
const schemaBridge = schemaService.getSchemaBridge(mockSchema);

beforeEach(() => {
mockOnChange.mockClear();
});

it('should render the component', () => {
render(
<AutoField.componentDetectorContext.Provider value={CustomAutoFieldDetector}>
<AutoForm schema={schemaBridge!}>
<TypeaheadField name="rejectedPolicy" />
</AutoForm>
</AutoField.componentDetectorContext.Provider>,
);
const inputElement = screen.getByRole('combobox');
expect(inputElement).toBeInTheDocument();
});

it('should display the options when the input is clicked', () => {
render(
<AutoField.componentDetectorContext.Provider value={CustomAutoFieldDetector}>
<AutoForm schema={schemaBridge!}>
<TypeaheadField name="rejectedPolicy" />
</AutoForm>
</AutoField.componentDetectorContext.Provider>,
);
const inputElement = screen.getByRole('combobox');
fireEvent.click(inputElement);
const optionElements = screen.getAllByRole('option');
expect(optionElements).toHaveLength(3);
});

it('should select an option when clicked', () => {
render(
<AutoField.componentDetectorContext.Provider value={CustomAutoFieldDetector}>
<AutoForm schema={schemaBridge!}>
<TypeaheadField name="rejectedPolicy" onChange={mockOnChange} />
</AutoForm>
</AutoField.componentDetectorContext.Provider>,
);
const inputElement = screen.getByRole('combobox');
fireEvent.click(inputElement);
const optionElement = screen.getByText('option1');
act(() => {
fireEvent.click(optionElement);
});
expect(mockOnChange).toHaveBeenCalledWith('option1');
});

it('should create a new option when a custom value is entered', () => {
const wrapper = render(
<AutoField.componentDetectorContext.Provider value={CustomAutoFieldDetector}>
<AutoForm schema={schemaBridge!}>
<TypeaheadField name="rejectedPolicy" onChange={mockOnChange} />
</AutoForm>
</AutoField.componentDetectorContext.Provider>,
);
const inputElement = wrapper.getByRole('combobox');
act(() => {
fireEvent.change(inputElement, { target: { value: 'customValue' } });
fireEvent.keyDown(inputElement, { key: 'Enter' });
});
expect(inputElement).toHaveValue('customValue');
});

it('should clear the input value when the clear button is clicked', () => {
render(
<AutoField.componentDetectorContext.Provider value={CustomAutoFieldDetector}>
<AutoForm schema={schemaBridge!}>
<TypeaheadField name="rejectedPolicy" onChange={mockOnChange} />
</AutoForm>
</AutoField.componentDetectorContext.Provider>,
);
const inputElement = screen.getByRole('combobox');
act(() => {
fireEvent.change(inputElement, { target: { value: 'customValue' } });
});
const clearButton = screen.getByLabelText('Clear input value');
act(() => {
fireEvent.click(clearButton);
});
expect(inputElement).toHaveValue('');
});
});
239 changes: 239 additions & 0 deletions packages/ui/src/components/Form/customField/TypeaheadField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import { wrapField } from '@kaoto-next/uniforms-patternfly';
import {
Button,
MenuToggle,
MenuToggleElement,
Select,
SelectList,
SelectOption,
SelectOptionProps,
TextInputGroup,
TextInputGroupMain,
TextInputGroupUtilities,
} from '@patternfly/react-core';
import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { HTMLFieldProps, connectField } from 'uniforms';

export type SelectOptionObject = {
value: string | number;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type TypeaheadProps = HTMLFieldProps<any, HTMLDivElement> & {
options?: Array<SelectOptionObject>;
};

function TypeaheadField(props: TypeaheadProps) {
const selectedReference = (props.value as string) ?? '';
let allOptions: SelectOptionProps[] = useMemo(() => {
const hasSelectedReference = props.options?.some((option) => option.value === selectedReference);
if (!hasSelectedReference && selectedReference !== '') {
const newOption: SelectOptionObject = {
value: selectedReference,
};
props.options?.push(newOption);
}
return props.options!.map((option) => {
return {
value: option.value,
children: option.value,
isSelected: option.value === selectedReference,
};
});
}, [selectedReference]);

const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState<string>('');
const [inputValue, setInputValue] = useState<string>(selectedReference);
const [filterValue, setFilterValue] = useState<string>('');
const [selectOptions, setSelectOptions] = useState<SelectOptionProps[]>(allOptions);
const [focusedItemIndex, setFocusedItemIndex] = useState<number | null>(null);
const [activeItem, setActiveItem] = useState<string | null>(null);
const [onCreation, setOnCreation] = useState<boolean>(false); // Boolean to refresh filter state after new option is created
const textInputRef = useRef<HTMLInputElement>();

useEffect(() => {
let newSelectOptions: SelectOptionProps[] = [...allOptions];

// Filter menu items based on the text input value when one exists
if (filterValue) {
newSelectOptions = allOptions.filter((menuItem) =>
String(menuItem.children).toLowerCase().includes(filterValue.toLowerCase()),
);
// When no options are found after filtering, display creation option
if (!newSelectOptions.length) {
newSelectOptions = [{ isDisabled: false, children: `Insert custom value "${filterValue}"`, value: 'create' }];
}
// 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, onCreation, isOpen]);

const onToggleClick = () => {
setIsOpen(!isOpen);
};

const onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
if (value) {
if (value === 'create') {
if (!allOptions.some((item) => item.value === filterValue)) {
allOptions = [...allOptions, { value: filterValue, children: filterValue }];
}
setSelected(filterValue);
setOnCreation(!onCreation);
props.onChange(filterValue);
setFilterValue('');
} else {
setInputValue(value as string);
setFilterValue('');
setSelected(value as string);
props.onChange(value as string);
}
}
setIsOpen(false);
setFocusedItemIndex(null);
setActiveItem(null);
};

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 ?? null);
const focusedItem = selectOptions.filter((option) => !option.isDisabled)[indexToFocus!];
setActiveItem(`select-create-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) {
onSelect(undefined, focusedItem.value as string);
setIsOpen((prevIsOpen) => !prevIsOpen);
setFocusedItemIndex(null);
setActiveItem(null);
}

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: React.Ref<MenuToggleElement>) => (
<MenuToggle ref={toggleRef} variant="typeahead" onClick={onToggleClick} isExpanded={isOpen} isFullWidth>
<TextInputGroup isPlain>
<TextInputGroupMain
value={inputValue}
onClick={onToggleClick}
onChange={onTextInputChange}
onKeyDown={onInputKeyDown}
id="create-typeahead-select-input"
autoComplete="off"
innerRef={textInputRef}
placeholder="Select an option"
{...(activeItem && { 'aria-activedescendant': activeItem })}
role="combobox"
isExpanded={isOpen}
aria-controls="select-create-typeahead-listbox"
/>

<TextInputGroupUtilities>
{!!inputValue && (
<Button
variant="plain"
onClick={() => {
setSelected('');
setInputValue('');
setFilterValue('');
props.onChange('');
textInputRef?.current?.focus();
}}
aria-label="Clear input value"
>
<TimesIcon aria-hidden />
</Button>
)}
</TextInputGroupUtilities>
</TextInputGroup>
</MenuToggle>
);

return wrapField(
props,
<Select
id="create-typeahead-select"
isOpen={isOpen}
selected={selected}
onSelect={onSelect}
onOpenChange={() => {
setIsOpen(false);
}}
toggle={toggle}
>
<SelectList id="select-create-typeahead-listbox">
{selectOptions.map((option, index) => (
<SelectOption
key={option.value as string}
description={option.description}
isFocused={focusedItemIndex === index}
className={option.className}
onClick={() => setSelected(option.value)}
id={`select-typeahead-${option.value}`}
{...option}
ref={null}
/>
))}
</SelectList>
</Select>,
);
}

TypeaheadField.defaultProps = {};

export default connectField(TypeaheadField);

0 comments on commit 33b3b2b

Please sign in to comment.