From f45a242863dfbbc5d4d41fbba750afc9fb707f25 Mon Sep 17 00:00:00 2001 From: Shivam Gupta Date: Sat, 1 Jun 2024 04:19:39 +0530 Subject: [PATCH] fix(1055): Added TypeaheadEditor and reusing it with DataFormatEditor, ExpressionEditor, and LoadBalancerEditor --- .../Form/customField/TypeaheadEditor.test.tsx | 113 +++++++ .../Form/customField/TypeaheadEditor.tsx | 252 +++++++++++++++ .../Form/dataFormat/DataFormatEditor.scss | 4 +- .../Form/dataFormat/DataFormatEditor.tsx | 289 +++--------------- .../Form/dataFormat/dataformat.service.ts | 2 +- .../Form/expression/ExpressionEditor.scss | 14 +- .../Form/expression/ExpressionEditor.tsx | 246 ++------------- .../Form/loadBalancer/LoadBalancerEditor.scss | 10 +- .../Form/loadBalancer/LoadBalancerEditor.tsx | 269 +++------------- .../Form/loadBalancer/loadbalancer.service.ts | 2 +- 10 files changed, 499 insertions(+), 702 deletions(-) create mode 100644 packages/ui/src/components/Form/customField/TypeaheadEditor.test.tsx create mode 100644 packages/ui/src/components/Form/customField/TypeaheadEditor.tsx diff --git a/packages/ui/src/components/Form/customField/TypeaheadEditor.test.tsx b/packages/ui/src/components/Form/customField/TypeaheadEditor.test.tsx new file mode 100644 index 000000000..cafe149a9 --- /dev/null +++ b/packages/ui/src/components/Form/customField/TypeaheadEditor.test.tsx @@ -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( + , + ); + const inputElement = screen.getByRole('combobox'); + expect(inputElement).toBeInTheDocument(); + }); + + it('should display the options when the input is clicked', async () => { + render( + , + ); + 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( + , + ); + 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( + , + ); + 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, {}); + }); +}); diff --git a/packages/ui/src/components/Form/customField/TypeaheadEditor.tsx b/packages/ui/src/components/Form/customField/TypeaheadEditor.tsx new file mode 100644 index 000000000..6843c74fa --- /dev/null +++ b/packages/ui/src/components/Form/customField/TypeaheadEditor.tsx @@ -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 | undefined; + selectionOnChange: ( + selectedItem: { name: string; title: string } | undefined, + newItemModel: Record, + ) => void; +} + +export const TypeaheadEditor: FunctionComponent = (props) => { + const [isOpen, setIsOpen] = useState(false); + const [selected, setSelected] = useState(props.selected?.name || ''); + const [inputValue, setInputValue] = useState(props.selected?.title || ''); + const [filterValue, setFilterValue] = useState(''); + const [selectOptions, setSelectOptions] = useState(props.selectOptions); + const [focusedItemIndex, setFocusedItemIndex] = useState(null); + const [activeItem, setActiveItem] = useState(null); + const textInputRef = useRef(); + + 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 | 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, 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: Ref) => ( + + + + + + {!!inputValue && ( + + )} + + + + ); + + return ( + props.selectOptions && ( + <> + + {props.selected && ( + + props.selectionOnChange({ name: props.selected!.name, title: props.selected!.title }, model) + } + /> + )} + + ) + ); +}; diff --git a/packages/ui/src/components/Form/dataFormat/DataFormatEditor.scss b/packages/ui/src/components/Form/dataFormat/DataFormatEditor.scss index b2cc161a6..21534393e 100644 --- a/packages/ui/src/components/Form/dataFormat/DataFormatEditor.scss +++ b/packages/ui/src/components/Form/dataFormat/DataFormatEditor.scss @@ -1,9 +1,9 @@ .dataformat-metadata-editor { - .pf-v5-c-card { + .dataformat-metadata-editor-card { margin-bottom: 24px; } - .pf-v5-c-form { + [data-testid='dataformat-config-card'] form { margin-top: 24px; } } diff --git a/packages/ui/src/components/Form/dataFormat/DataFormatEditor.tsx b/packages/ui/src/components/Form/dataFormat/DataFormatEditor.tsx index ae5f66fa3..bef0bd813 100644 --- a/packages/ui/src/components/Form/dataFormat/DataFormatEditor.tsx +++ b/packages/ui/src/components/Form/dataFormat/DataFormatEditor.tsx @@ -1,28 +1,17 @@ import { - Button, Card, CardBody, CardExpandableContent, CardHeader, CardTitle, - MenuToggle, - MenuToggleElement, - Select, - SelectList, - SelectOption, SelectOptionProps, - TextInputGroup, - TextInputGroupMain, - TextInputGroupUtilities, } from '@patternfly/react-core'; -import { FunctionComponent, Ref, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { FunctionComponent, useCallback, useContext, useMemo, 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'; +import { TypeaheadEditor } from '../customField/TypeaheadEditor'; interface DataFormatEditorProps { selectedNode: CanvasNode; @@ -30,26 +19,12 @@ interface DataFormatEditorProps { export const DataFormatEditor: FunctionComponent = (props) => { const entitiesContext = useContext(EntitiesContext); - const [isOpen, setIsOpen] = useState(false); const [isExpanded, setIsExpanded] = useState(true); - const dataFormatCatalogMap = useMemo(() => { return DataFormatService.getDataFormatMap(); }, []); - const visualComponentSchema = props.selectedNode.data?.vizNode?.getComponentSchema(); - if (visualComponentSchema) { - if (!visualComponentSchema.definition) { - visualComponentSchema.definition = {}; - } - } - const { dataFormat, model: dataFormatModel } = DataFormatService.parseDataFormatModel( - dataFormatCatalogMap, - visualComponentSchema?.definition, - ); - const [selected, setSelected] = useState(dataFormat?.model.name || ''); - const [inputValue, setInputValue] = useState(dataFormat?.model.title || ''); - const initialDataFormatOptions = useMemo(() => { + const initialDataFormatOptions: SelectOptionProps[] = useMemo(() => { return Object.values(dataFormatCatalogMap).map((option) => { return { value: option.model.name, @@ -58,235 +33,69 @@ export const DataFormatEditor: FunctionComponent = (props }; }); }, [dataFormatCatalogMap]); - const [selectOptions, setSelectOptions] = useState(initialDataFormatOptions); - const [focusedItemIndex, setFocusedItemIndex] = useState(null); - const [activeItem, setActiveItem] = useState(null); - const [filterValue, setFilterValue] = useState(''); - const textInputRef = useRef(); - 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); - } + const visualComponentSchema = props.selectedNode.data?.vizNode?.getComponentSchema(); + if (visualComponentSchema) { + if (!visualComponentSchema.definition) { + visualComponentSchema.definition = {}; } + } - setSelectOptions(newSelectOptions); - setActiveItem(null); - setFocusedItemIndex(null); - }, [filterValue, initialDataFormatOptions, isOpen]); + const { dataFormat, model: dataFormatModel } = DataFormatService.parseDataFormatModel( + dataFormatCatalogMap, + visualComponentSchema?.definition, + ); + const dataFormatOption = dataFormat && { + name: dataFormat!.model.name, + title: dataFormat!.model.title, + }; + const [selectedDataFormatOption, setSelectedDataFormatOption] = useState<{ name: string; title: string } | undefined>( + dataFormatOption, + ); const dataFormatSchema = useMemo(() => { return DataFormatService.getDataFormatSchema(dataFormat); }, [dataFormat]); - const onToggleClick = useCallback(() => { - setIsOpen(!isOpen); - }, [isOpen]); - const handleOnChange = useCallback( - (selectedDataFormat: string, newDataFormatModel: Record) => { + ( + selectedDataFormatOption: { name: string; title: string } | undefined, + newDataFormatModel: Record, + ) => { + setSelectedDataFormatOption(selectedDataFormatOption); const model = props.selectedNode.data?.vizNode?.getComponentSchema()?.definition; if (!model) return; - DataFormatService.setDataFormatModel(dataFormatCatalogMap, model, selectedDataFormat, newDataFormatModel); + DataFormatService.setDataFormatModel( + dataFormatCatalogMap, + model, + selectedDataFormatOption ? selectedDataFormatOption!.name : '', + newDataFormatModel, + ); props.selectedNode.data?.vizNode?.updateModel(model); entitiesContext?.updateSourceCodeFromEntities(); }, [entitiesContext, dataFormatCatalogMap, props.selectedNode.data?.vizNode], ); - const onSelect = useCallback( - (_event: React.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); - setFocusedItemIndex(null); - setActiveItem(null); - }, - [selectOptions, handleOnChange], - ); - - 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: Ref) => ( - - - - - - {!!inputValue && ( - - )} - - - - ); - return ( - dataFormatCatalogMap && ( -
- - setIsExpanded(!isExpanded)}> - Data Format - - - - - {dataFormat && ( - handleOnChange(dataFormat.model.name, model)} - /> - )} - - - -
- ) +
+ + setIsExpanded(!isExpanded)}> + Data Format + + + + + + + +
); }; diff --git a/packages/ui/src/components/Form/dataFormat/dataformat.service.ts b/packages/ui/src/components/Form/dataFormat/dataformat.service.ts index 41f0b6691..53ac8dfdf 100644 --- a/packages/ui/src/components/Form/dataFormat/dataformat.service.ts +++ b/packages/ui/src/components/Form/dataFormat/dataformat.service.ts @@ -68,7 +68,7 @@ export class DataFormatService { } } - private static getDefinitionFromModelName( + static getDefinitionFromModelName( dataFormatCatalogMap: Record, modelName: string, ): ICamelDataformatDefinition | undefined { diff --git a/packages/ui/src/components/Form/expression/ExpressionEditor.scss b/packages/ui/src/components/Form/expression/ExpressionEditor.scss index d979da493..b8f03aff9 100644 --- a/packages/ui/src/components/Form/expression/ExpressionEditor.scss +++ b/packages/ui/src/components/Form/expression/ExpressionEditor.scss @@ -1,8 +1,10 @@ -.pf-v5-c-menu { - max-height: 500px; - overflow-y: auto; -} +.expression-metadata-editor { + #typeahead-select { + max-height: 500px; + overflow-y: auto; + } -.metadata-editor { - margin-top: 24px; + form { + margin-top: 24px; + } } diff --git a/packages/ui/src/components/Form/expression/ExpressionEditor.tsx b/packages/ui/src/components/Form/expression/ExpressionEditor.tsx index 4d3eb86a6..9a50adf55 100644 --- a/packages/ui/src/components/Form/expression/ExpressionEditor.tsx +++ b/packages/ui/src/components/Form/expression/ExpressionEditor.tsx @@ -1,22 +1,9 @@ -import { - Button, - MenuToggle, - MenuToggleElement, - Select, - SelectList, - SelectOption, - SelectOptionProps, - TextInputGroup, - TextInputGroupMain, - TextInputGroupUtilities, -} from '@patternfly/react-core'; -import { TimesIcon } from '@patternfly/react-icons'; -import { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { SelectOptionProps } from '@patternfly/react-core'; +import { FunctionComponent, useCallback, useMemo, useState } from 'react'; import { ICamelLanguageDefinition } from '../../../models'; -import { MetadataEditor } from '../../MetadataEditor'; -import { SchemaService } from '../schema.service'; import './ExpressionEditor.scss'; import { ExpressionService } from './expression.service'; +import { TypeaheadEditor } from '../customField/TypeaheadEditor'; interface ExpressionEditorProps { language?: ICamelLanguageDefinition; @@ -41,216 +28,39 @@ export const ExpressionEditor: FunctionComponent = ({ }); }, []); - 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 languageOption = language && { + name: language!.model.name, + title: language!.model.title, + }; + const [selectedLanguageOption, setSelectedLanguageOption] = useState<{ name: string; title: string } | undefined>( + languageOption, + ); const languageSchema = useMemo(() => { return language && ExpressionService.getLanguageSchema(language); }, [language]); - 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); - setFocusedItemIndex(null); - setActiveItem(null); + const handleOnChange = useCallback( + ( + selectedLanguageOption: { name: string; title: string } | undefined, + newlanguageModel: Record, + ) => { + setSelectedLanguageOption(selectedLanguageOption); + onChangeExpressionModel(selectedLanguageOption ? selectedLanguageOption!.name : '', newlanguageModel); }, - [languageCatalogMap, onChangeExpressionModel], - ); - - const onToggleClick = useCallback(() => { - setIsOpen(!isOpen); - }, [isOpen]); - - useEffect(() => { - let newSelectOptions: SelectOptionProps[] = languageCatalogMap; - - // Filter menu items based on the text input value when one exists - if (filterValue) { - const lowerFilterValue = filterValue.toLowerCase(); - newSelectOptions = languageCatalogMap.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, isOpen, languageCatalogMap]); - - 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 && ( - - )} - - - + [languageCatalogMap], ); return ( - languageCatalogMap && ( - <> - - {language && ( -
- onChangeExpressionModel(language.model.name, model)} - /> -
- )} - - ) +
+ +
); }; diff --git a/packages/ui/src/components/Form/loadBalancer/LoadBalancerEditor.scss b/packages/ui/src/components/Form/loadBalancer/LoadBalancerEditor.scss index e1115b4ba..92063c990 100644 --- a/packages/ui/src/components/Form/loadBalancer/LoadBalancerEditor.scss +++ b/packages/ui/src/components/Form/loadBalancer/LoadBalancerEditor.scss @@ -1,3 +1,9 @@ -.load-balancer-editor { - margin-top: 24px; +.loadbalancer-metadata-editor { + .loadbalancer-metadata-editor-card { + margin-bottom: 24px; + } + + [data-testid='loadbalancer-config-card'] form { + margin-top: 24px; + } } diff --git a/packages/ui/src/components/Form/loadBalancer/LoadBalancerEditor.tsx b/packages/ui/src/components/Form/loadBalancer/LoadBalancerEditor.tsx index 35c074270..a32a57d99 100644 --- a/packages/ui/src/components/Form/loadBalancer/LoadBalancerEditor.tsx +++ b/packages/ui/src/components/Form/loadBalancer/LoadBalancerEditor.tsx @@ -1,54 +1,31 @@ import { - Button, Card, CardBody, CardExpandableContent, CardHeader, CardTitle, - MenuToggle, - MenuToggleElement, - Select, - SelectList, - SelectOption, SelectOptionProps, - TextInputGroup, - TextInputGroupMain, - TextInputGroupUtilities, } from '@patternfly/react-core'; -import { FunctionComponent, Ref, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { FunctionComponent, useCallback, useContext, useMemo, useState } from 'react'; import { EntitiesContext } from '../../../providers'; -import { MetadataEditor } from '../../MetadataEditor'; import { CanvasNode } from '../../Visualization/Canvas/canvas.models'; import { LoadBalancerService } from './loadbalancer.service'; -import { SchemaService } from '../schema.service'; import './LoadBalancerEditor.scss'; -import { TimesIcon } from '@patternfly/react-icons'; +import { TypeaheadEditor } from '../customField/TypeaheadEditor'; + interface LoadBalancerEditorProps { selectedNode: CanvasNode; } export const LoadBalancerEditor: FunctionComponent = (props) => { const entitiesContext = useContext(EntitiesContext); - const [isOpen, setIsOpen] = useState(false); const [isExpanded, setIsExpanded] = useState(true); const loadBalancerCatalogMap = useMemo(() => { return LoadBalancerService.getLoadBalancerMap(); }, []); - const visualComponentSchema = props.selectedNode.data?.vizNode?.getComponentSchema(); - if (visualComponentSchema) { - if (!visualComponentSchema.definition) { - visualComponentSchema.definition = {}; - } - } - const { loadBalancer, model: loadBalancerModel } = LoadBalancerService.parseLoadBalancerModel( - loadBalancerCatalogMap, - visualComponentSchema?.definition, - ); - const [selected, setSelected] = useState(loadBalancer?.model.name || ''); - const [inputValue, setInputValue] = useState(loadBalancer?.model.title || ''); - const initialLoadBalancerOptions = useMemo(() => { + const initialLoadBalancerOptions: SelectOptionProps[] = useMemo(() => { return Object.values(loadBalancerCatalogMap).map((option) => { return { value: option.model.name, @@ -57,62 +34,41 @@ export const LoadBalancerEditor: FunctionComponent = (p }; }); }, [loadBalancerCatalogMap]); - const [selectOptions, setSelectOptions] = useState(initialLoadBalancerOptions); - const [focusedItemIndex, setFocusedItemIndex] = useState(null); - const [activeItem, setActiveItem] = useState(null); - const [filterValue, setFilterValue] = useState(''); - const textInputRef = useRef(); - useEffect(() => { - loadBalancer ? setSelected(loadBalancer.model.name) : setSelected(''); - }, [loadBalancer]); - - useEffect(() => { - let newSelectOptions: SelectOptionProps[] = initialLoadBalancerOptions; - - // Filter menu items based on the text input value when one exists - if (filterValue) { - const lowerFilterValue = filterValue.toLowerCase(); - newSelectOptions = initialLoadBalancerOptions.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); - } + const visualComponentSchema = props.selectedNode.data?.vizNode?.getComponentSchema(); + if (visualComponentSchema) { + if (!visualComponentSchema.definition) { + visualComponentSchema.definition = {}; } - - setSelectOptions(newSelectOptions); - setActiveItem(null); - setFocusedItemIndex(null); - }, [filterValue, initialLoadBalancerOptions, isOpen]); + } + const { loadBalancer, model: loadBalancerModel } = LoadBalancerService.parseLoadBalancerModel( + loadBalancerCatalogMap, + visualComponentSchema?.definition, + ); + const loadBalancerOption = loadBalancer && { + name: loadBalancer!.model.name, + title: loadBalancer!.model.title, + }; + const [selectedLoadBalancerOption, setSelectedLoadBalancerOption] = useState< + { name: string; title: string } | undefined + >(loadBalancerOption); const loadBalancerSchema = useMemo(() => { return LoadBalancerService.getLoadBalancerSchema(loadBalancer); }, [loadBalancer]); - const onToggleClick = useCallback(() => { - setIsOpen(!isOpen); - }, [isOpen]); - const handleOnChange = useCallback( - (selectedLoadBalancer: string, newLoadBalancerModel: Record) => { + ( + selectedLoadBalancerOption: { name: string; title: string } | undefined, + newLoadBalancerModel: Record, + ) => { + setSelectedLoadBalancerOption(selectedLoadBalancerOption); const model = props.selectedNode.data?.vizNode?.getComponentSchema()?.definition; if (!model) return; LoadBalancerService.setLoadBalancerModel( loadBalancerCatalogMap, model, - selectedLoadBalancer, + selectedLoadBalancerOption ? selectedLoadBalancerOption!.name : '', newLoadBalancerModel, ); props.selectedNode.data?.vizNode?.updateModel(model); @@ -121,176 +77,25 @@ export const LoadBalancerEditor: FunctionComponent = (p [entitiesContext, loadBalancerCatalogMap, props.selectedNode.data?.vizNode], ); - const onSelect = useCallback( - (_event: React.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); - setFocusedItemIndex(null); - setActiveItem(null); - }, - [handleOnChange, selectOptions], - ); - - 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: Ref) => ( - - - - - - {!!inputValue && ( - - )} - - - - ); - return ( - loadBalancerCatalogMap && ( - +
+ setIsExpanded(!isExpanded)}> Load Balancer - - {loadBalancer && ( -
- handleOnChange(loadBalancer.model.name, model)} - /> -
- )} +
- ) +
); }; diff --git a/packages/ui/src/components/Form/loadBalancer/loadbalancer.service.ts b/packages/ui/src/components/Form/loadBalancer/loadbalancer.service.ts index 1855ca20c..56a6017a5 100644 --- a/packages/ui/src/components/Form/loadBalancer/loadbalancer.service.ts +++ b/packages/ui/src/components/Form/loadBalancer/loadbalancer.service.ts @@ -68,7 +68,7 @@ export class LoadBalancerService { } } - private static getDefinitionFromModelName( + static getDefinitionFromModelName( loadBalancerCatalogMap: Record, modelName: string, ): ICamelLoadBalancerDefinition | undefined {