This is an automated email from the ASF dual-hosted git repository. marat pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel-karavan.git
The following commit(s) were added to refs/heads/main by this push: new 4ce5f9f9 Create Kamelet from Existing #315 4ce5f9f9 is described below commit 4ce5f9f9e92c4c7b0bbcf0434dc6dc82efb10221 Author: Marat Gubaidullin <ma...@talismancloud.io> AuthorDate: Fri Oct 27 19:44:10 2023 -0400 Create Kamelet from Existing #315 --- .../route/property/KameletPropertyField.tsx | 1 - .../src/designer/ui/TypeaheadSelect.tsx | 233 +++++++++++++++++++++ karavan-designer/src/designer/utils/CamelUi.tsx | 2 +- karavan-space/src/designer/KaravanDesigner.tsx | 2 - .../src/designer/icons/ComponentIcons.tsx | 23 +- karavan-space/src/designer/icons/KaravanIcons.tsx | 3 +- .../src/designer/route/DslConnections.tsx | 2 + karavan-space/src/designer/route/DslElement.tsx | 14 +- karavan-space/src/designer/route/DslSelector.tsx | 3 +- karavan-space/src/designer/route/RouteDesigner.tsx | 53 +++-- .../route/property/KameletPropertyField.tsx | 1 - .../src/designer/route/useRouteDesignerHook.tsx | 42 +++- karavan-space/src/designer/ui/TypeaheadSelect.tsx | 233 +++++++++++++++++++++ karavan-space/src/designer/utils/CamelUi.tsx | 12 +- .../main/webui/src/designer/ui/TypeaheadSelect.tsx | 233 +++++++++++++++++++++ .../src/main/webui/src/designer/utils/CamelUi.tsx | 2 +- .../webui/src/project/files/CreateFileModal.tsx | 86 ++------ 17 files changed, 844 insertions(+), 101 deletions(-) diff --git a/karavan-designer/src/designer/route/property/KameletPropertyField.tsx b/karavan-designer/src/designer/route/property/KameletPropertyField.tsx index 0bac1c32..96b880c1 100644 --- a/karavan-designer/src/designer/route/property/KameletPropertyField.tsx +++ b/karavan-designer/src/designer/route/property/KameletPropertyField.tsx @@ -34,7 +34,6 @@ import ShowIcon from "@patternfly/react-icons/dist/js/icons/eye-icon"; import HideIcon from "@patternfly/react-icons/dist/js/icons/eye-slash-icon"; import DockerIcon from "@patternfly/react-icons/dist/js/icons/docker-icon"; import {usePropertiesHook} from "../usePropertiesHook"; -import {CamelUi} from "../../utils/CamelUi"; import {Select, SelectDirection, SelectOption, SelectVariant} from "@patternfly/react-core/deprecated"; interface Props { diff --git a/karavan-designer/src/designer/ui/TypeaheadSelect.tsx b/karavan-designer/src/designer/ui/TypeaheadSelect.tsx new file mode 100644 index 00000000..a529db43 --- /dev/null +++ b/karavan-designer/src/designer/ui/TypeaheadSelect.tsx @@ -0,0 +1,233 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + Select, + SelectOption, + SelectList, + SelectOptionProps, + MenuToggle, + MenuToggleElement, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button +} from '@patternfly/react-core'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +export interface Value { + value: string + children: string +} + +interface Props { + listOfValues: Value[] + onSelect: (value: string) => void +} + +export function TypeaheadSelect(props: Props) { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState<string>(''); + const [inputValue, setInputValue] = React.useState<string>(''); + const [filterValue, setFilterValue] = React.useState<string>(''); + const [selectOptions, setSelectOptions] = React.useState<SelectOptionProps[]>(props.listOfValues); + const [focusedItemIndex, setFocusedItemIndex] = React.useState<number | undefined>(undefined); + const [activeItem, setActiveItem] = React.useState<string | undefined>(undefined); + const textInputRef = React.useRef<HTMLInputElement>(); + + React.useEffect(() => { + let newSelectOptions: SelectOptionProps[] = props.listOfValues; + + // Filter menu items based on the text input value when one exists + if (filterValue) { + newSelectOptions = props.listOfValues.filter((menuItem) => + String(menuItem.children).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(undefined); + setFocusedItemIndex(undefined); + }, [filterValue]); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => { + if (value) { + props.onSelect( value.toString()) + } + + if (value && value !== 'no results') { + setInputValue(value as string); + setFilterValue(''); + const text = props.listOfValues.filter(v => v.value === value).at(0)?.children; + setSelected(text || value.toString()); + setInputValue(text || value.toString()); + } + setIsOpen(false); + setFocusedItemIndex(undefined); + setActiveItem(undefined); + }; + + 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 === undefined || 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 === undefined || focusedItemIndex === selectOptions.length - 1) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + } + + if (indexToFocus !== undefined) { + setFocusedItemIndex(indexToFocus); + const focusedItem = selectOptions[indexToFocus]; + setActiveItem(`select-typeahead-${focusedItem.value}`); + } + } + }; + + 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)); + props.onSelect(focusedItem.value) + } + + setIsOpen((prevIsOpen) => !prevIsOpen); + setFocusedItemIndex(undefined); + setActiveItem(undefined); + + break; + case 'Tab': + case 'Escape': + setIsOpen(false); + setActiveItem(undefined); + 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="typeahead-select-input" + autoComplete="off" + innerRef={textInputRef} + placeholder="Select a state" + {...(activeItem && { 'aria-activedescendant': activeItem })} + role="combobox" + isExpanded={isOpen} + aria-controls="select-typeahead-listbox" + /> + + <TextInputGroupUtilities> + {!!inputValue && ( + <Button + variant="plain" + onClick={() => { + setSelected(''); + setInputValue(''); + setFilterValue(''); + textInputRef?.current?.focus(); + }} + aria-label="Clear input value" + > + <TimesIcon aria-hidden /> + </Button> + )} + </TextInputGroupUtilities> + </TextInputGroup> + </MenuToggle> + ); + + return ( + <Select + id="typeahead-select" + isOpen={isOpen} + selected={selected} + onSelect={onSelect} + onOpenChange={() => { + setIsOpen(false); + }} + toggle={toggle} + > + <SelectList id="select-typeahead-listbox"> + {selectOptions.map((option, index) => ( + <SelectOption + key={option.value || option.children} + isFocused={focusedItemIndex === index} + className={option.className} + onClick={() => setSelected(option.value)} + id={`select-typeahead-${option.value}`} + {...option} + ref={null} + /> + ))} + </SelectList> + </Select> + ); +}; diff --git a/karavan-designer/src/designer/utils/CamelUi.tsx b/karavan-designer/src/designer/utils/CamelUi.tsx index 2b672a8d..ec4d5796 100644 --- a/karavan-designer/src/designer/utils/CamelUi.tsx +++ b/karavan-designer/src/designer/utils/CamelUi.tsx @@ -108,7 +108,7 @@ const StepElements: string[] = [ // "ErrorHandlerDefinition", "FilterDefinition", "IdempotentConsumerDefinition", - "KameletDefinition", + // "KameletDefinition", "LogDefinition", "LoopDefinition", "MarshalDefinition", diff --git a/karavan-space/src/designer/KaravanDesigner.tsx b/karavan-space/src/designer/KaravanDesigner.tsx index 4b7b3c7a..b1af73c5 100644 --- a/karavan-space/src/designer/KaravanDesigner.tsx +++ b/karavan-space/src/designer/KaravanDesigner.tsx @@ -24,8 +24,6 @@ import { Tabs, TabTitleIcon, TabTitleText, - Tooltip, - TooltipPosition, } from '@patternfly/react-core'; import './karavan.css'; import {RouteDesigner} from "./route/RouteDesigner"; diff --git a/karavan-space/src/designer/icons/ComponentIcons.tsx b/karavan-space/src/designer/icons/ComponentIcons.tsx index a05f5e70..bda35d53 100644 --- a/karavan-space/src/designer/icons/ComponentIcons.tsx +++ b/karavan-space/src/designer/icons/ComponentIcons.tsx @@ -1165,7 +1165,28 @@ export function ApiIcon() { ); } -export function MonitoringIcon() { +export function KameletIcon() { + return ( + <svg + className="icon" id="icon" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 32 32" + > + <title>{"application"}</title> + <path d="M16 18H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2ZM6 6v10h10V6ZM26 12v4h-4v-4h4m0-2h-4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2ZM26 22v4h-4v-4h4m0-2h-4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2ZM16 22v4h-4v-4h4m0-2h-4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2Z" /> + <path + d="M0 0h32v32H0z" + data-name="<Transparent Rectangle>" + style={{ + fill: "none", + }} + /> + </svg> + ) +} + + + export function MonitoringIcon() { return ( <svg xmlns="http://www.w3.org/2000/svg" diff --git a/karavan-space/src/designer/icons/KaravanIcons.tsx b/karavan-space/src/designer/icons/KaravanIcons.tsx index b7165b93..b0b8042f 100644 --- a/karavan-space/src/designer/icons/KaravanIcons.tsx +++ b/karavan-space/src/designer/icons/KaravanIcons.tsx @@ -260,7 +260,7 @@ export function CamelIcon(props?: (JSX.IntrinsicAttributes & React.SVGProps<SVGS ); } -export function getDesignerIcon(icon: string) { +export function getDesignerIcon(icon: string): React.JSX.Element { if (icon === 'kamelet') return ( <svg className="top-icon" id="icon" @@ -429,6 +429,7 @@ export function getDesignerIcon(icon: string) { <rect id="_Transparent_Rectangle_" data-name="<Transparent Rectangle>" className="cls-1" width="32" height="32" transform="translate(0 32) rotate(-90)"/> </svg>) + return <></>; } diff --git a/karavan-space/src/designer/route/DslConnections.tsx b/karavan-space/src/designer/route/DslConnections.tsx index de6a0060..a636339e 100644 --- a/karavan-space/src/designer/route/DslConnections.tsx +++ b/karavan-space/src/designer/route/DslConnections.tsx @@ -63,6 +63,7 @@ export function DslConnections() { .filter(pos => ["FromDefinition"].includes(pos.step.dslName)) .filter(pos => !TopologyUtils.isElementInternalComponent(pos.step)) .filter(pos => !(pos.step.dslName === 'FromDefinition' && TopologyUtils.hasInternalUri(pos.step))) + .filter(pos => !(pos.step.dslName === 'FromDefinition' && (pos.step as any).uri === 'kamelet:source')) .sort((pos1: DslPosition, pos2: DslPosition) => { const y1 = pos1.headerRect.y + pos1.headerRect.height / 2; const y2 = pos2.headerRect.y + pos2.headerRect.height / 2; @@ -142,6 +143,7 @@ export function DslConnections() { .filter(pos => pos.step.dslName === 'ToDefinition' && !CamelUi.isActionKamelet(pos.step) && !TopologyUtils.isElementInternalComponent(pos.step)) .filter(pos => !(outgoingDefinitions.includes(pos.step.dslName) && TopologyUtils.hasInternalUri(pos.step))) .filter(pos => pos.step.dslName !== 'SagaDefinition') + .filter(pos => !CamelUi.isKameletSink(pos.step)) .sort((pos1: DslPosition, pos2: DslPosition) => { const y1 = pos1.headerRect.y + pos1.headerRect.height / 2; const y2 = pos2.headerRect.y + pos2.headerRect.height / 2; diff --git a/karavan-space/src/designer/route/DslElement.tsx b/karavan-space/src/designer/route/DslElement.tsx index 305b7859..9e4a8c70 100644 --- a/karavan-space/src/designer/route/DslElement.tsx +++ b/karavan-space/src/designer/route/DslElement.tsx @@ -42,7 +42,7 @@ interface Props { export function DslElement(props: Props) { const headerRef = React.useRef<HTMLDivElement>(null); - const {selectElement, moveElement, onShowDeleteConfirmation, openSelector} = useRouteDesignerHook(); + const {selectElement, moveElement, onShowDeleteConfirmation, openSelector, isKamelet, isSourceKamelet, isActionKamelet} = useRouteDesignerHook(); const [integration] = useIntegrationStore((s) => [s.integration, s.setIntegration], shallow) @@ -241,9 +241,19 @@ export function DslElement(props: Props) { ) } + function getHeaderText(step: CamelElement): string { + if (isKamelet() && step.dslName === 'ToDefinition' && (step as any).uri === 'kamelet:sink') { + return "Sink"; + } else if (isKamelet() && step.dslName === 'FromDefinition' && (step as any).uri === 'kamelet:source') { + return "Source"; + } else { + return (step as any).description ? (step as any).description : CamelUi.getElementTitle(props.step); + } + } + function getHeaderTextWithTooltip(step: CamelElement) { const checkRequired = CamelUtil.checkRequired(step); - const title = (step as any).description ? (step as any).description : CamelUi.getElementTitle(props.step); + const title = getHeaderText(step); let className = hasWideChildrenElement() ? "text text-right" : "text text-bottom"; if (!checkRequired[0]) className = className + " header-text-required"; if (checkRequired[0]) { diff --git a/karavan-space/src/designer/route/DslSelector.tsx b/karavan-space/src/designer/route/DslSelector.tsx index 8f815536..a80525ba 100644 --- a/karavan-space/src/designer/route/DslSelector.tsx +++ b/karavan-space/src/designer/route/DslSelector.tsx @@ -24,7 +24,7 @@ import { import '../karavan.css'; import {CamelUi} from "../utils/CamelUi"; import {DslMetaModel} from "../utils/DslMetaModel"; -import {useDesignerStore, useSelectorStore} from "../DesignerStore"; +import {useDesignerStore, useIntegrationStore, useSelectorStore} from "../DesignerStore"; import {shallow} from "zustand/shallow"; import {useRouteDesignerHook} from "./useRouteDesignerHook"; @@ -40,7 +40,6 @@ export function DslSelector (props: Props) { [s.showSelector, s.showSteps, s.parentId, s.parentDsl, s.selectorTabIndex, s.setShowSelector, s.setSelectorTabIndex, s.selectedPosition, s.selectedLabels, s.addSelectedLabel, s.deleteSelectedLabel], shallow) - const [dark] = useDesignerStore((s) => [s.dark], shallow) const {onDslSelect} = useRouteDesignerHook(); diff --git a/karavan-space/src/designer/route/RouteDesigner.tsx b/karavan-space/src/designer/route/RouteDesigner.tsx index 215f334b..c57260db 100644 --- a/karavan-space/src/designer/route/RouteDesigner.tsx +++ b/karavan-space/src/designer/route/RouteDesigner.tsx @@ -40,7 +40,8 @@ import {DslElementMoveModal} from "./DslElementMoveModal"; export function RouteDesigner() { - const {openSelector, createRouteConfiguration, onCommand, handleKeyDown, handleKeyUp, unselectElement} = useRouteDesignerHook(); + const {openSelector, createRouteConfiguration, onCommand, handleKeyDown, handleKeyUp, unselectElement, onDslSelect, + isSourceKamelet, isActionKamelet, isKamelet, isSinkKamelet} = useRouteDesignerHook(); const [integration] = useIntegrationStore((state) => [state.integration], shallow) const [showDeleteConfirmation, setPosition, width, height, top, left, hideLogDSL, showMoveConfirmation, setShowMoveConfirmation] = @@ -104,6 +105,37 @@ export function RouteDesigner() { ) } + function getGraphButtons() { + const routes = CamelUi.getRoutes(integration); + const showNewRoute = (isKamelet() && routes.length === 0) || !isKamelet(); + const showNewRouteConfiguration = !isKamelet(); + return ( + <div className="add-flow"> + {showNewRoute && <Button + variant={routes.length === 0 ? "primary" : "secondary"} + icon={<PlusIcon/>} + onClick={e => { + if (isSinkKamelet() || isActionKamelet()) { + const dsl = CamelUi.getDslMetaModel('FromDefinition'); + dsl.uri = 'kamelet:source'; + onDslSelect(dsl, '', undefined); + } else { + openSelector(undefined, undefined) + } + }} + > + Create route + </Button>} + {showNewRouteConfiguration && <Button + variant="secondary" + icon={<PlusIcon/>} + onClick={e => createRouteConfiguration()} + > + Create configuration + </Button>} + </div> + ) + } function getGraph() { const routes = CamelUi.getRoutes(integration); const routeConfigurations = CamelUi.getRouteConfigurations(integration); @@ -129,24 +161,7 @@ export function RouteDesigner() { step={route} parent={undefined}/> ))} - <div className="add-flow"> - <Button - variant={routes.length === 0 ? "primary" : "secondary"} - icon={<PlusIcon/>} - onClick={e => { - openSelector(undefined, undefined) - }} - > - Create route - </Button> - <Button - variant="secondary" - icon={<PlusIcon/>} - onClick={e => createRouteConfiguration()} - > - Create configuration - </Button> - </div> + {getGraphButtons()} </div> </div>) } diff --git a/karavan-space/src/designer/route/property/KameletPropertyField.tsx b/karavan-space/src/designer/route/property/KameletPropertyField.tsx index 0bac1c32..96b880c1 100644 --- a/karavan-space/src/designer/route/property/KameletPropertyField.tsx +++ b/karavan-space/src/designer/route/property/KameletPropertyField.tsx @@ -34,7 +34,6 @@ import ShowIcon from "@patternfly/react-icons/dist/js/icons/eye-icon"; import HideIcon from "@patternfly/react-icons/dist/js/icons/eye-slash-icon"; import DockerIcon from "@patternfly/react-icons/dist/js/icons/docker-icon"; import {usePropertiesHook} from "../usePropertiesHook"; -import {CamelUi} from "../../utils/CamelUi"; import {Select, SelectDirection, SelectOption, SelectVariant} from "@patternfly/react-core/deprecated"; interface Props { diff --git a/karavan-space/src/designer/route/useRouteDesignerHook.tsx b/karavan-space/src/designer/route/useRouteDesignerHook.tsx index fe61d410..f48fc850 100644 --- a/karavan-space/src/designer/route/useRouteDesignerHook.tsx +++ b/karavan-space/src/designer/route/useRouteDesignerHook.tsx @@ -19,7 +19,7 @@ import '../karavan.css'; import {DslMetaModel} from "../utils/DslMetaModel"; import {CamelUtil} from "karavan-core/lib/api/CamelUtil"; import {ChoiceDefinition, FromDefinition, LogDefinition, RouteConfigurationDefinition, RouteDefinition} from "karavan-core/lib/model/CamelDefinition"; -import {CamelElement} from "karavan-core/lib/model/IntegrationDefinition"; +import {CamelElement, MetadataLabels} from "karavan-core/lib/model/IntegrationDefinition"; import {CamelDefinitionApiExt} from "karavan-core/lib/api/CamelDefinitionApiExt"; import {CamelDefinitionApi} from "karavan-core/lib/api/CamelDefinitionApi"; import {Command, EventBus} from "../utils/EventBus"; @@ -27,6 +27,7 @@ import {CamelDisplayUtil} from "karavan-core/lib/api/CamelDisplayUtil"; import {toPng} from 'html-to-image'; import {useDesignerStore, useIntegrationStore, useSelectorStore} from "../DesignerStore"; import {shallow} from "zustand/shallow"; +import {v4 as uuidv4} from 'uuid'; export function useRouteDesignerHook () { @@ -46,6 +47,34 @@ export function useRouteDesignerHook () { } } + function isKamelet(): boolean { + return integration.type === 'kamelet'; + } + + function isSourceKamelet(): boolean { + if (isKamelet()){ + const m: MetadataLabels | undefined = integration.metadata.labels; + return m !== undefined && m["camel.apache.org/kamelet.type"] === 'source'; + } + return false; + } + + function isSinkKamelet(): boolean { + if (isKamelet()){ + const m: MetadataLabels | undefined = integration.metadata.labels; + return m !== undefined && m["camel.apache.org/kamelet.type"] === 'sink'; + } + return false; + } + + function isActionKamelet(): boolean { + if (isKamelet()){ + const m: MetadataLabels | undefined = integration.metadata.labels; + return m !== undefined && m["camel.apache.org/kamelet.type"] === 'action'; + } + return false; + } + const onShowDeleteConfirmation = (id: string) => { let message: string; const uuidsToDelete:string [] = [id]; @@ -193,13 +222,17 @@ export function useRouteDesignerHook () { setSelectorTabIndex((parentId === undefined && parentDsl === undefined) ? 'kamelet' : 'eip'); } - const onDslSelect = (dsl: DslMetaModel, parentId: string, position?: number | undefined) => { + function onDslSelect (dsl: DslMetaModel, parentId: string, position?: number | undefined) { switch (dsl.dsl) { case 'FromDefinition' : - const route = CamelDefinitionApi.createRouteDefinition({from: new FromDefinition({uri: dsl.uri})}); + const nodePrefixId = isKamelet() ? integration.metadata.name : 'route-' + uuidv4().substring(0,3); + const route = CamelDefinitionApi.createRouteDefinition({from: new FromDefinition({uri: dsl.uri}), nodePrefixId: nodePrefixId}); addStep(route, parentId, position) break; case 'ToDefinition' : + if (dsl.uri === undefined && isKamelet()) { + dsl.uri = 'kamelet:sink'; + } const to = CamelDefinitionApi.createStep(dsl.dsl, {uri: dsl.uri}); addStep(to, parentId, position) break; @@ -291,5 +324,6 @@ export function useRouteDesignerHook () { } return { deleteElement, selectElement, moveElement, onShowDeleteConfirmation, onDslSelect, openSelector, - createRouteConfiguration, onCommand, handleKeyDown, handleKeyUp, unselectElement} + createRouteConfiguration, onCommand, handleKeyDown, handleKeyUp, unselectElement, isKamelet, isSourceKamelet, + isActionKamelet, isSinkKamelet} } \ No newline at end of file diff --git a/karavan-space/src/designer/ui/TypeaheadSelect.tsx b/karavan-space/src/designer/ui/TypeaheadSelect.tsx new file mode 100644 index 00000000..a529db43 --- /dev/null +++ b/karavan-space/src/designer/ui/TypeaheadSelect.tsx @@ -0,0 +1,233 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + Select, + SelectOption, + SelectList, + SelectOptionProps, + MenuToggle, + MenuToggleElement, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button +} from '@patternfly/react-core'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +export interface Value { + value: string + children: string +} + +interface Props { + listOfValues: Value[] + onSelect: (value: string) => void +} + +export function TypeaheadSelect(props: Props) { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState<string>(''); + const [inputValue, setInputValue] = React.useState<string>(''); + const [filterValue, setFilterValue] = React.useState<string>(''); + const [selectOptions, setSelectOptions] = React.useState<SelectOptionProps[]>(props.listOfValues); + const [focusedItemIndex, setFocusedItemIndex] = React.useState<number | undefined>(undefined); + const [activeItem, setActiveItem] = React.useState<string | undefined>(undefined); + const textInputRef = React.useRef<HTMLInputElement>(); + + React.useEffect(() => { + let newSelectOptions: SelectOptionProps[] = props.listOfValues; + + // Filter menu items based on the text input value when one exists + if (filterValue) { + newSelectOptions = props.listOfValues.filter((menuItem) => + String(menuItem.children).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(undefined); + setFocusedItemIndex(undefined); + }, [filterValue]); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => { + if (value) { + props.onSelect( value.toString()) + } + + if (value && value !== 'no results') { + setInputValue(value as string); + setFilterValue(''); + const text = props.listOfValues.filter(v => v.value === value).at(0)?.children; + setSelected(text || value.toString()); + setInputValue(text || value.toString()); + } + setIsOpen(false); + setFocusedItemIndex(undefined); + setActiveItem(undefined); + }; + + 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 === undefined || 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 === undefined || focusedItemIndex === selectOptions.length - 1) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + } + + if (indexToFocus !== undefined) { + setFocusedItemIndex(indexToFocus); + const focusedItem = selectOptions[indexToFocus]; + setActiveItem(`select-typeahead-${focusedItem.value}`); + } + } + }; + + 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)); + props.onSelect(focusedItem.value) + } + + setIsOpen((prevIsOpen) => !prevIsOpen); + setFocusedItemIndex(undefined); + setActiveItem(undefined); + + break; + case 'Tab': + case 'Escape': + setIsOpen(false); + setActiveItem(undefined); + 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="typeahead-select-input" + autoComplete="off" + innerRef={textInputRef} + placeholder="Select a state" + {...(activeItem && { 'aria-activedescendant': activeItem })} + role="combobox" + isExpanded={isOpen} + aria-controls="select-typeahead-listbox" + /> + + <TextInputGroupUtilities> + {!!inputValue && ( + <Button + variant="plain" + onClick={() => { + setSelected(''); + setInputValue(''); + setFilterValue(''); + textInputRef?.current?.focus(); + }} + aria-label="Clear input value" + > + <TimesIcon aria-hidden /> + </Button> + )} + </TextInputGroupUtilities> + </TextInputGroup> + </MenuToggle> + ); + + return ( + <Select + id="typeahead-select" + isOpen={isOpen} + selected={selected} + onSelect={onSelect} + onOpenChange={() => { + setIsOpen(false); + }} + toggle={toggle} + > + <SelectList id="select-typeahead-listbox"> + {selectOptions.map((option, index) => ( + <SelectOption + key={option.value || option.children} + isFocused={focusedItemIndex === index} + className={option.className} + onClick={() => setSelected(option.value)} + id={`select-typeahead-${option.value}`} + {...option} + ref={null} + /> + ))} + </SelectList> + </Select> + ); +}; diff --git a/karavan-space/src/designer/utils/CamelUi.tsx b/karavan-space/src/designer/utils/CamelUi.tsx index 6864361b..ec4d5796 100644 --- a/karavan-space/src/designer/utils/CamelUi.tsx +++ b/karavan-space/src/designer/utils/CamelUi.tsx @@ -51,7 +51,7 @@ import { IgniteIcon, InfinispanIcon, IotIcon, - KafkaIcon, + KafkaIcon, KameletIcon, KubernetesIcon, MachineLearningIcon, MailIcon, @@ -93,6 +93,7 @@ import { import React from "react"; import {TopologyUtils} from "karavan-core/lib/api/TopologyUtils"; import {CamelDisplayUtil} from "karavan-core/lib/api/CamelDisplayUtil"; +import {getDesignerIcon} from "../icons/KaravanIcons"; const StepElements: string[] = [ "AggregateDefinition", @@ -107,6 +108,7 @@ const StepElements: string[] = [ // "ErrorHandlerDefinition", "FilterDefinition", "IdempotentConsumerDefinition", + // "KameletDefinition", "LogDefinition", "LoopDefinition", "MarshalDefinition", @@ -306,6 +308,10 @@ export class CamelUi { else return false; } + static isKameletSink = (element: CamelElement): boolean => { + return element.dslName === 'ToDefinition' && (element as any).uri === 'kamelet:sink'; + } + static getInternalRouteUris = (integration: Integration, componentName: string, showComponentName: boolean = true): string[] => { const result: string[] = []; integration.spec.flows?.filter(f => f.dslName === 'RouteDefinition') @@ -519,7 +525,7 @@ export class CamelUi { } } - static getIconForDsl = (dsl: DslMetaModel): JSX.Element => { + static getIconForDsl = (dsl: DslMetaModel): React.JSX.Element => { if (dsl.dsl && (dsl.dsl === "KameletDefinition" || dsl.navigation === 'kamelet')) { return this.getIconFromSource(CamelUi.getKameletIconByName(dsl.name)); } else if ((dsl.dsl && dsl.dsl === "FromDefinition") @@ -687,6 +693,8 @@ export class CamelUi { return <ApiIcon/>; case 'HeadDefinition' : return <ApiIcon/>; + case 'KameletDefinition' : + return <KameletIcon/>; default: return this.getIconFromSource(CamelUi.getIconSrcForName(dslName)) } diff --git a/karavan-web/karavan-app/src/main/webui/src/designer/ui/TypeaheadSelect.tsx b/karavan-web/karavan-app/src/main/webui/src/designer/ui/TypeaheadSelect.tsx new file mode 100644 index 00000000..a529db43 --- /dev/null +++ b/karavan-web/karavan-app/src/main/webui/src/designer/ui/TypeaheadSelect.tsx @@ -0,0 +1,233 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + Select, + SelectOption, + SelectList, + SelectOptionProps, + MenuToggle, + MenuToggleElement, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button +} from '@patternfly/react-core'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +export interface Value { + value: string + children: string +} + +interface Props { + listOfValues: Value[] + onSelect: (value: string) => void +} + +export function TypeaheadSelect(props: Props) { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState<string>(''); + const [inputValue, setInputValue] = React.useState<string>(''); + const [filterValue, setFilterValue] = React.useState<string>(''); + const [selectOptions, setSelectOptions] = React.useState<SelectOptionProps[]>(props.listOfValues); + const [focusedItemIndex, setFocusedItemIndex] = React.useState<number | undefined>(undefined); + const [activeItem, setActiveItem] = React.useState<string | undefined>(undefined); + const textInputRef = React.useRef<HTMLInputElement>(); + + React.useEffect(() => { + let newSelectOptions: SelectOptionProps[] = props.listOfValues; + + // Filter menu items based on the text input value when one exists + if (filterValue) { + newSelectOptions = props.listOfValues.filter((menuItem) => + String(menuItem.children).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(undefined); + setFocusedItemIndex(undefined); + }, [filterValue]); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => { + if (value) { + props.onSelect( value.toString()) + } + + if (value && value !== 'no results') { + setInputValue(value as string); + setFilterValue(''); + const text = props.listOfValues.filter(v => v.value === value).at(0)?.children; + setSelected(text || value.toString()); + setInputValue(text || value.toString()); + } + setIsOpen(false); + setFocusedItemIndex(undefined); + setActiveItem(undefined); + }; + + 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 === undefined || 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 === undefined || focusedItemIndex === selectOptions.length - 1) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + } + + if (indexToFocus !== undefined) { + setFocusedItemIndex(indexToFocus); + const focusedItem = selectOptions[indexToFocus]; + setActiveItem(`select-typeahead-${focusedItem.value}`); + } + } + }; + + 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)); + props.onSelect(focusedItem.value) + } + + setIsOpen((prevIsOpen) => !prevIsOpen); + setFocusedItemIndex(undefined); + setActiveItem(undefined); + + break; + case 'Tab': + case 'Escape': + setIsOpen(false); + setActiveItem(undefined); + 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="typeahead-select-input" + autoComplete="off" + innerRef={textInputRef} + placeholder="Select a state" + {...(activeItem && { 'aria-activedescendant': activeItem })} + role="combobox" + isExpanded={isOpen} + aria-controls="select-typeahead-listbox" + /> + + <TextInputGroupUtilities> + {!!inputValue && ( + <Button + variant="plain" + onClick={() => { + setSelected(''); + setInputValue(''); + setFilterValue(''); + textInputRef?.current?.focus(); + }} + aria-label="Clear input value" + > + <TimesIcon aria-hidden /> + </Button> + )} + </TextInputGroupUtilities> + </TextInputGroup> + </MenuToggle> + ); + + return ( + <Select + id="typeahead-select" + isOpen={isOpen} + selected={selected} + onSelect={onSelect} + onOpenChange={() => { + setIsOpen(false); + }} + toggle={toggle} + > + <SelectList id="select-typeahead-listbox"> + {selectOptions.map((option, index) => ( + <SelectOption + key={option.value || option.children} + isFocused={focusedItemIndex === index} + className={option.className} + onClick={() => setSelected(option.value)} + id={`select-typeahead-${option.value}`} + {...option} + ref={null} + /> + ))} + </SelectList> + </Select> + ); +}; diff --git a/karavan-web/karavan-app/src/main/webui/src/designer/utils/CamelUi.tsx b/karavan-web/karavan-app/src/main/webui/src/designer/utils/CamelUi.tsx index 2b672a8d..ec4d5796 100644 --- a/karavan-web/karavan-app/src/main/webui/src/designer/utils/CamelUi.tsx +++ b/karavan-web/karavan-app/src/main/webui/src/designer/utils/CamelUi.tsx @@ -108,7 +108,7 @@ const StepElements: string[] = [ // "ErrorHandlerDefinition", "FilterDefinition", "IdempotentConsumerDefinition", - "KameletDefinition", + // "KameletDefinition", "LogDefinition", "LoopDefinition", "MarshalDefinition", diff --git a/karavan-web/karavan-app/src/main/webui/src/project/files/CreateFileModal.tsx b/karavan-web/karavan-app/src/main/webui/src/project/files/CreateFileModal.tsx index 53473734..688c37e9 100644 --- a/karavan-web/karavan-app/src/main/webui/src/project/files/CreateFileModal.tsx +++ b/karavan-web/karavan-app/src/main/webui/src/project/files/CreateFileModal.tsx @@ -22,9 +22,7 @@ import { FormGroup, ModalVariant, Form, - ToggleGroupItem, ToggleGroup, FormHelperText, HelperText, HelperTextItem, TextInput, Select, - SelectOption, - SelectList, MenuToggleElement, MenuToggle, TextInputGroup, TextInputGroupMain, TextInputGroupUtilities, + ToggleGroupItem, ToggleGroup, FormHelperText, HelperText, HelperTextItem, TextInput, } from '@patternfly/react-core'; import '../../designer/karavan.css'; import {Integration, KameletTypes, MetadataLabels} from "karavan-core/lib/model/IntegrationDefinition"; @@ -36,6 +34,7 @@ import {ProjectService} from "../../api/ProjectService"; import {shallow} from "zustand/shallow"; import {CamelUtil} from "karavan-core/lib/api/CamelUtil"; import {KameletApi} from "karavan-core/lib/api/KameletApi"; +import {TypeaheadSelect, Value} from "../../designer/ui/TypeaheadSelect"; interface Props { types: string[], @@ -49,9 +48,7 @@ export function CreateFileModal(props: Props) { const [name, setName] = useState<string>(''); const [fileType, setFileType] = useState<string>(); const [kameletType, setKameletType] = useState<KameletTypes>('source'); - const [inputValue, setInputValue] = useState<string>(); - const [selectIsOpen, setSelectIsOpen] = useState<boolean>(false); - const [selectedKamelet, setSelectedKamelet] = useState<[string, string]>(['', '']); + const [selectedKamelet, setSelectedKamelet] = useState<string>(); useEffect(() => { if (props.types.length > 0) { @@ -73,11 +70,20 @@ export function CreateFileModal(props: Props) { if (fileType === 'INTEGRATION') { return CamelDefinitionYaml.integrationToYaml(Integration.createNew(name, 'plain')); } else if (fileType === 'KAMELET') { - console.log(selectedKamelet, inputValue); const kameletName = name + (isKamelet ? '-' + kameletType : ''); const integration = Integration.createNew(kameletName, 'kamelet'); const meta: MetadataLabels = new MetadataLabels({"camel.apache.org/kamelet.type": kameletType}); integration.metadata.labels = meta; + if (selectedKamelet !== undefined && selectedKamelet !== '') { + const kamelet= KameletApi.getKamelets().filter(k => k.metadata.name === selectedKamelet).at(0); + if (kamelet) { + (integration as any).spec = kamelet.spec; + (integration as any).metadata.labels = kamelet.metadata.labels; + (integration as any).metadata.annotations = kamelet.metadata.annotations; + const i = CamelUtil.cloneIntegration(integration); + return CamelDefinitionYaml.integrationToYaml(i); + } + } return CamelDefinitionYaml.integrationToYaml(integration); } else { return ''; @@ -112,51 +118,12 @@ export function CreateFileModal(props: Props) { : CamelUi.javaNameFromTitle(name); const fullFileName = filename + (isKamelet ? '-' + kameletType : '') + '.' + extension; - const selectOptions: React.JSX.Element[] = [] - KameletApi.getKamelets() + const listOfValues: Value[] = KameletApi.getKamelets() .filter(k => k.metadata.labels["camel.apache.org/kamelet.type"] === kameletType) - .filter(k => k.spec.definition.title.toLowerCase().includes(inputValue?.toLowerCase() || '')) - .forEach((kamelet) => { - const s = <SelectOption key={kamelet.metadata.name} - value={kamelet.metadata.name} - description={kamelet.spec.definition.title} - isFocused={kamelet.metadata.name === selectedKamelet[0]} - onClick={event => { - setSelectedKamelet([kamelet.metadata.name, kamelet.spec.definition.title]); - setSelectIsOpen(false); - setInputValue(kamelet.spec.definition.title) - }} - />; - selectOptions.push(s); - }) - - - const toggle = (toggleRef: React.Ref<MenuToggleElement>) => ( - <MenuToggle variant={"typeahead"} - isFullWidth - ref={toggleRef} - onClick={() => setSelectIsOpen(!selectIsOpen)} - isExpanded={selectIsOpen} - isDisabled={false}> - <TextInputGroup isPlain> - <TextInputGroupMain - value={inputValue} - onClick={event => {}} - onChange={(event, value) => { - setInputValue(value); - setSelectIsOpen(true) - }} - id="typeahead-select-input" - autoComplete="off" - // innerRef={textInputRef} - placeholder="Select Kamelet" - role="combobox" - isExpanded={selectIsOpen} - aria-controls="select-typeahead-listbox" - /> - </TextInputGroup> - </MenuToggle> - ); + .map(k => { + const v: Value = {value: k.metadata.name, children: k.spec.definition.title} + return v; + }) return ( <Modal @@ -191,7 +158,7 @@ export function CreateFileModal(props: Props) { isSelected={kameletType === type} onChange={(_, selected) => { setKameletType(type as KameletTypes); - setSelectedKamelet(['', '']) + setSelectedKamelet(undefined) }}/> })} </ToggleGroup> @@ -205,18 +172,9 @@ export function CreateFileModal(props: Props) { </FormHelperText> </FormGroup> {isKamelet && <FormGroup label="Copy from" fieldId="kamelet"> - <Select - aria-label={"Kamelet"} - onOpenChange={isOpen => setSelectIsOpen(false)} - isOpen={selectIsOpen} - aria-labelledby={"Kamelets"} - toggle={toggle} - shouldFocusToggleOnSelect - > - <SelectList> - {selectOptions} - </SelectList> - </Select> + <TypeaheadSelect listOfValues={listOfValues} onSelect={value => { + setSelectedKamelet(value) + }}/> </FormGroup>} </Form> </Modal>