diff --git a/docs/modules/ROOT/content-nav.adoc b/docs/modules/ROOT/content-nav.adoc index 2d3fa12808e..2552c8fc64c 100644 --- a/docs/modules/ROOT/content-nav.adoc +++ b/docs/modules/ROOT/content-nav.adoc @@ -30,3 +30,4 @@ * xref:internals/index.adoc[Internals] ** xref:internals/graph-modelling.adoc[] +** xref:internals/graph-interactions.adoc[] diff --git a/docs/modules/ROOT/images/neo4j-browser.drawio b/docs/modules/ROOT/images/neo4j-browser.drawio new file mode 100644 index 00000000000..012567d14b3 --- /dev/null +++ b/docs/modules/ROOT/images/neo4j-browser.drawio @@ -0,0 +1,253 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/modules/ROOT/images/neo4j-browser.png b/docs/modules/ROOT/images/neo4j-browser.png index f8d4927af76..535f3d6726c 100644 Binary files a/docs/modules/ROOT/images/neo4j-browser.png and b/docs/modules/ROOT/images/neo4j-browser.png differ diff --git a/docs/modules/ROOT/pages/internals/graph-interactions.adoc b/docs/modules/ROOT/pages/internals/graph-interactions.adoc new file mode 100644 index 00000000000..3ca5508ed29 --- /dev/null +++ b/docs/modules/ROOT/pages/internals/graph-interactions.adoc @@ -0,0 +1,130 @@ +:description: How graph interations take effect on displayed graph and backing database + + +[[user-interactions]] += How Graph Interations Take Effect on Displayed Graph and Backing Database + +We define *graph interactions* as any https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent[mouse] and +https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent[keyboard] events on the +link:../../visual-tour/index.html#frame-views[_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 + +[NOTE] +==== + +In the case 3. above, _VisualizationView_ +https://react.dev/learn/passing-data-deeply-with-context#the-problem-with-passing-props[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 link:../index.html[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: + +[source,typescript] +---- + onCanvasDblClicked(): void { + this.graph.addNodes([ new NodeModel(...) ]) + this.visualization.update({ updateNodes: true, updateRelationships: true }) + this.graphModelChanged() + + this.onGraphInteraction(NODE_ON_CANVAS_CREATE, { id: newNodeId, name: 'New Node', labels: ['Undefined'] }) + } +---- + +[NOTE] +==== + +When we add a new node to the graph, it is important to update the visual of the graph using `visualization.update()` +and the stats panel with `graphModelChanged()` + +We would also like to persist the new node to database so that refreshing the page or re-login shall still render this +node. Therefore we invoke `onGraphInteraction()` above. The details of this method will be disucssed below + +==== + +Next, we bind the function so that `Visualization.ts` component can delegte any canvas double click callbacks to this +function + +[source,typescript] +---- +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 +https://www.d3indepth.com/selections/#event-handling[D3 event handlers]: + +[source,typescript] +---- +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: + +[source,typescript] +---- + onGraphInteraction: GraphInteractionCallBack = (event, properties) => { + if (event == NODE_ON_CANVAS_CREATE) { + if (properties == null) { + throw new Error( + 'A property map with id, name, and labels keys are required' + ) + } + + const id = properties['id'] + const name = properties['name'] + const variableName = `node${id}` + const labels = (properties['labels'] as string[]).map(label => `\`${label}\``).join(':') + + const query = `CREATE (${variableName}:${labels} { id: ${id}, name: "${name}" });` + + this.props.bus.self( + CYPHER_REQUEST, + { + query, + params: { labels, id, name }, + queryType: NEO4J_BROWSER_USER_ACTION_QUERY + }, + (response: any) => { + if (!response.success) { + throw new Error(response.error) + } + } + ) + } + } +---- + +The complete implementation is in https://github.com/QubitPi/neo4j-browser/pull/7[this PR] as a reference diff --git a/docs/modules/ROOT/pages/internals/index.adoc b/docs/modules/ROOT/pages/internals/index.adoc index 034d5a6b4af..f81fdccf10c 100644 --- a/docs/modules/ROOT/pages/internals/index.adoc +++ b/docs/modules/ROOT/pages/internals/index.adoc @@ -10,19 +10,23 @@ The Neo4J Browser is logically composed of 2 parts: 2. A user-interface that combines the graph rendering (supported by the graphing module), database, and user interaction together -The graphing is based on D3 but pretty much developed from scratch by building everything on top of D3 and implementing -its own layout, coloring, and link drawing. For example, the calculation of arrow (e.g. node links) between nodes -uses some very complicated math along with very prelimiary MDN standard basic shap specification. +The graphing is based on D3 and implements its own layout, coloring, and link drawing. For example, the calculation of +arrow, i.e. links, between nodes uses some +https://github.com/QubitPi/neo4j-browser/blob/master/src/neo4j-arc/graph-visualization/utils/ArcArrow.ts[very complicated math] +along with very prelimiary MDN standard basic shap specification. [WARNING] ==== -We will not have any TypeDoc documentation, because the TypeScript version used in Neo4J Browser is not supprted by it +We will not have any TypeDoc documentation, because the TypeScript version used in Neo4J Browser is not supprted by the +TypeDoc ==== == Component Diagram (WIP) -image:neo4j-browser.png[width=300] +image:neo4j-browser.png[width=900] +* The orange triangle labled with "On Canvas Interaction" is discussed in detail in + link:internals/graph-interactions[graph interactions] section * Sentry.io is initialized in the top index.tsx * AppInit.tsx is responsible for several initializations: + diff --git a/e2e_tests/integration/viz.spec.ts b/e2e_tests/integration/viz.spec.ts index 66fe6aca2c4..c063dadc1b7 100644 --- a/e2e_tests/integration/viz.spec.ts +++ b/e2e_tests/integration/viz.spec.ts @@ -241,4 +241,22 @@ describe('Viz rendering', () => { cy.get('#svg-vis').trigger('wheel', { deltaY: 3000, shiftKey: true }) cy.get(`[aria-label="zoom-out"]`).should('be.disabled') }) + + it('can create a new node by double clicking the canvas', () => { + cy.executeCommand(':clear') + cy.executeCommand(`CREATE (a:TestLabel {name: 'testNode'}) RETURN a`, { + parseSpecialCharSequences: false + }) + + cy.get('[data-testid="graphCanvas"]') + .trigger('click', 200, 200, { force: true }) + .trigger('dblclick', 200, 200, { force: true }) + + cy.get('[data-testid="nodeGroups"]', { timeout: 5000 }).contains('New Node') + cy.get('[data-testid="vizInspector"]', { timeout: 5000 }).contains( + 'Undefined' + ) + + cy.executeCommand('MATCH (n) DETACH DELETE n') + }) }) diff --git a/src/browser/modules/Stream/CypherFrame/VisualizationView/VisualizationView.tsx b/src/browser/modules/Stream/CypherFrame/VisualizationView/VisualizationView.tsx index 877147680ab..e3381dadb3d 100644 --- a/src/browser/modules/Stream/CypherFrame/VisualizationView/VisualizationView.tsx +++ b/src/browser/modules/Stream/CypherFrame/VisualizationView/VisualizationView.tsx @@ -24,7 +24,12 @@ import { withBus } from 'react-suber' import { Action, Dispatch } from 'redux' import { Bus } from 'suber' -import { GraphModel, GraphVisualizer } from 'neo4j-arc/graph-visualization' +import { + GraphInteractionCallBack, + GraphModel, + GraphVisualizer, + NODE_ON_CANVAS_CREATE +} from 'neo4j-arc/graph-visualization' import { StyledVisContainer } from './VisualizationView.styled' import { resultHasTruncatedFields } from 'browser/modules/Stream/CypherFrame/helpers' @@ -271,6 +276,39 @@ LIMIT ${maxNewNeighbours}` this.autoCompleteRelationships([], this.graph.nodes(), true) } + onGraphInteraction: GraphInteractionCallBack = (event, properties) => { + if (event == NODE_ON_CANVAS_CREATE) { + if (properties == null) { + throw new Error( + 'A property map with id, name, and labels keys are required' + ) + } + + const id = properties['id'] + const name = properties['name'] + const variableName = `node${id}` + const labels = (properties['labels'] as string[]) + .map(label => `\`${label}\``) + .join(':') + + const query = `CREATE (${variableName}:${labels} { id: ${id}, name: "${name}" });` + + this.props.bus.self( + CYPHER_REQUEST, + { + query, + params: { labels, id, name }, + queryType: NEO4J_BROWSER_USER_ACTION_QUERY + }, + (response: any) => { + if (!response.success) { + throw new Error(response.error) + } + } + ) + } + } + render(): React.ReactNode { if (!this.state.nodes.length) return null @@ -309,6 +347,7 @@ LIMIT ${maxNewNeighbours}` OverviewPaneOverride={OverviewPane} useGeneratedDefaultColors={false} initialZoomToFit + onGraphInteraction={this.onGraphInteraction} /> ) diff --git a/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/GraphEventHandlerModel.ts b/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/GraphEventHandlerModel.ts index 04f65c1f502..aa15f6afc54 100644 --- a/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/GraphEventHandlerModel.ts +++ b/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/GraphEventHandlerModel.ts @@ -30,10 +30,14 @@ import { } from '../../utils/mapper' import { Visualization } from './visualization/Visualization' +export const NODE_ON_CANVAS_CREATE = 'NODE_ON_CANVAS_CREATE' + export type GraphInteraction = | 'NODE_EXPAND' | 'NODE_UNPINNED' | 'NODE_DISMISSED' + | 'NODE_ON_CANVAS_CREATE' + | typeof NODE_ON_CANVAS_CREATE export type GraphInteractionCallBack = ( event: GraphInteraction, @@ -225,6 +229,30 @@ export class GraphEventHandlerModel { this.deselectItem() } + onCanvasDblClicked(): void { + const maxId: number = Math.max( + ...this.graph.nodes().map(node => parseInt(node.id)) + ) + const newId = maxId + 1 + + this.graph.addNodes([ + new NodeModel( + newId.toString(), + ['Undefined'], + { name: 'New Node' }, + { name: 'string' } + ) + ]) + this.visualization.update({ updateNodes: true, updateRelationships: true }) + this.graphModelChanged() + + this.onGraphInteraction(NODE_ON_CANVAS_CREATE, { + id: newId, + name: 'New Node', + labels: ['Undefined'] + }) + } + onItemMouseOut(): void { this.onItemMouseOver({ type: 'canvas', @@ -245,6 +273,7 @@ export class GraphEventHandlerModel { .on('relMouseOut', this.onItemMouseOut.bind(this)) .on('relationshipClicked', this.onRelationshipClicked.bind(this)) .on('canvasClicked', this.onCanvasClicked.bind(this)) + .on('canvasDblClicked', this.onCanvasDblClicked.bind(this)) .on('nodeClose', this.nodeClose.bind(this)) .on('nodeClicked', this.nodeClicked.bind(this)) .on('nodeDblClicked', this.nodeDblClicked.bind(this)) diff --git a/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/visualization/Visualization.ts b/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/visualization/Visualization.ts index d536715ce7b..8fdfee786fa 100644 --- a/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/visualization/Visualization.ts +++ b/src/neo4j-arc/graph-visualization/GraphVisualizer/Graph/visualization/Visualization.ts @@ -101,6 +101,7 @@ export class Visualization { .attr('width', '100%') .attr('height', '100%') .attr('transform', 'scale(1)') + .attr('data-testid', 'graphCanvas') // Background click event // Check if panning is ongoing .on('click', () => { @@ -108,6 +109,11 @@ export class Visualization { return this.trigger('canvasClicked') } }) + .on('dblclick', () => { + if (!this.draw) { + return this.trigger('canvasDblClicked') + } + }) this.container = this.baseGroup.append('g') this.geometry = new GraphGeometryModel(style) @@ -166,6 +172,7 @@ export class Visualization { const nodeGroups = this.container .selectAll('g.node') .attr('transform', d => `translate(${d.x},${d.y})`) + .attr('data-testid', 'nodeGroups') nodeRenderer.forEach(renderer => nodeGroups.call(renderer.onTick, this)) diff --git a/src/neo4j-arc/graph-visualization/index.ts b/src/neo4j-arc/graph-visualization/index.ts index 3d85b722eb6..ed0130a0f71 100644 --- a/src/neo4j-arc/graph-visualization/index.ts +++ b/src/neo4j-arc/graph-visualization/index.ts @@ -33,3 +33,6 @@ export { measureText } from './utils/textMeasurement' export { GraphVisualizer } from './GraphVisualizer/GraphVisualizer' export type { DetailsPaneProps } from './GraphVisualizer/DefaultPanelContent/DefaultDetailsPane' export type { OverviewPaneProps } from './GraphVisualizer/DefaultPanelContent/DefaultOverviewPane' + +export { NODE_ON_CANVAS_CREATE } from './GraphVisualizer/Graph/GraphEventHandlerModel' +export type { GraphInteractionCallBack } from './GraphVisualizer/Graph/GraphEventHandlerModel'