Prototype #693
diff --git a/karavan-designer/src/App.tsx b/karavan-designer/src/App.tsx index e536a09..b7bba3a 100644 --- a/karavan-designer/src/App.tsx +++ b/karavan-designer/src/App.tsx
@@ -34,6 +34,7 @@ import './designer/karavan.css'; import {DesignerPage} from "./DesignerPage"; import {TemplateApi} from "karavan-core/lib/api/TemplateApi"; +import {DataMapper} from "./data/DataMapper"; class ToastMessage { id: string = '' @@ -76,7 +77,7 @@ class App extends React.Component<Props, State> { public state: State = { - pageId: "designer", + pageId: "datamapper", alerts: [], name: 'example.yaml', key: '', @@ -106,13 +107,13 @@ const kamelets: string[] = []; data[0].split("\n---\n").map(c => c.trim()).forEach(z => kamelets.push(z)); KameletApi.saveKamelets(kamelets, true); - this.toast("Success", "Loaded " + kamelets.length + " kamelets", 'success'); + // this.toast("Success", "Loaded " + kamelets.length + " kamelets", 'success'); const jsons: string[] = []; JSON.parse(data[1]).forEach((c: any) => jsons.push(JSON.stringify(c))); ComponentApi.saveComponents(jsons, true); - this.toast("Success", "Loaded " + jsons.length + " components", 'success'); + // this.toast("Success", "Loaded " + jsons.length + " components", 'success'); this.setState({loaded: true}); TemplateApi.saveTemplate("org.apache.camel.AggregationStrategy", data[2]); @@ -147,6 +148,7 @@ new MenuItem("eip", "Enterprise Integration Patterns", <EipIcon/>), new MenuItem("kamelets", "Kamelets", <KameletsIcon/>), new MenuItem("components", "Components", <ComponentsIcon/>), + new MenuItem("datamapper", "Data Mapper", <ComponentsIcon/>), ] return (<Flex className="nav-buttons" direction={{default: "column"}} style={{height: "100%"}} spaceItems={{default: "spaceItemsNone"}}> @@ -196,6 +198,10 @@ return ( <EipPage dark={dark}/> ) + case "datamapper": + return ( + <DataMapper dark={dark}/> + ) } }
diff --git a/karavan-designer/src/data/DataMapper.tsx b/karavan-designer/src/data/DataMapper.tsx new file mode 100644 index 0000000..3796b11 --- /dev/null +++ b/karavan-designer/src/data/DataMapper.tsx
@@ -0,0 +1,192 @@ +/* + * 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, {RefObject} from 'react'; +import { + Button, + PageSection, + PageSectionVariants, + Tooltip, + TreeView +} from '@patternfly/react-core'; +import '../designer/karavan.css'; +import './datamapper.css'; +import AngleDoubleRightIcon from '@patternfly/react-icons/dist/esm/icons/arrow-alt-circle-right-icon'; +import CollapseIcon from '@patternfly/react-icons/dist/esm/icons/angle-double-right-icon'; +import ExpandIcon from '@patternfly/react-icons/dist/esm/icons/angle-double-down-icon'; +import DownloadIcon from "@patternfly/react-icons/dist/esm/icons/download-icon"; +import DownloadImageIcon from "@patternfly/react-icons/dist/esm/icons/image-icon"; +import AddIcon from "@patternfly/react-icons/dist/js/icons/plus-circle-icon"; +import {DataMappingConnections} from "./DataMappingConnections"; +import {TreeViewDataItem} from "@patternfly/react-core/components"; +import {DataTreeItem} from "./DataTreeItem"; +import {ConnectionPoint, ConnectionsRect, Exchange, ExchangeElement} from "./DataMapperModel"; + +interface Props { + dark: boolean + tab?: string +} + +interface State { + source: any [], + target: any [], + transformation: any [], + activeItems1: any [], + activeItems2: any [], + ref1: RefObject<HTMLDivElement>, + ref2: RefObject<HTMLDivElement>, + connections: ConnectionsRect, + startingPoint: ConnectionPoint, + movingPoint: ConnectionPoint +} + +export class DataMapper extends React.Component<Props, State> { + + state: State = { + activeItems1: [], + activeItems2: [], + source: [new Exchange({defaultExpanded: true})], + target: [new Exchange({defaultExpanded: true})], + transformation: [new ExchangeElement({id: "xxx", name: "new java.util.Date()", customBadgeContent: "java"})], + ref1: React.createRef(), + ref2: React.createRef(), + connections: {top: 0, left: 0, width: 0, height: 0}, + startingPoint: new ConnectionPoint(0,0), + movingPoint: new ConnectionPoint(2000,2000) + } + + ref1: RefObject<HTMLDivElement> = React.createRef(); + ref2: RefObject<HTMLDivElement> = React.createRef(); + + interval: any; + + componentDidMount() { + this.onRefresh(); + this.interval = setInterval(() => this.onRefresh(), 300); + } + + componentWillUnmount() { + clearInterval(this.interval); + } + + onRefresh = () => { + const source = this.ref1.current?.children[0]?.children[0]?.getBoundingClientRect(); + const target = this.ref2.current?.children[0]?.children[0]?.getBoundingClientRect(); + const sourceTop = source?.top || 0; + const sourceLeft = source?.left || 0; + const sourceHeight = source?.height || 0; + const sourceWidth = source?.width || 0; + const targetTop = target?.top || 0; + const targetLeft = target?.left || 0; + const targetHeight = target?.height || 0; + const targetWidth = target?.width || 0; + const height = (sourceTop + sourceHeight > targetTop + targetHeight) + ? (sourceTop + sourceHeight) + : (targetTop + targetHeight); + const width = targetLeft + targetWidth - sourceLeft; + this.setState({connections: new ConnectionsRect(width, height, sourceTop, sourceLeft)}) + } + + + onSelect = (evt: any, treeViewItem: any) => { + // Ignore folders for selection + if (treeViewItem && !treeViewItem.children) { + this.setState({ + activeItems1: [treeViewItem], + activeItems2: [treeViewItem] + }); + } + } + + onDragStart = (rect: DOMRect) => { + const top = rect.top + (rect.height / 2); + const left = rect.left + rect.width; + this.setState({startingPoint: new ConnectionPoint(top, left)}) + } + + onMoving = (clientX: number, clientY: number) => { + this.setState({movingPoint: new ConnectionPoint(clientY, clientX)}) + } + + private onMapElements(source: ExchangeElement, target: ExchangeElement) { + this.setState({startingPoint: new ConnectionPoint(0, 0), movingPoint: new ConnectionPoint(0, 0),}) + } + + convertTreeItem(items: any [], type: 'source' | 'target' | 'transformation'): TreeViewDataItem[] { + return items.map((value: any) => this.convertTreeItems(value, type)); + } + + convertTreeItems(value: any, type: 'source' | 'target' | 'transformation'): TreeViewDataItem { + return { + id: value.id, + name: <DataTreeItem element={value} + type={type} + onDragStart={this.onDragStart} + onMoving={this.onMoving} + onMapElements={(source, target) => this.onMapElements(source, target)}/>, + children: value.children ? this.convertTreeItem(value.children, type) : undefined, + defaultExpanded: value.id === 'exchange', + action: value.id === 'headers' ? <Button variant={"plain"} icon={<AddIcon/>}/> : undefined, + customBadgeContent: value.customBadgeContent + } + } + + render() { + const {activeItems1, startingPoint, movingPoint, source, target, transformation, connections} = this.state; + return ( + <PageSection variant={this.props.dark ? PageSectionVariants.darker : PageSectionVariants.light} className="page" isFilled padding={{default: 'noPadding'}}> + <DataMappingConnections rect={connections} moving={movingPoint} starting={startingPoint}/> + <div className="exchange-mapper"> + <div className="exchange-tree-panel"> + <div className="data-toolbar"> + <div className="panel-header"> + Source + </div> + </div> + <div className="exchange-tree-parent source" ref={this.ref1}> + <TreeView data={this.convertTreeItem(source, 'source')} activeItems={activeItems1} hasBadges hasGuides/> + </div> + </div> + <div className="exchange-tree-panel"> + <div className="data-toolbar"> + <div className="panel-header"> + Transformation + </div> + <Tooltip content="Add Transformation" position={"left"}> + <Button variant="plain" icon={<AddIcon/>} onClick={e => { + }}> + </Button> + </Tooltip> + </div> + <div className="exchange-tree-parent transformation"> + <TreeView data={transformation} hasSelectableNodes={false}/> + </div> + </div> + <div className="exchange-tree-panel"> + <div className="data-toolbar"> + <div className="panel-header"> + Target + </div> + </div> + <div className="exchange-tree-parent target" ref={this.ref2}> + <TreeView data={this.convertTreeItem(target, 'target')} hasBadges hasGuides/> + </div> + </div> + </div> + </PageSection> + ) + } +} \ No newline at end of file
diff --git a/karavan-designer/src/data/DataMapperModel.tsx b/karavan-designer/src/data/DataMapperModel.tsx new file mode 100644 index 0000000..34c1854 --- /dev/null +++ b/karavan-designer/src/data/DataMapperModel.tsx
@@ -0,0 +1,118 @@ +/* + * 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. + */ + +export class ExchangeElement { + id: string = '' + name: string = '' + customBadgeContent: string = '' + + public constructor(init?: Partial<ExchangeElement>) { + Object.assign(this, init); + } +} + +export class ExchangeHeader extends ExchangeElement { + +} + +export class ExchangeProperty extends ExchangeElement { + +} + +export class ExchangeElementWithChildren extends ExchangeElement { + children: ExchangeElement[] = [] + defaultExpanded: boolean = false; + + public constructor(init?: Partial<ExchangeElementWithChildren>) { + super(init); + Object.assign(this, init); + } +} + +export class ExchangeHeaders extends ExchangeElementWithChildren { + + public constructor(init?: Partial<Body>) { + super(init); + this.name = "Headers"; + this.id = "headers"; + this.customBadgeContent = "Map<String, Object>"; + this.children = Array.from(Array(10).keys()).map(value => new ExchangeHeader({id: "id" + value, name: "header" + value, customBadgeContent:"String"})); + } +} + +export class ExchangeProperties extends ExchangeElementWithChildren { + public constructor(init?: Partial<Body>) { + super(init); + this.name = "Properties"; + this.id = "properties"; + this.customBadgeContent = "Map<String, Object>"; + } +} + +export class Body extends ExchangeElement { + + public constructor(init?: Partial<Body>) { + super(init); + this.name = "Body"; + this.id = "body"; + this.customBadgeContent = "Object"; + } +} + +export class Exchange extends ExchangeElementWithChildren { + + public constructor(init?: Partial<Exchange>) { + super(init); + this.customBadgeContent = "Exchange"; + if (init?.name === undefined) { + this.name = "Exchange"; + } + if (init?.id === undefined) { + this.id = "exchange"; + } + if (!init?.children) { + this.children.push(new ExchangeElement({id:"exchangeId", name: "Exchange ID", customBadgeContent: "String"})) + this.children.push(new ExchangeHeaders()) + this.children.push(new ExchangeProperties()) + this.children.push(new Body()) + } + } +} + +export class ConnectionsRect { + width: number = 0 + height: number = 0 + top: number = 0 + left: number = 0 + + constructor(width: number, height: number, top: number, left: number) { + this.width = width; + this.height = height; + this.top = top; + this.left = left; + } +} + +export class ConnectionPoint { + top: number = 0 + left: number = 0 + + constructor(top: number, left: number) { + this.top = top; + this.left = left; + } +}
diff --git a/karavan-designer/src/data/DataMappingConnections.tsx b/karavan-designer/src/data/DataMappingConnections.tsx new file mode 100644 index 0000000..a052143 --- /dev/null +++ b/karavan-designer/src/data/DataMappingConnections.tsx
@@ -0,0 +1,61 @@ +/* + * 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 './datamapper.css'; +import {ConnectionPoint, ConnectionsRect} from "./DataMapperModel"; + +interface Props { + rect: ConnectionsRect + starting: ConnectionPoint + moving: ConnectionPoint +} + +interface State { + connections:[] +} + +export class DataMappingConnections extends React.Component<Props, State> { + + public state: State = { + connections: [], + }; + + render() { + const {starting, moving} = this.props; + const {top, left, height, width} = this.props.rect; + const startX = starting.left - left; + const startY = starting.top - top; + const endX = moving.left - left; + const endY = moving.top - top; + const middleX = (endX + startX) / 2; + console.log(`M ${startX},${startY} C ${middleX},${startY} ${middleX},${endY} ${endX},${endY}`); + return ( + <svg className="data-mapping-connection" + style={{width: width, height: height, position: "absolute", left: left, top: top, backgroundColor: "transparent", zIndex:0}} + viewBox={"0 0 " + width + " " + height}> + <defs> + <marker id="arrowhead" markerWidth="9" markerHeight="6" refX="0" refY="3" orient="auto" className="arrow"> + <polygon points="0 0, 9 3, 0 6"/> + </marker> + </defs> + {starting.top !==0 && moving.top !== 0 + && <path name={"moving"} d={`M ${startX},${startY} C ${middleX},${startY} ${middleX},${endY} ${endX},${endY}`} + className="path" key={"moving"} markerEnd="url(#arrowhead)"/>} + </svg> + ); + } +}
diff --git a/karavan-designer/src/data/DataTreeItem.tsx b/karavan-designer/src/data/DataTreeItem.tsx new file mode 100644 index 0000000..52d300d --- /dev/null +++ b/karavan-designer/src/data/DataTreeItem.tsx
@@ -0,0 +1,108 @@ +/* + * 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, {RefObject} from 'react'; +import './datamapper.css'; +import '../designer/karavan.css'; +import {ExchangeElement} from "./DataMapperModel"; + +interface Props { + element: ExchangeElement + type: 'source' | 'target' | 'transformation' + onMapElements: (source: ExchangeElement, target: ExchangeElement) => void + onDragStart: (rect: DOMRect) => void + onMoving: (clientX: number, clientY: number) => void +} + +interface State { + isDragging: boolean + isDraggedOver: boolean +} + +export class DataTreeItem extends React.Component<Props, State> { + + public state: State = { + isDragging: false, + isDraggedOver: false + } + + ref:RefObject<HTMLDivElement> = React.createRef(); + + render() { + const {isDragging, isDraggedOver} = this.state; + const {element, type} = this.props; + const className = "exchange-item" ; + return ( + <div key={"root-" + element.id} + className={className} + ref={this.ref} + style={{ + // borderStyle: this.hasBorder() ? "dotted" : "none", + // borderColor: this.isSelected() ? "var(--step-border-color-selected)" : "var(--step-border-color)", + // marginTop: this.isInStepWithChildren() ? "16px" : "8px", + // zIndex: element.dslName === 'ToDefinition' ? 20 : 10, + boxShadow: isDraggedOver && type !== 'source' ? "0px 0px 1px 2px var(--step-border-color-selected)" : "none", + }} + onMouseOver={event => event.stopPropagation()} + // onClick={event => this.selectElement(event)}body + onDragStart={event => { + event.stopPropagation(); + event.dataTransfer.setData("text/plain", element.id); + (event.target as any).style.opacity = 1.5; + (event.target as any).style.borderRadius = 16; + this.setState({isDragging: true}); + const rect = this.ref.current?.getBoundingClientRect(); + if (rect) this.props.onDragStart?.call(this, rect); + }} + onDragEnd={event => { + (event.target as any).style.opacity = ''; + this.setState({isDragging: false}); + }} + onDragOver={event => { + event.preventDefault(); + event.stopPropagation(); + if (element.id !== 'exchange') { + this.setState({isDraggedOver: true}); + } + }} + onDragEnter={event => { + event.preventDefault(); + event.stopPropagation(); + if (element.id !== 'exchange') { + this.setState({isDraggedOver: true}); + } + }} + onDragLeave={event => { + event.preventDefault(); + event.stopPropagation(); + this.setState({isDraggedOver: false}); + }} + onDrag ={event => { + this.props.onMoving?.call(this, event.nativeEvent.clientX, event.nativeEvent.clientY); + }} + onDrop={event => this.dragElement(event, element)} + draggable={element.id !== 'exchange'} + > + {element.name} + </div> + ); + } + + private dragElement(event: React.DragEvent<HTMLDivElement>, element: ExchangeElement) { + this.props.onMapElements?.call(this, this.props.element, element); + this.setState({isDraggedOver: false}); + } +}
diff --git a/karavan-designer/src/data/datamapper.css b/karavan-designer/src/data/datamapper.css new file mode 100644 index 0000000..7e62461 --- /dev/null +++ b/karavan-designer/src/data/datamapper.css
@@ -0,0 +1,118 @@ +.exchange-mapper { + display: flex; + flex-direction: row; + padding: 16px; + justify-content: space-between; + background-color: transparent; +} + +.exchange-tree-panel { + display: flex; + flex-direction: column; + flex: 1; +} + +.exchange-tree-panel .pf-c-tree-view { + --pf-c-tree-view__node--indent--base: calc(var(--pf-global--spacer--md) * 2 + var(--pf-c-tree-view__node-toggle-icon--MinWidth)); + --pf-c-tree-view__node--nested-indent--base: calc(var(--pf-c-tree-view__node--indent--base) - var(--pf-global--spacer--md)); + --pf-c-tree-view__node--hover--BackgroundColor: transtparent; + --pf-c-tree-view__node--focus--BackgroundColor: transtparent; +} + +.exchange-tree-panel .pf-c-tree-view__list-item .pf-c-tree-view__list-item .pf-c-tree-view__list-item { + /*--pf-c-tree-view__node--PaddingLeft: calc(var(--pf-c-tree-view__node--nested-indent--base) * 2 + var(--pf-c-tree-view__node--indent--base));*/ + --pf-c-tree-view__node--PaddingLeft: calc(var(--pf-c-tree-view__node--nested-indent--base) * 2 + var(--pf-c-tree-view__node--indent--base)) ; +} + +.exchange-tree-panel .pf-c-tree-view__node-count { + margin-top: auto; + margin-bottom: auto; +} +.exchange-tree-panel .pf-c-tree-view__node-count .pf-c-badge { + font-weight: 100; +} + +/* width */ +.exchange-tree-parent::-webkit-scrollbar { + width: 10px; +} +/* Track */ +.exchange-tree-parent::-webkit-scrollbar-track { + background: #f1f1f1; +} +/* Handle */ +.exchange-tree-parent::-webkit-scrollbar-thumb { + background: #888; +} +/* Handle on hover */ +.exchange-tree-parent::-webkit-scrollbar-thumb:hover { + background: #555; +} +.exchange-tree-parent { + scrollbar-color: #9aa0a6 transparent; + scrollbar-width: thin; + scroll-behavior: smooth; + display: flex; + height: 100vh; + overflow: auto; +} + +.exchange-tree-parent .pf-c-tree-view { + width: 100%; + padding: 0; + border: 1px solid var(--pf-global--disabled-color--300); +} + +.draggable-element { + visibility: hidden; +} + +.pf-c-tree-view__node { + padding-top: 0; + padding-bottom: 0; + padding-right: 0; +} + +.exchange-item { + padding: 6px 6px 6px 6px; + border-radius: 16px; +} + +.dragging-over { + border-radius: 16px; +} + +.karavan .data-mapping-connection .path { + stroke: var(--pf-global--Color--200); + stroke-width: 1; + fill: transparent; +} + +.source .pf-c-tree-view__content:hover .draggable-element, +.transformation .pf-c-tree-view__content:hover .draggable-element { + visibility: visible; +} + +.transformation .pf-c-tree-view__content .pf-c-tree-view__node-text { + border-color: var(--pf-global--Color--400); + border-radius: 16px; + border-width: 1px; + border-style: solid; + padding: 3px 6px 3px 6px; +} + +.data-toolbar { + padding: 3px 6px 3px 6px; + display: flex; + justify-content: space-between; +} +.data-toolbar .pf-c-button { + padding: 0; +} + +.panel-header { + padding-left: 6px; + padding-bottom: 3px; + font-size: 14px; + font-weight: bold; +} \ No newline at end of file
diff --git a/karavan-designer/src/index.css b/karavan-designer/src/index.css index e6a61da..8b2fe78 100644 --- a/karavan-designer/src/index.css +++ b/karavan-designer/src/index.css
@@ -3,6 +3,7 @@ #root, .App { height: 100%; + font-size: 14px; } #root {