Interaction API

There are 6 types of interactions supported

  1. Creating Node by Double-Clicking Canvas (NODE_ON_CANVAS_CREATE)

  2. Modifying node labels by double-clicking them in Node Inspector Panel (NODE_LABEL_UPDATE)

  3. Creating relationship between two arbitrary nodes by alt-clicking them in sequence (REL_ON_CANVAS_CREATE)

  4. Modifying relatioship type from properties table (REL_TYPE_UPDATE)

  5. Modifying node/relationship properties in properties table (PROP_UPDATE)

  6. Modifying details pane title (DETAILS_PANE_TITLE_UPDATE)

The design of interaction API is that the API defines the the data that is messaged out from Neo4j Arc library and let library client to decide how to use those data. This is a very powerful approach because we can choose how to persist those interactions. For example, when user creates a new node on canvase, we can choose to store this new node into a dedicated database or send it to some downstream API.

The basic setup is the following:

import { GraphInteractionCallBack } from "neo4j-devtools-arc";
export default function MyGraphComponent(): JSX.Element {

  const onGraphInteraction: GraphInteractionCallBack = (event, properties) => {
    if (event == NODE_ON_CANVAS_CREATE) {
      const name = properties['name']
      const description = properties['description']
      const labels = properties['labels']

      // custom logics
      // ...
    }

    if (event == NODE_LABEL_UPDATE) {
      const nodeId = properties['nodeId']
      const oldLabel = properties['oldLabel']
      const newLabel = properties['newLabel']

      // custom logics
      // ...
    }

    if (event == REL_ON_CANVAS_CREATE) {
      const sourceNodeId = properties['sourceNodeId']
      const targetNodeId = properties['targetNodeId']
      const type = properties['type']

      // custom logics
      // ...
    }

    if (event == REL_TYPE_UPDATE) {
      const relId = properties["relId"]
      const sourceNodeId = properties['sourceNodeId']
      const targetNodeId = properties['targetNodeId']
      const oldType = properties['oldType']
      const newType = properties['newType']

      // custom logics
      // ...
    }

    if (event == PROP_UPDATE) {
      const isNode = properties['isNode']
      const nodeOrRelId = properties['nodeOrRelId']
      const propKey = properties['propKey']
      const propVal = properties['propVal']

      // custom logics
      // ...
    }

    if (event == DETAILS_PANE_TITLE_UPDATE) {
      const isNode = properties["isNode"]
      const nodeOrRelId = properties['nodeOrRelId']
      const titlePropertyKey = properties['titlePropertyKey']
      const newTitle = properties['newTitle']

      // custom logics
      // ...
    }
  };

  return (
    <GraphVisualizer
      ...
      onGraphInteraction={onGraphInteraction}
    />
  );
}

How Graph Interations Take Effect on Displayed Graph and Backing Database

We define graph interactions as any mouse or keyboard events on the Graph frame view

There are basically 3 components involved in handling users' interactions:

  1. GraphEventHandlerModel contains implementations on how to operate on the displayed graph for various interactions

  2. Visualization triggers the implementations via D3 callbacks

  3. VisualizationView is involved if a certain type of user interaction involves database changes

In the case 3. above, VisualizationView prop-drilling a callback called GraphInteractionCallBack all they way to the GraphEventHandlerModel. Since database operations should not be defined in neo4j-arc module, GraphEventHandlerModel can simply pass any argument to the callback function and let the upper level component (recall the component diagram in overview section) VisualizationView handle the database connection, query issuing, and response handling, etc.

How to Implement a User Interaction

  1. Implement an event handler function in GraphEventHandlerModel

  2. Bind the handler function in GraphEventHandlerModel.bindEventHandlers()

  3. Trigger the handler function in Visualization

  4. If the interaction involves database changes, add the corresponding logic to GraphInteractionCallBack, and

  5. trigger the GraphInteractionCallBack in the handler function

For example, let’s say we’d like to support easy on-graph editing by allowing us to create a new node when we double click on canvas. We will follow the steps above by first defining the event handler function:

  onCanvasDblClicked(): void {
    this.onGraphInteraction(NODE_ON_CANVAS_CREATE, { name: 'New Node', labels: ['Undefined'] })
  }

When we add a new node to the graph, we do not update the visual of the graph on current canvas, because this update process involves lots of organic parts and taking care all of them is error-prone

In stead, we persist the new node to database first (by invoking onGraphInteraction() above. The details of this method will be disucssed below) and later we trigger a new frame so all the refreshed data will be put onto a new canvas, including the new node.

Next, we bind the function so that Visualization.ts component can delegte any canvas double click callbacks to this function

bindEventHandlers(): void {
  this.visualization
    ...
    .on('canvasDblClicked', this.onCanvasDblClicked.bind(this))
    ...
  this.onItemMouseOut()
}

When Visualization.ts recrives a canvas double click from user, it invoke the event handler function via D3 event handlers:

this.rect = this.baseGroup
  .append('rect')
  ...
  .on('dblclick', () => {
    if (!this.draw) {
      return this.trigger('canvasDblClicked')
    }
  })

This is how we trigger the handler function in Visualization.

As we’ve mentioned earlier, we would like to persist this new node to database. They way to do that is by modifying VisualizationView.onGraphInteraction() method:

import {
  commandSources,
  executeCommand
} from 'shared/modules/commands/commandsDuck'

...


  onGraphInteraction: GraphInteractionCallBack = (event, properties) => {
    if (event == NODE_ON_CANVAS_CREATE) {
      if (properties == null) {
        throw new Error(
          'A property map with name, and labels keys are required'
        )
      }

      const name = properties['name']
      const variableName = `node`
      const labels = (properties['labels'] as string[]).map(label => `\`${label}\``).join(':')

      const query = `CREATE (${variableName}:${labels} { name: "${name}" });`

      this.props.bus.self(
        CYPHER_REQUEST,
        {
          query,
          params: { labels, name },
          queryType: NEO4J_BROWSER_USER_ACTION_QUERY
        },
        (response: any) => {
          if (!response.success) {
            throw new Error(response.error)
          }
        }
      )

      const cmd = 'MATCH (n) RETURN n;'
      const action = executeCommand(cmd, { source: commandSources.rerunFrame })
      this.props.bus.send(action.type, action)
    }
  }

We trigger the aforementioned new fram using the last 3 lines above

The complete implementation is in this PR and this PR (optimization) as references