diff --git a/docs/modules/ROOT/pages/operations/on-canvas-operations.adoc b/docs/modules/ROOT/pages/operations/on-canvas-operations.adoc index ac522c8c294..9beb9e72903 100644 --- a/docs/modules/ROOT/pages/operations/on-canvas-operations.adoc +++ b/docs/modules/ROOT/pages/operations/on-canvas-operations.adoc @@ -28,4 +28,4 @@ image:modifying-node-properties-example.png[width=600] Similary, we can modify node label: -image:modifying-node-label-example.png[width=600] \ No newline at end of file +image:modifying-node-label-example.png[width=600] diff --git a/src/browser/modules/Stream/CypherFrame/VisualizationView/PropertiesPanelContent/DetailsPane.test.tsx b/src/browser/modules/Stream/CypherFrame/VisualizationView/PropertiesPanelContent/DetailsPane.test.tsx index 25317b7a6bf..11d8ad128e3 100644 --- a/src/browser/modules/Stream/CypherFrame/VisualizationView/PropertiesPanelContent/DetailsPane.test.tsx +++ b/src/browser/modules/Stream/CypherFrame/VisualizationView/PropertiesPanelContent/DetailsPane.test.tsx @@ -22,7 +22,11 @@ import React from 'react' import { DETAILS_PANE_STEP_SIZE, DetailsPane } from './DetailsPane' import { VizItemProperty } from 'neo4j-arc/common' -import { GraphStyleModel, VizItem } from 'neo4j-arc/graph-visualization' +import { + GraphStyleModel, + VizItem, + NodeModel +} from 'neo4j-arc/graph-visualization' describe('<DetailsPane />', () => { const mockGraphStyle = new GraphStyleModel() @@ -66,7 +70,9 @@ describe('<DetailsPane />', () => { item: { id: 'abc', type: 'abc2', - propertyList + propertyList, + source: new NodeModel('1', [], {}, {}), + target: new NodeModel('2', [], {}, {}) } } } diff --git a/src/browser/modules/Stream/CypherFrame/VisualizationView/PropertiesPanelContent/DetailsPane.tsx b/src/browser/modules/Stream/CypherFrame/VisualizationView/PropertiesPanelContent/DetailsPane.tsx index 6b28348a9b9..c0431e241c8 100644 --- a/src/browser/modules/Stream/CypherFrame/VisualizationView/PropertiesPanelContent/DetailsPane.tsx +++ b/src/browser/modules/Stream/CypherFrame/VisualizationView/PropertiesPanelContent/DetailsPane.tsx @@ -71,6 +71,9 @@ export function DetailsPane({ relType: vizItem.item.type }} graphStyle={graphStyle} + onGraphInteraction={onGraphInteraction} + sourceNodeId={vizItem.item.source.id} + targetNodeId={vizItem.item.target.id} /> )} {vizItem.type === 'node' && diff --git a/src/browser/modules/Stream/CypherFrame/VisualizationView/PropertiesPanelContent/StyleableRelType.tsx b/src/browser/modules/Stream/CypherFrame/VisualizationView/PropertiesPanelContent/StyleableRelType.tsx index e79bfdc0ba2..a5ac847a924 100644 --- a/src/browser/modules/Stream/CypherFrame/VisualizationView/PropertiesPanelContent/StyleableRelType.tsx +++ b/src/browser/modules/Stream/CypherFrame/VisualizationView/PropertiesPanelContent/StyleableRelType.tsx @@ -21,44 +21,67 @@ import React from 'react' import { Popup } from 'semantic-ui-react' import { StyledRelationshipChip } from 'neo4j-arc/common' -import { GraphStyleModel } from 'neo4j-arc/graph-visualization' +import { + GraphInteractionCallBack, + GraphStyleModel, + REL_TYPE_UPDATE +} from 'neo4j-arc/graph-visualization' import { GrassEditor } from './GrassEditor' export type StyleableRelTypeProps = { graphStyle: GraphStyleModel selectedRelType: { relType: string; propertyKeys: string[]; count?: number } + onGraphInteraction?: GraphInteractionCallBack + sourceNodeId?: string + targetNodeId?: string } export function StyleableRelType({ selectedRelType, - graphStyle + graphStyle, + onGraphInteraction = () => undefined, + sourceNodeId, + targetNodeId }: StyleableRelTypeProps): JSX.Element { const styleForRelType = graphStyle.forRelationship({ type: selectedRelType.relType }) return ( - <Popup - on="click" - basic - key={selectedRelType.relType} - position="left center" - offset={[0, 0]} - trigger={ - <StyledRelationshipChip - style={{ - backgroundColor: styleForRelType.get('color'), - color: styleForRelType.get('text-color-internal') - }} - data-testid={`property-details-overview-relationship-type-${selectedRelType.relType}`} - > - {selectedRelType.count !== undefined - ? `${selectedRelType.relType} (${selectedRelType.count})` - : `${selectedRelType.relType}`} - </StyledRelationshipChip> + <div + suppressContentEditableWarning={true} + contentEditable="true" + onInput={e => + onGraphInteraction(REL_TYPE_UPDATE, { + sourceNodeId: sourceNodeId, + targetNodeId: targetNodeId, + oldType: selectedRelType.relType, + newType: e.currentTarget.textContent + }) } - wide > - <GrassEditor selectedRelType={selectedRelType} /> - </Popup> + <Popup + on="click" + basic + key={selectedRelType.relType} + position="left center" + offset={[0, 0]} + trigger={ + <StyledRelationshipChip + style={{ + backgroundColor: styleForRelType.get('color'), + color: styleForRelType.get('text-color-internal') + }} + data-testid={`property-details-overview-relationship-type-${selectedRelType.relType}`} + > + {selectedRelType.count !== undefined + ? `${selectedRelType.relType} (${selectedRelType.count})` + : `${selectedRelType.relType}`} + </StyledRelationshipChip> + } + wide + > + <GrassEditor selectedRelType={selectedRelType} /> + </Popup> + </div> ) } diff --git a/src/browser/modules/Stream/CypherFrame/VisualizationView/VisualizationView.tsx b/src/browser/modules/Stream/CypherFrame/VisualizationView/VisualizationView.tsx index 18417afc061..bd22a3b71b3 100644 --- a/src/browser/modules/Stream/CypherFrame/VisualizationView/VisualizationView.tsx +++ b/src/browser/modules/Stream/CypherFrame/VisualizationView/VisualizationView.tsx @@ -28,9 +28,11 @@ import { GraphInteractionCallBack, GraphModel, GraphVisualizer, + REL_ON_CANVAS_CREATE, NODE_ON_CANVAS_CREATE, NODE_PROP_UPDATE, - NODE_LABEL_UPDATE + NODE_LABEL_UPDATE, + REL_TYPE_UPDATE } from 'neo4j-arc/graph-visualization' import { StyledVisContainer } from './VisualizationView.styled' @@ -279,6 +281,37 @@ LIMIT ${maxNewNeighbours}` } onGraphInteraction: GraphInteractionCallBack = (event, properties) => { + if (event == REL_TYPE_UPDATE) { + if (properties == null) { + throw new Error( + 'A property map with sourceNodeId, targetNodeId, oldType, and newType keys are required' + ) + } + + const sourceNodeId = properties['sourceNodeId'] + const targetNodeId = properties['targetNodeId'] + const oldType = properties['oldType'] + const newType = properties['newType'] + + const type = `\`${oldType}\`` + + const query = `MATCH (source)-[rel:${type}]->(target) WHERE ID(source) = ${sourceNodeId} AND ID(target) = ${targetNodeId} CALL apoc.refactor.setType(rel, '${newType}') YIELD input, output RETURN input, output;` + + this.props.bus.self( + CYPHER_REQUEST, + { + query, + params: { sourceNodeId, targetNodeId, oldType, newType }, + queryType: NEO4J_BROWSER_USER_ACTION_QUERY + }, + (response: any) => { + if (!response.success) { + throw new Error(response.error) + } + } + ) + } + if (event == NODE_LABEL_UPDATE) { if (properties == null) { throw new Error( @@ -291,7 +324,7 @@ LIMIT ${maxNewNeighbours}` const newLabel = `\`${properties['newLabel']}\`` const query = `MATCH(n) WHERE ID(n) = ${nodeId} REMOVE n:${oldLabel} SET n:${newLabel}` - console.log(query) + this.props.bus.self( CYPHER_REQUEST, { @@ -309,7 +342,9 @@ LIMIT ${maxNewNeighbours}` if (event == NODE_PROP_UPDATE) { if (properties == null) { - throw new Error('') + throw new Error( + 'A property map with nodeId, propKey, and propVal keys are required' + ) } const nodeId = properties['nodeId'] @@ -334,6 +369,34 @@ LIMIT ${maxNewNeighbours}` ) } + if (event == REL_ON_CANVAS_CREATE) { + if (properties == null) { + throw new Error( + 'A property map with sourceNodeId, targetNodeId, and type keys are required' + ) + } + + const sourceNodeId = properties['sourceNodeId'] + const targetNodeId = properties['targetNodeId'] + const type = properties['type'] + + const query = `MATCH (source), (target) WHERE ID(source) = ${sourceNodeId} AND ID(target) = ${targetNodeId} CREATE (source)-[r:\`${type}\` {name: "new link"}]->(target) RETURN type(r);` + + this.props.bus.self( + CYPHER_REQUEST, + { + query, + params: { sourceNodeId, targetNodeId, type }, + queryType: NEO4J_BROWSER_USER_ACTION_QUERY + }, + (response: any) => { + if (!response.success) { + throw new Error(response.error) + } + } + ) + } + if (event == NODE_ON_CANVAS_CREATE) { if (properties == null) { throw new Error( diff --git a/src/neo4j-arc/common/components/PropertiesTable/PropertiesTable.test.tsx b/src/neo4j-arc/common/components/PropertiesTable/PropertiesTable.test.tsx index 4e08e0d0337..6ca69352c40 100644 --- a/src/neo4j-arc/common/components/PropertiesTable/PropertiesTable.test.tsx +++ b/src/neo4j-arc/common/components/PropertiesTable/PropertiesTable.test.tsx @@ -20,7 +20,7 @@ import { render, screen, waitFor } from '@testing-library/react' import React from 'react' -import { VizItem } from 'neo4j-arc/graph-visualization' +import { VizItem, NodeModel } from 'neo4j-arc/graph-visualization' import { PropertiesTable, @@ -62,7 +62,9 @@ describe('<DetailsPane />', () => { item: { id: 'abc', type: 'abc2', - propertyList + propertyList, + source: new NodeModel('1', [], {}, {}), + target: new NodeModel('2', [], {}, {}) } } } diff --git a/src/neo4j-arc/graph-visualization/GraphVisualizer/DefaultPanelContent/DefaultDetailsPane.test.tsx b/src/neo4j-arc/graph-visualization/GraphVisualizer/DefaultPanelContent/DefaultDetailsPane.test.tsx index 29d3a31d729..8be6bf05a76 100644 --- a/src/neo4j-arc/graph-visualization/GraphVisualizer/DefaultPanelContent/DefaultDetailsPane.test.tsx +++ b/src/neo4j-arc/graph-visualization/GraphVisualizer/DefaultPanelContent/DefaultDetailsPane.test.tsx @@ -27,6 +27,7 @@ import { import { VizItemProperty } from 'neo4j-arc/common' import { GraphStyleModel } from '../../models/GraphStyle' import { VizItem } from '../../types' +import { NodeModel } from 'neo4j-arc/graph-visualization' describe('<DetailsPane />', () => { const mockGraphStyle = new GraphStyleModel() @@ -70,7 +71,9 @@ describe('<DetailsPane />', () => { item: { id: 'abc', type: 'abc2', - propertyList + propertyList, + source: new NodeModel('1', [], {}, {}), + target: new NodeModel('2', [], {}, {}) } } } diff --git a/src/neo4j-arc/graph-visualization/GraphVisualizer/DefaultPanelContent/DefaultDetailsPane.tsx b/src/neo4j-arc/graph-visualization/GraphVisualizer/DefaultPanelContent/DefaultDetailsPane.tsx index a02a7c0cf86..02d10efb76b 100644 --- a/src/neo4j-arc/graph-visualization/GraphVisualizer/DefaultPanelContent/DefaultDetailsPane.tsx +++ b/src/neo4j-arc/graph-visualization/GraphVisualizer/DefaultPanelContent/DefaultDetailsPane.tsx @@ -79,6 +79,9 @@ export function DefaultDetailsPane({ relType: vizItem.item.type }} graphStyle={graphStyle} + onGraphInteraction={onGraphInteraction} + sourceNodeId={vizItem.item.source.id} + targetNodeId={vizItem.item.target.id} /> )} {vizItem.type === 'node' && diff --git a/src/neo4j-arc/graph-visualization/GraphVisualizer/DefaultPanelContent/RelType.tsx b/src/neo4j-arc/graph-visualization/GraphVisualizer/DefaultPanelContent/RelType.tsx index 8b4cbd62969..662d4c6cac3 100644 --- a/src/neo4j-arc/graph-visualization/GraphVisualizer/DefaultPanelContent/RelType.tsx +++ b/src/neo4j-arc/graph-visualization/GraphVisualizer/DefaultPanelContent/RelType.tsx @@ -21,28 +21,54 @@ import React from 'react' import { GraphStyleModel } from '../../models/GraphStyle' import { NonClickableRelTypeChip } from './styled' +import { + GraphInteractionCallBack, + REL_TYPE_UPDATE +} from '../Graph/GraphEventHandlerModel' export type RelTypeProps = { graphStyle: GraphStyleModel selectedRelType: { relType: string; propertyKeys: string[]; count?: number } + onGraphInteraction?: GraphInteractionCallBack + sourceNodeId?: string + targetNodeId?: string +} +RelType.defaultProps = { + onGraphInteraction: () => undefined } export function RelType({ selectedRelType, - graphStyle + graphStyle, + onGraphInteraction = () => undefined, + sourceNodeId, + targetNodeId }: RelTypeProps): JSX.Element { const styleForRelType = graphStyle.forRelationship({ type: selectedRelType.relType }) return ( - <NonClickableRelTypeChip - style={{ - backgroundColor: styleForRelType.get('color'), - color: styleForRelType.get('text-color-internal') - }} + <div + suppressContentEditableWarning={true} + contentEditable="true" + onInput={e => + onGraphInteraction(REL_TYPE_UPDATE, { + sourceNodeId: sourceNodeId, + targetNodeId: targetNodeId, + oldType: selectedRelType.relType, + newType: e.currentTarget.textContent + }) + } > - {selectedRelType.count !== undefined - ? `${selectedRelType.relType} (${selectedRelType.count})` - : `${selectedRelType.relType}`} - </NonClickableRelTypeChip> + <NonClickableRelTypeChip + style={{ + backgroundColor: styleForRelType.get('color'), + color: styleForRelType.get('text-color-internal') + }} + > + {selectedRelType.count !== undefined + ? `${selectedRelType.relType} (${selectedRelType.count})` + : `${selectedRelType.relType}`} + </NonClickableRelTypeChip> + </div> ) } diff --git a/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/GraphEventHandlerModel.ts b/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/GraphEventHandlerModel.ts index d64aba685ef..3ba4a6f935b 100644 --- a/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/GraphEventHandlerModel.ts +++ b/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/GraphEventHandlerModel.ts @@ -33,6 +33,8 @@ import { Visualization } from './visualization/Visualization' export const NODE_ON_CANVAS_CREATE = 'NODE_ON_CANVAS_CREATE' export const NODE_PROP_UPDATE = 'NODE_PROP_UPDATE' export const NODE_LABEL_UPDATE = 'NODE_LABEL_UPDATE' +export const REL_ON_CANVAS_CREATE = 'REL_ON_CANVAS_CREATE' +export const REL_TYPE_UPDATE = 'REL_TYPE_UPDATE' export type GraphInteraction = | 'NODE_EXPAND' @@ -42,6 +44,8 @@ export type GraphInteraction = | typeof NODE_ON_CANVAS_CREATE | typeof NODE_PROP_UPDATE | typeof NODE_LABEL_UPDATE + | typeof REL_ON_CANVAS_CREATE + | typeof REL_TYPE_UPDATE export type GraphInteractionCallBack = ( event: GraphInteraction, @@ -58,6 +62,9 @@ export class GraphEventHandlerModel { onGraphInteraction: GraphInteractionCallBack selectedItem: NodeModel | RelationshipModel | null + private altCreatedRelSourceNode: any + private altCreatedRelTargetNode: any + constructor( graph: GraphModel, visualization: Visualization, @@ -76,6 +83,9 @@ export class GraphEventHandlerModel { this.onGraphInteraction = onGraphInteraction ?? (() => undefined) this.onGraphModelChange = onGraphModelChange + + this.altCreatedRelSourceNode = null + this.altCreatedRelTargetNode = null } graphModelChanged(): void { @@ -274,6 +284,56 @@ export class GraphEventHandlerModel { }) } + nodeAltDown(node: NodeModel): void { + if (!node) { + return + } + + if ( + this.altCreatedRelSourceNode == null && + this.altCreatedRelTargetNode == null + ) { + this.altCreatedRelSourceNode = node + } else if ( + this.altCreatedRelSourceNode != null && + this.altCreatedRelTargetNode == null + ) { + this.altCreatedRelTargetNode = node + + const maxId: number = Math.max( + ...this.graph + .relationships() + .map(relationship => parseInt(relationship.id)) + ) + const newId = maxId + 1 + + const altCreatedRel: RelationshipModel = new RelationshipModel( + newId.toString(), + this.altCreatedRelSourceNode, + this.altCreatedRelTargetNode, + newId.toString(), + { name: 'new link' }, + { name: 'string' } + ) + + this.graph.addRelationships([altCreatedRel]) + this.visualization.update({ + updateNodes: true, + updateRelationships: true + }) + this.graphModelChanged() + + this.onGraphInteraction(REL_ON_CANVAS_CREATE, { + type: newId, + sourceNodeId: this.altCreatedRelSourceNode.id, + targetNodeId: this.altCreatedRelTargetNode.id + }) + + this.altCreatedRelSourceNode = null + this.altCreatedRelTargetNode = null + } + } + bindEventHandlers(): void { this.visualization .on('nodeMouseOver', this.onNodeMouseOver.bind(this)) @@ -289,6 +349,7 @@ export class GraphEventHandlerModel { .on('nodeClicked', this.nodeClicked.bind(this)) .on('nodeDblClicked', this.nodeDblClicked.bind(this)) .on('nodeUnlock', this.nodeUnlock.bind(this)) + .on('nodeAltDown', this.nodeAltDown.bind(this)) this.onItemMouseOut() } } diff --git a/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/visualization/mouseEventHandlers.ts b/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/visualization/mouseEventHandlers.ts index a65eb8e8f30..ffb28ae3689 100644 --- a/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/visualization/mouseEventHandlers.ts +++ b/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/visualization/mouseEventHandlers.ts @@ -66,6 +66,12 @@ export const nodeEventHandlers = ( trigger('nodeMouseOut', node) } + const onNodeMouseDown = (_event: KeyboardEvent, node: NodeModel) => { + if (_event.altKey || _event.ctrlKey || _event.metaKey || _event.shiftKey) { + trigger('nodeAltDown', node) + } + } + const dragstarted = (event: D3DragEvent<SVGGElement, NodeModel, any>) => { initialDragPosition = [event.x, event.y] restartedSimulation = false @@ -115,6 +121,7 @@ export const nodeEventHandlers = ( .on('mouseout', onNodeMouseOut) .on('click', onNodeClick) .on('dblclick', onNodeDblClick) + .on('mousedown', onNodeMouseDown) } export const relationshipEventHandlers = ( diff --git a/src/neo4j-arc/graph-visualization/index.ts b/src/neo4j-arc/graph-visualization/index.ts index 145b62675a7..b683cc49442 100644 --- a/src/neo4j-arc/graph-visualization/index.ts +++ b/src/neo4j-arc/graph-visualization/index.ts @@ -18,6 +18,7 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. */ +export { NodeModel } from './models/Node' export { GraphModel } from './models/Graph' export { GraphStyleModel, Selector } from './models/GraphStyle' @@ -35,8 +36,10 @@ export type { DetailsPaneProps } from './GraphVisualizer/DefaultPanelContent/Def export type { OverviewPaneProps } from './GraphVisualizer/DefaultPanelContent/DefaultOverviewPane' export { + REL_ON_CANVAS_CREATE, NODE_ON_CANVAS_CREATE, NODE_PROP_UPDATE, - NODE_LABEL_UPDATE + NODE_LABEL_UPDATE, + REL_TYPE_UPDATE } from './GraphVisualizer/Graph/GraphEventHandlerModel' export type { GraphInteractionCallBack } from './GraphVisualizer/Graph/GraphEventHandlerModel' diff --git a/src/neo4j-arc/graph-visualization/types.ts b/src/neo4j-arc/graph-visualization/types.ts index cce0cc53526..e388f42bfeb 100644 --- a/src/neo4j-arc/graph-visualization/types.ts +++ b/src/neo4j-arc/graph-visualization/types.ts @@ -49,7 +49,10 @@ type StatusItem = { export type RelationshipItem = { type: 'relationship' - item: Pick<RelationshipModel, 'id' | 'type' | 'propertyList'> + item: Pick< + RelationshipModel, + 'id' | 'type' | 'propertyList' | 'source' | 'target' + > } type CanvasItem = {