Skip to content

Commit

Permalink
fix(868) : Use a Typeahead component for the ExpressionField
Browse files Browse the repository at this point in the history
  • Loading branch information
tplevko authored and lordrip committed Mar 5, 2024
1 parent 5985ec8 commit 5ffd944
Show file tree
Hide file tree
Showing 8 changed files with 238 additions and 75 deletions.
6 changes: 4 additions & 2 deletions packages/ui-tests/cypress/support/next-commands/design.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Cypress.Commands.add('closeStepConfigurationTab', () => {
});

Cypress.Commands.add('interactWithExpressinInputObject', (inputName: string, value?: string) => {
cy.get('[data-testid="expression-modal"]').within(() => {
cy.get('[data-ouia-component-id="ExpressionModal"]').within(() => {
cy.interactWithConfigInputObject(inputName, value);
});
});
Expand Down Expand Up @@ -113,7 +113,9 @@ Cypress.Commands.add('cancelExpressionModal', () => {
});

Cypress.Commands.add('selectExpression', (expression: string) => {
cy.get('div[data-testid="expression-modal"] button.pf-v5-c-menu-toggle').should('be.visible').click();
cy.get('div[data-ouia-component-id="ExpressionModal"] button.pf-v5-c-menu-toggle__button')
.should('be.visible')
.click();
const regex = new RegExp(`^${expression}$`);
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
@@ -0,0 +1,8 @@
.pf-v5-c-menu {
max-height: 500px;
overflow-y: auto;
}

.metadata-editor {
margin-top: 24px;
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { ExpressionEditor } from './ExpressionEditor';
import { fireEvent, render, screen } from '@testing-library/react';
import { CamelCatalogService, CatalogKind, ICamelLanguageDefinition } from '../../../models';
import * as catalogIndex from '@kaoto-next/camel-catalog/index.json';
import { ExpressionService } from './expression.service';
import { fireEvent, render, screen } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { CamelCatalogService, CatalogKind, ICamelLanguageDefinition } from '../../../models';
import { SchemaService } from '../schema.service';
import { ExpressionEditor } from './ExpressionEditor';
import { ExpressionService } from './expression.service';

describe('ExpressionEditor', () => {
const onChangeMock = jest.fn();
Expand All @@ -24,8 +24,8 @@ describe('ExpressionEditor', () => {
it('render empty simple if language is not specified', () => {
render(<ExpressionEditor expressionModel={{}} onChangeExpressionModel={onChangeMock}></ExpressionEditor>);
const dropdown = screen
.getAllByRole('button')
.filter((button) => button.innerHTML.includes(SchemaService.DROPDOWN_PLACEHOLDER));
.getAllByTestId('typeahead-select-input')
.filter((input) => input.innerHTML.includes(SchemaService.DROPDOWN_PLACEHOLDER));
expect(dropdown).toHaveLength(1);
});

Expand All @@ -42,7 +42,7 @@ describe('ExpressionEditor', () => {
onChangeExpressionModel={onChangeMock}
></ExpressionEditor>,
);
const dropdown = screen.getAllByRole('button').filter((button) => button.textContent === 'JQ');
const dropdown = screen.getAllByTestId('typeahead-select-input').filter((input) => input.innerHTML.includes('JQ'));
expect(dropdown).toHaveLength(1);
const resultTypeInput = screen
.getAllByRole('textbox')
Expand Down
257 changes: 201 additions & 56 deletions packages/ui/src/components/Form/expression/ExpressionEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import {
Dropdown,
DropdownItem,
DropdownList,
Button,
MenuToggle,
MenuToggleElement,
Text,
TextContent,
TextVariants,
Select,
SelectList,
SelectOption,
SelectOptionProps,
TextInputGroup,
TextInputGroupMain,
TextInputGroupUtilities,
} from '@patternfly/react-core';
import { FunctionComponent, Ref, useCallback, useMemo, useState } from 'react';
import { MetadataEditor } from '../../MetadataEditor';
import { ExpressionService } from './expression.service';
import { TimesIcon } from '@patternfly/react-icons';
import { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ICamelLanguageDefinition } from '../../../models';
import { MetadataEditor } from '../../MetadataEditor';
import { SchemaService } from '../schema.service';
import './ExpressionEditor.scss';
import { ExpressionService } from './expression.service';

interface ExpressionEditorProps {
language?: ICamelLanguageDefinition;
Expand All @@ -25,79 +29,220 @@ export const ExpressionEditor: FunctionComponent<ExpressionEditorProps> = ({
expressionModel,
onChangeExpressionModel,
}) => {
const [isOpen, setIsOpen] = useState(false);
const languageCatalogMap = useMemo(() => {
return ExpressionService.getLanguageMap();
const languageCatalogMap: SelectOptionProps[] = useMemo(() => {
const languageCatalog = Object.values(ExpressionService.getLanguageMap());
return languageCatalog!.map((option) => {
return {
value: option.model.name,
children: option.model.title,
className: option.model.name,
description: option.model.description,
};
});
}, []);
const [selected, setSelected] = useState<string>(language?.model.name || '');

const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState<string>(language?.model.title || '');
const [filterValue, setFilterValue] = useState<string>('');
const [selectOptions, setSelectOptions] = useState<SelectOptionProps[]>(languageCatalogMap);
const [focusedItemIndex, setFocusedItemIndex] = useState<number | null>(null);
const [activeItem, setActiveItem] = useState<string | null>(null);
const textInputRef = useRef<HTMLInputElement>();
const [selected, setSelected] = useState<string>(language?.model.title || '');

const languageSchema = useMemo(() => {
return language && ExpressionService.getLanguageSchema(language);
}, [language]);

const onSelect = useCallback(
(_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
const model = languageCatalogMap.find((model) => model.children === value);
if (model && value !== 'no results') {
setInputValue(value as string);
setFilterValue('');
onChangeExpressionModel(model!.value as string, {});
setSelected(model!.children as string);
}
setIsOpen(false);
if ((!language && value === '') || value === language?.model.name) return;
setSelected(value as string);
onChangeExpressionModel(value as string, {});
setFocusedItemIndex(null);
setActiveItem(null);
},
[onChangeExpressionModel, language],
[languageCatalogMap, onChangeExpressionModel],
);

const onToggleClick = useCallback(() => {
setIsOpen(!isOpen);
}, [isOpen]);

const toggle = useCallback(
(toggleRef: Ref<MenuToggleElement>) => (
<MenuToggle ref={toggleRef} onClick={onToggleClick} isExpanded={isOpen}>
{language?.model.title || (
<TextContent>
<Text component={TextVariants.small}>{SchemaService.DROPDOWN_PLACEHOLDER}</Text>
</TextContent>
)}
</MenuToggle>
),
[onToggleClick, isOpen, language],
useEffect(() => {
let newSelectOptions: SelectOptionProps[] = languageCatalogMap;

// Filter menu items based on the text input value when one exists
if (filterValue) {
newSelectOptions = languageCatalogMap.filter((menuItem) =>
String(menuItem.value).toLowerCase().includes(filterValue.toLowerCase()),
);
// 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]);

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: React.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('');
onChangeExpressionModel('', {});
textInputRef?.current?.focus();
}}
aria-label="Clear input value"
>
<TimesIcon aria-hidden />
</Button>
)}
</TextInputGroupUtilities>
</TextInputGroup>
</MenuToggle>
);

return (
languageCatalogMap && (
<>
<Dropdown
id="expression-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="expression-dropdownlist">
{Object.values(languageCatalogMap).map((lang) => {
return (
<DropdownItem
data-testid={`expression-dropdownitem-${lang.model.name}`}
key={lang.model.title}
value={lang.model.name}
description={lang.model.description}
>
{lang.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={`expression-dropdownitem-${option.value}`}
onClick={() => setSelected(option.children as string)}
id={`select-typeahead-${option.value.replace(' ', '-')}`}
{...option}
value={option.children}
/>
))}
</SelectList>
</Select>
{language && (
<MetadataEditor
data-testid="expression-editor"
name={'expression'}
schema={languageSchema}
metadata={expressionModel}
onChangeModel={(model) => onChangeExpressionModel(language.model.name, model)}
/>
<div className="metadata-editor">
<MetadataEditor
data-testid="expression-editor"
name={'expression'}
schema={languageSchema}
metadata={expressionModel}
onChangeModel={(model) => onChangeExpressionModel(language.model.name, model)}
/>
</div>
)}
</>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ export class ExpressionService {
(parentModel.expression as Record<string, unknown>)[languageModelName] = newExpressionModel;
}

static deleteStepExpressionModel(parentModel: Record<string, unknown>): void {
if (parentModel.expression) {
delete parentModel.expression;
}
}

/**
* Parse the property expression model from the parent parameter model object.
* @param languageCatalogMap The language catalog map to use as a dictionary.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ describe('StepExpressionEditor', () => {
await act(async () => {
fireEvent.click(launcherButton[0]);
});
const dropdownButton = screen
.getAllByRole('button')
.filter((button) => button.innerHTML.includes(SchemaService.DROPDOWN_PLACEHOLDER));
const dropdown = screen
.getAllByTestId('typeahead-select-input')
.filter((input) => input.innerHTML.includes(SchemaService.DROPDOWN_PLACEHOLDER));
await act(async () => {
fireEvent.click(dropdownButton[0]);
fireEvent.click(dropdown[0]);
});
const jsonpath = screen.getByTestId('expression-dropdownitem-jsonpath');
fireEvent.click(jsonpath.getElementsByTagName('button')[0]);
Expand Down
Loading

0 comments on commit 5ffd944

Please sign in to comment.