Skip to content

Commit

Permalink
fix: Use a Typeahead component for the DataFormat and LoadBalancer Field
Browse files Browse the repository at this point in the history
Fixes: #903
  • Loading branch information
igarashitm authored and lordrip committed May 2, 2024
1 parent 1add8d5 commit e054d2d
Show file tree
Hide file tree
Showing 7 changed files with 490 additions and 117 deletions.
4 changes: 3 additions & 1 deletion packages/ui-tests/cypress/support/next-commands/design.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ Cypress.Commands.add('selectDataformat', (dataformat: string) => {
});

Cypress.Commands.add('selectCustomMetadataEditor', (type: string, format: string) => {
cy.get(`div[data-testid="${type}-config-card"] button.pf-v5-c-menu-toggle`).should('be.visible').click();
cy.get(`div[data-testid="${type}-config-card"] div.pf-v5-c-menu-toggle button.pf-v5-c-menu-toggle__button`)
.should('be.visible')
.click();
const regex = new RegExp(`^${format}$`);
cy.get('span.pf-v5-c-menu__item-text').contains(regex).should('exist').scrollIntoView().click();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as catalogIndex from '@kaoto/camel-catalog/index.json';
import { fireEvent, render, screen } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { fireEvent, render, screen, act } from '@testing-library/react';
import { CatalogKind, ICamelDataformatDefinition, KaotoSchemaDefinition } from '../../../models';
import { IVisualizationNode, VisualComponentSchema } from '../../../models/visualization/base-visual-entity';
import { CamelCatalogService } from '../../../models/visualization/flows';
Expand All @@ -11,7 +10,7 @@ import { DataFormatEditor } from './DataFormatEditor';
describe('DataFormatEditor', () => {
let mockNode: CanvasNode;
let dataformatCatalog: Record<string, ICamelDataformatDefinition>;
beforeAll(async () => {
beforeEach(async () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
dataformatCatalog = await import('@kaoto/camel-catalog/' + catalogIndex.catalogs.dataformats.file);
/* eslint-disable @typescript-eslint/no-explicit-any */
Expand Down Expand Up @@ -50,16 +49,54 @@ describe('DataFormatEditor', () => {

it('should render', async () => {
render(<DataFormatEditor selectedNode={mockNode} />);
const buttons = screen.getAllByRole('button');
const buttons = screen.getAllByRole('button', { name: 'Menu toggle' });
await act(async () => {
fireEvent.click(buttons[1]);
fireEvent.click(buttons[0]);
});
const json = screen.getByTestId('dataformat-dropdownitem-json');
fireEvent.click(json.getElementsByTagName('button')[0]);
const form = screen.getByTestId('metadata-editor-form-dataformat');
expect(form.innerHTML).toContain('Allow Unmarshall Type');
});

it('should filter candidates with a text input', async () => {
render(<DataFormatEditor selectedNode={mockNode} />);
const buttons = screen.getAllByRole('button', { name: 'Menu toggle' });
await act(async () => {
fireEvent.click(buttons[0]);
});
let dropdownItems = screen.queryAllByTestId(/dataformat-dropdownitem-.*/);
expect(dropdownItems.length).toBeGreaterThan(40);
const inputElement = screen.getAllByRole('combobox')[0];
await act(async () => {
fireEvent.change(inputElement, { target: { value: 'json' } });
});
dropdownItems = screen.getAllByTestId(/dataformat-dropdownitem-.*/);
expect(dropdownItems).toHaveLength(3);
});

it('should clear filter and close the dropdown with close button', async () => {
render(<DataFormatEditor selectedNode={mockNode} />);
const buttons = screen.getAllByRole('button', { name: 'Menu toggle' });
await act(async () => {
fireEvent.click(buttons[0]);
});
let inputElement = screen.getAllByRole('combobox')[0];
await act(async () => {
fireEvent.change(inputElement, { target: { value: 'json' } });
});
let dropdownItems = screen.getAllByTestId(/dataformat-dropdownitem-.*/);
expect(dropdownItems).toHaveLength(3);
const clearButton = screen.getByLabelText('Clear input value');
await act(async () => {
fireEvent.click(clearButton);
});
dropdownItems = screen.getAllByTestId(/dataformat-dropdownitem-.*/);
expect(dropdownItems.length).toBeGreaterThan(40);
inputElement = screen.getAllByRole('combobox')[0];
expect(inputElement).toHaveValue('');
});

it('should render for all dataformats without an error', () => {
Object.entries(dataformatCatalog).forEach(([name, dataformat]) => {
try {
Expand Down
233 changes: 190 additions & 43 deletions packages/ui/src/components/Form/dataFormat/DataFormatEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
import {
Button,
Card,
CardBody,
CardExpandableContent,
CardHeader,
CardTitle,
Dropdown,
DropdownItem,
DropdownList,
MenuToggle,
MenuToggleElement,
Text,
TextContent,
TextVariants,
Select,
SelectList,
SelectOption,
SelectOptionProps,
TextInputGroup,
TextInputGroupMain,
TextInputGroupUtilities,
} from '@patternfly/react-core';
import { FunctionComponent, Ref, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { FunctionComponent, Ref, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { EntitiesContext } from '../../../providers';
import { MetadataEditor } from '../../MetadataEditor';
import { CanvasNode } from '../../Visualization/Canvas/canvas.models';
import { SchemaService } from '../schema.service';
import './DataFormatEditor.scss';
import { DataFormatService } from './dataformat.service';
import { TimesIcon } from '@patternfly/react-icons';

interface DataFormatEditorProps {
selectedNode: CanvasNode;
Expand All @@ -45,11 +48,56 @@ export const DataFormatEditor: FunctionComponent<DataFormatEditorProps> = (props
visualComponentSchema?.definition,
);
const [selected, setSelected] = useState<string>(dataFormat?.model.name || '');
const [inputValue, setInputValue] = useState<string>(dataFormat?.model.title || '');
const initialDataFormatOptions = useMemo(() => {
return Object.values(dataFormatCatalogMap).map((option) => {
return {
value: option.model.name,
children: option.model.title,
description: option.model.description,
};
});
}, [dataFormatCatalogMap]);
const [selectOptions, setSelectOptions] = useState<SelectOptionProps[]>(initialDataFormatOptions);
const [focusedItemIndex, setFocusedItemIndex] = useState<number | null>(null);
const [activeItem, setActiveItem] = useState<string | null>(null);
const [filterValue, setFilterValue] = useState<string>('');
const textInputRef = useRef<HTMLInputElement>();

useEffect(() => {
dataFormat ? setSelected(dataFormat.model.name) : setSelected('');
}, [dataFormat]);

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

// Filter menu items based on the text input value when one exists
if (filterValue) {
const lowerFilterValue = filterValue.toLowerCase();
newSelectOptions = initialDataFormatOptions.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, initialDataFormatOptions, isOpen]);

const dataFormatSchema = useMemo(() => {
return DataFormatService.getDataFormatSchema(dataFormat);
}, [dataFormat]);
Expand All @@ -71,25 +119,123 @@ export const DataFormatEditor: FunctionComponent<DataFormatEditorProps> = (props

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('');
handleOnChange(option!.value as string, {});
setSelected(option!.children as string);
}
setIsOpen(false);
if ((!dataFormat && value === '') || value === dataFormat?.model.name) return;
setSelected(value as string);
handleOnChange(value as string, {});
setFocusedItemIndex(null);
setActiveItem(null);
},
[handleOnChange, dataFormat],
[selectOptions, handleOnChange],
);

const toggle = useCallback(
(toggleRef: Ref<MenuToggleElement>) => (
<MenuToggle ref={toggleRef} onClick={onToggleClick} isFullWidth isExpanded={isOpen}>
{selected || (
<TextContent>
<Text component={TextVariants.small}>{SchemaService.DROPDOWN_PLACEHOLDER}</Text>
</TextContent>
)}
</MenuToggle>
),
[isOpen, onToggleClick, selected],
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" 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('');
handleOnChange('', {});
textInputRef?.current?.focus();
}}
aria-label="Clear input value"
>
<TimesIcon aria-hidden />
</Button>
)}
</TextInputGroupUtilities>
</TextInputGroup>
</MenuToggle>
);

return (
Expand All @@ -101,31 +247,32 @@ export const DataFormatEditor: FunctionComponent<DataFormatEditorProps> = (props
</CardHeader>
<CardExpandableContent>
<CardBody data-testid={'dataformat-config-card'}>
<Dropdown
id="dataformat-select"
data-testid="expression-dropdown"
<Select
id="typeahead-select"
isOpen={isOpen}
selected={selected !== '' ? selected : undefined}
selected={selected}
onSelect={onSelect}
onOpenChange={setIsOpen}
onOpenChange={() => {
setIsOpen(false);
}}
toggle={toggle}
isScrollable={true}
>
<DropdownList data-testid="dataformat-dropdownlist">
{Object.values(dataFormatCatalogMap).map((df) => {
return (
<DropdownItem
data-testid={`dataformat-dropdownitem-${df.model.name}`}
key={df.model.title}
value={df.model.name}
description={df.model.description}
>
{df.model.title}
</DropdownItem>
);
})}
</DropdownList>
</Dropdown>
<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={`dataformat-dropdownitem-${option.value}`}
onClick={() => setSelected(option.children as string)}
id={`select-typeahead-${option.value.replace(' ', '-')}`}
{...option}
value={option.children}
/>
))}
</SelectList>
</Select>
{dataFormat && (
<MetadataEditor
key={dataFormat.model.name}
Expand Down
Loading

0 comments on commit e054d2d

Please sign in to comment.