Persist deletes, edits & creates
When the graph is a view of persisted records, no write can be optimistic. Three before-hooks make every user-initiated write a proposal the consumer decides on, so the canvas and the database can never disagree:
onBeforeDelete— fires for every delete affordance (the sidebar's bulk row, the node / edge / note context-menu entries) once the whole target set is resolved, includingcascadingEdges: the relationships that removing a node would destroy. Returnfalseto veto, or{ accept: true, nodes, edges }to keep only what the server actually deleted.ctx.confirm()is the built-in confirmation modal.onBeforeNodeCreate— fires for Create ▸ Add node and the canvas menu's Add Node Here, with the graph-spacepositionand apromptData()form. What you return is what enters the graph.onBeforeEdgeEditCommit— fires when an edge edit is committed (right-click an edge ▸ Edit Edge). Refuse it and the edge keeps its data.
All three may be async, and a second gesture is ignored while one is pending. Programmatic graph.removeNode() / addNode() are never gated, so your own code isn't blocked by your own hook.
Try it: select nodes and hit Delete in the sidebar (the published record comes back refused), right-click an edge to change its relationship, or use Create ▸ Add node. Set editors.deletion.enabled: false to remove the delete affordances altogether instead of vetoing every click.
js
const options = {
// Label each attribute with its value, so an edit's effect is visible.
render: {
defaultNodeStyle: {
size: 16,
textColor: '#334155',
text: (node) => node.getData()?.label,
textVerticalShift: -1.6
}
},
UI: {
mode: 'full',
// Off: `full` mode brings a minimap and the data dock, and this card is about
// neither (see the Minimap and Data table cards for those).
minimap: false,
table: false,
// Give the edge editor a relationship dropdown instead of a free-text field.
editors: {
edgeEditor: {
fields: [{
key: 'label',
label: 'Relationship',
type: 'select',
options: RELATIONSHIPS.map((value) => ({ value, label: value }))
}]
}
}
},
callbacks: {
// Nothing leaves the model until the server agrees. The library resolves the
// whole target set first — including `cascadingEdges`, the relationships that
// removing a node would take with it — so you can persist the real consequence.
onBeforeDelete: async ({ nodes, edges, cascadingEdges, origin, confirm }) => {
const links = edges.length + cascadingEdges.length
const ok = await confirm({
title: 'Delete from event?',
body: `${nodes.length} attribute(s) and ${links} relationship(s) will be `
+ `deleted server-side. (asked from the ${origin})`,
confirmLabel: 'Delete'
})
if (!ok) return false
graph.notifier.info('Saving…', 'POST /events/delete')
await wait(700) // a backend call that could fail
// The server refused the published record: keep exactly what it deleted.
const refused = nodes.filter((node) => node.getData().published)
if (refused.length) {
graph.notifier.warning('Kept', `${refused[0].getData().label} is published — not deleted.`)
}
return { accept: true, nodes: nodes.filter((node) => !node.getData().published) }
},
// Create ▸ Add node, or the canvas menu's "Add Node Here". `promptData` opens a
// form; the id/data you return is what enters the graph.
onBeforeNodeCreate: async ({ position, promptData }) => {
const values = await promptData({
title: 'New attribute',
fields: [
{ key: 'label', label: 'Value', type: 'text' },
{
key: 'type', label: 'Type', type: 'select', defaultValue: 'ip-src',
options: ['ip-src', 'domain', 'md5', 'url'].map((v) => ({ value: v, label: v }))
}
]
})
if (!values) return false // cancelled
graph.notifier.info('Saving…', 'POST /attributes/add')
await wait(500)
graph.notifier.success('Created', `${values.label} at ${Math.round(position.x)},${Math.round(position.y)}`)
return { accept: true, id: `attr-${values.label}`, data: values }
},
// Right-click an edge ▸ Edit Edge. The commit is a proposal too: refuse it and
// the edge keeps its data, with the modal still open to correct.
onBeforeEdgeEditCommit: async ({ previousData, nextData }) => {
if (!RELATIONSHIPS.includes(nextData.label)) {
graph.notifier.warning('Rejected', `"${nextData.label}" is not a known relationship.`)
return false
}
graph.notifier.info('Saving…', 'PUT /object_references')
await wait(500)
graph.notifier.success('Updated', `${previousData.label} → ${nextData.label}`)
return true
}
}
}js
const data = {
nodes: [
{ id: 'ip-8888', data: { label: '8.8.8.8', type: 'ip-src' } },
{ id: 'dom-evil', data: { label: 'evil.example.com', type: 'domain' } },
{ id: 'md5', data: { label: 'd41d8cd9…f8427e', type: 'md5' } },
// The server refuses to delete this one — a published record.
{ id: 'file-obj', data: { label: 'invoice.doc', type: 'file', published: true } }
],
edges: [
{ id: 'r1', from: 'file-obj', to: 'md5', data: { label: 'contains' } },
{ id: 'r2', from: 'dom-evil', to: 'ip-8888', data: { label: 'resolves-to' } }
]
}