From 5ffd94479a480c11b6d445c2769fb4e53f8d5ff7 Mon Sep 17 00:00:00 2001 From: tplevko Date: Mon, 4 Mar 2024 14:06:23 +0100 Subject: [PATCH] fix(868) : Use a Typeahead component for the ExpressionField --- .../cypress/support/next-commands/design.ts | 6 +- .../Form/expression/ExpressionEditor.scss | 8 + .../Form/expression/ExpressionEditor.test.tsx | 14 +- .../Form/expression/ExpressionEditor.tsx | 257 ++++++++++++++---- .../Form/expression/expression.service.ts | 6 + .../StepExpressionEditor.test.tsx | 8 +- .../stepExpression/StepExpressionEditor.tsx | 6 +- .../Visualization/Canvas/CanvasForm.test.tsx | 8 +- 8 files changed, 238 insertions(+), 75 deletions(-) create mode 100644 packages/ui/src/components/Form/expression/ExpressionEditor.scss diff --git a/packages/ui-tests/cypress/support/next-commands/design.ts b/packages/ui-tests/cypress/support/next-commands/design.ts index 329679cf4..e7422993d 100644 --- a/packages/ui-tests/cypress/support/next-commands/design.ts +++ b/packages/ui-tests/cypress/support/next-commands/design.ts @@ -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); }); }); @@ -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(); }); diff --git a/packages/ui/src/components/Form/expression/ExpressionEditor.scss b/packages/ui/src/components/Form/expression/ExpressionEditor.scss new file mode 100644 index 000000000..d979da493 --- /dev/null +++ b/packages/ui/src/components/Form/expression/ExpressionEditor.scss @@ -0,0 +1,8 @@ +.pf-v5-c-menu { + max-height: 500px; + overflow-y: auto; +} + +.metadata-editor { + margin-top: 24px; +} diff --git a/packages/ui/src/components/Form/expression/ExpressionEditor.test.tsx b/packages/ui/src/components/Form/expression/ExpressionEditor.test.tsx index 20900e562..91ab77282 100644 --- a/packages/ui/src/components/Form/expression/ExpressionEditor.test.tsx +++ b/packages/ui/src/components/Form/expression/ExpressionEditor.test.tsx @@ -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(); @@ -24,8 +24,8 @@ describe('ExpressionEditor', () => { it('render empty simple if language is not specified', () => { render(); 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); }); @@ -42,7 +42,7 @@ describe('ExpressionEditor', () => { onChangeExpressionModel={onChangeMock} >, ); - 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') diff --git a/packages/ui/src/components/Form/expression/ExpressionEditor.tsx b/packages/ui/src/components/Form/expression/ExpressionEditor.tsx index c66c35473..9a09d263c 100644 --- a/packages/ui/src/components/Form/expression/ExpressionEditor.tsx +++ b/packages/ui/src/components/Form/expression/ExpressionEditor.tsx @@ -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; @@ -25,11 +29,26 @@ export const ExpressionEditor: FunctionComponent = ({ 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(language?.model.name || ''); + + const [isOpen, setIsOpen] = useState(false); + const [inputValue, setInputValue] = useState(language?.model.title || ''); + const [filterValue, setFilterValue] = useState(''); + const [selectOptions, setSelectOptions] = useState(languageCatalogMap); + const [focusedItemIndex, setFocusedItemIndex] = useState(null); + const [activeItem, setActiveItem] = useState(null); + const textInputRef = useRef(); + const [selected, setSelected] = useState(language?.model.title || ''); const languageSchema = useMemo(() => { return language && ExpressionService.getLanguageSchema(language); @@ -37,67 +56,193 @@ export const ExpressionEditor: FunctionComponent = ({ const onSelect = useCallback( (_event: React.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) => ( - - {language?.model.title || ( - - {SchemaService.DROPDOWN_PLACEHOLDER} - - )} - - ), - [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, 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) => { + 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) => ( + + + + + + {!!inputValue && ( + + )} + + + ); return ( languageCatalogMap && ( <> - { + setIsOpen(false); + }} toggle={toggle} - isScrollable={true} > - - {Object.values(languageCatalogMap).map((lang) => { - return ( - - {lang.model.title} - - ); - })} - - + + {selectOptions.map((option, index) => ( + setSelected(option.children as string)} + id={`select-typeahead-${option.value.replace(' ', '-')}`} + {...option} + value={option.children} + /> + ))} + + {language && ( - onChangeExpressionModel(language.model.name, model)} - /> +
+ onChangeExpressionModel(language.model.name, model)} + /> +
)} ) diff --git a/packages/ui/src/components/Form/expression/expression.service.ts b/packages/ui/src/components/Form/expression/expression.service.ts index 06496df9f..74bc8b0b3 100644 --- a/packages/ui/src/components/Form/expression/expression.service.ts +++ b/packages/ui/src/components/Form/expression/expression.service.ts @@ -138,6 +138,12 @@ export class ExpressionService { (parentModel.expression as Record)[languageModelName] = newExpressionModel; } + static deleteStepExpressionModel(parentModel: Record): 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. diff --git a/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.test.tsx b/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.test.tsx index f04d7f77f..66c5a14ef 100644 --- a/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.test.tsx +++ b/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.test.tsx @@ -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]); diff --git a/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.tsx b/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.tsx index a4f83431f..55662b48f 100644 --- a/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.tsx +++ b/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.tsx @@ -51,9 +51,11 @@ export const StepExpressionEditor: FunctionComponent const model = props.selectedNode.data?.vizNode?.getComponentSchema()?.definition || {}; if (preparedLanguage && preparedModel) { ExpressionService.setStepExpressionModel(languageCatalogMap, model, preparedLanguage.model.name, preparedModel); - props.selectedNode.data?.vizNode?.updateModel(model); - entitiesContext?.updateSourceCodeFromEntities(); + } else { + ExpressionService.deleteStepExpressionModel(model); } + props.selectedNode.data?.vizNode?.updateModel(model); + entitiesContext?.updateSourceCodeFromEntities(); }, [entitiesContext, languageCatalogMap, preparedLanguage, preparedModel, props.selectedNode.data?.vizNode]); const handleCancel = useCallback(() => { diff --git a/packages/ui/src/components/Visualization/Canvas/CanvasForm.test.tsx b/packages/ui/src/components/Visualization/Canvas/CanvasForm.test.tsx index f69d73997..6b7c680ad 100644 --- a/packages/ui/src/components/Visualization/Canvas/CanvasForm.test.tsx +++ b/packages/ui/src/components/Visualization/Canvas/CanvasForm.test.tsx @@ -197,8 +197,8 @@ describe('CanvasForm', () => { fireEvent.click(launchExpression); }); const button = screen - .getAllByRole('button') - .filter((button) => button.innerHTML.includes(SchemaService.DROPDOWN_PLACEHOLDER)); + .getAllByTestId('typeahead-select-input') + .filter((input) => input.innerHTML.includes(SchemaService.DROPDOWN_PLACEHOLDER)); act(() => { fireEvent.click(button[0]); }); @@ -268,8 +268,8 @@ describe('CanvasForm', () => { fireEvent.click(launchExpression); }); const button = screen - .getAllByRole('button') - .filter((button) => button.innerHTML.includes(SchemaService.DROPDOWN_PLACEHOLDER)); + .getAllByTestId('typeahead-select-input') + .filter((input) => input.innerHTML.includes(SchemaService.DROPDOWN_PLACEHOLDER)); act(() => { fireEvent.click(button[0]); });