Creating a plug-in that writes to a chart in the i2 Notebook web client
This tutorial takes you through writing a plug-in for the i2 Notebook web client that can write new records to a chart, arrange the elements that contain those records, and update the view to contain those elements.
In doing so, the tutorial demonstrates many aspects of the support in the i2 Notebook API for mutations. In particular, it contains examples of running mutations from plug-in code, and of committing and rolling back mutations in appropriate circumstances.
You can find a full version of the source code for this tutorial in the samples/mutation-plugin
folder.
Prerequisities
This tutorial requires a running instance of i2 Analyze on which you have permission to use the i2 Notebook web client.
To follow the tutorial, you must install at least version 12 of Node.js on your development machine. For downloads and more information about Node.js, visit the project website at https://nodejs.org.
The tutorial also requires you to create GUIDs for a number of its elements.
The project files use some sample fixed GUIDs (00000000-0000-0000-0000-000000000001
, 00000000-0000-0000-0000-000000000002
, and so on), but you should always create real GUIDs for real projects.
Note: A suitable GUID generator is available online at https://www.guidgenerator.com.
Create a basic plug-in
To arrive at the starting point for this tutorial, follow the instructions in the basic plug-in tutorial, up to but not including the section named Add a ribbon command to the plug-in.
Make sure that you have a working plug-in before you continue.
Add a ribbon command
Starting from the essentially empty plug-in, we'll add a new command to the ribbon that will eventually add some Person records to the current chart.
In
entrypoint.js
, just before the call toinitializationComplete()
, create a command and surface it on the Home tab:const addRecords = api.commands.createCommand({ id: '00000000-0000-0000-0000-000000000002', name: 'Add items', type: 'application', icon: { type: 'inlineSvg', svg: '<svg viewBox="0 0 16 16"><rect width="8" height="8" x="4" y="4"/></svg>', }, onExecute() {}, }); api.commands.applicationRibbon.homeTab .after(api.commands.applicationRibbon.homeTab.systemGroups.searchInfoStore) .surfaceCommands(addRecords);
So far, this command is almost identical to the one that you would have added, if you had continued with the basic plug-in tutorial.
Reload i2 Notebook and check that the action is visible in the application ribbon.
Add entity records
Next, we're going to do something different from the basic tutorial. The command will add records to the chart, so we need to provide the data for those records, and identify the schema types that it conforms to.
At the top of the
entrypoint.js
file, add some dummy data to serve as the source for the records:const personData = [ { firstName: 'Lou', familyName: 'Tuft', }, { firstName: 'Jessy', familyName: 'Roberts', }, { firstName: 'Jayme', familyName: 'Timberson', }, ];
Also define the entity type and property types from the schema that will apply to the entity records. Place this code at the top of the file, next to the dummy data:
const personEntityTypeId = 'ET5'; const firstNamePropertyTypeId = 'PER4'; const familyNamePropertyTypeId = 'PER6';
Inside the implementation of
onExecute()
, add the following code to get the entity and property type objects that you need for the dummy data:const { getItemType } = application.chart.schema; const personEntityType = getItemType(personEntityTypeId); const firstNamePropertyType = personEntityType.getPropertyType(firstNamePropertyTypeId); const familyNamePropertyType = personEntityType.getPropertyType(familyNamePropertyTypeId);
At this point, you have the data, and you've identified the entity and property types in the i2 Analyze schema that model that data. Now we can start to do something with it.
Immediately after the previous code, add a call to
runTrackedMutations()
and start the mutation handler that will add records to the chart:api.runTrackedMutations((_application, mutations) => { // Mutations go here });
The changes that we want to make to the chart must take place in the function that we pass to
runTrackedMutations()
.Start the handler by looping over the contents of the
personData
object, and using the entity type and property types that we retrieved earlier to add entity records to the chart:for (const person of personData) { mutations.addEntityRecord({ itemType: personEntityType, properties: { [firstNamePropertyType.id]: person.firstName, [familyNamePropertyType.id]: person.familyName, }, }); }
Note: Alternatively, you might decide to add an "empty" entity record first, and then edit it:
for (const person of personData) { const newPerson = mutations.addEntityRecord({ itemType: personEntityType, }); newPerson.setProperties({ [firstNamePropertyType.id]: person.firstName, [familyNamePropertyType.id]: person.familyName, }); }
To complete the mutation handler, you must return an object that tells the application whether to commit the mutation. Since this is a tracked mutation, you must also supply the name of the entry to add to the undo stack. Add this code, just after the
for
loop:return { type: 'commit', actionDisplayName: 'Add records', };
Note: A tracked mutation adds an entry to the undo stack. If the user executes the undo action, all the mutation commands that were added are reversed, putting the chart back into its state before the
runTrackedMutations()
call.An untracked mutation does not add an entry to the undo stack, but is limited to mutation commands that affect selection and the viewport.
Reload i2 Notebook and run the command. The Person records that you created are added to new node elements on the chart.
Add linked records
Next, we'll put some more entity records on the chart, and create links between the new records and the Person records that you already created.
Add another set of data, which we'll use to define some events:
const eventEntityTypeId = 'ET2'; const eventTypePropertyTypeId = 'EVE3'; const startDateTimePropertyTypeId = 'EVE4'; const observedLinkTypeId = 'LOB1'; const eventData = [ { type: 'Theft', dateTime: '2022-03-01T12:31', timeZoneId: 'Europe/London', wasSeen: 'Tuft', }, { type: 'Arson', location: 'New York', dateTime: '2021-11-23T10:33', timeZoneId: 'America/New_York', wasSeen: 'Roberts', }, { type: 'Assault', dateTime: '2022-05-07T17:14', timeZoneId: 'America/Los_Angeles', wasSeen: 'Timberson', }, ];
Notice how each event has a
wasSeen
property that determines which Person record the event should be linked to.Add the following code to get the entity, property, and link type objects for the new data, just after the similar code that you added to
onExecute()
earlier:const { getItemType } = application.chart.schema; const eventEntityType = getItemType(eventEntityTypeId); const eventTypePropertyType = getItemType.getPropertyType(eventTypePropertyTypeId); const startDateTimePropertyType = eventEntityType.getPropertyType( eventEntityType, startDateTimePropertyTypeId ); const observedLinkType = getItemType(observedLinkTypeId);
To populate the events' time zone properties, we'll add another helper function that looks up time zones by their identifiers. Add this code after the other helper functions at the top of the file:
/** * @param {import("@i2analyze/notebook-sdk").data.IKeyedReadOnlyCollection<string,import("@i2analyze/notebook-sdk").data.ITimeZone>} timeZones * @param {string} timeZoneId */ function getTimeZone(timeZones, timeZoneId) { const timeZone = timeZones.get(timeZoneId); if (timeZone === undefined) { throw new Error('No time zone matches ${timeZoneId}'); } return timeZone; }
Note: The
/** @param {import...} */
and/** @type {import...} */
code helps the JavaScript type system to understand what we're working with, which enables auto-completion in the rest of the code. If we used TypeScript to create theentrypoint.js
file, we'd useimport type
statements instead.To make it easier to find the Person to whom a particular Event is related, we'll add a
Map
object that contains our Person records, keyed by theirfamilyName
property values./** * @type {Map<string,import("@i2analyze/notebook-sdk").app.IPendingRecord>} */ const personLookup = new Map();
Note: We need the map because we can't just look up the records on the chart, even after the call to
addEntityRecord()
. They're not actually on the chart, because the mutation is yet to be committed.Amend the
for
loop that queues the Person records for addition to the chart so that it matches this code, which also adds the new records to theMap
.for (const person of personData) { const personRecord = mutations.addEntityRecord({ itemType: personEntityType, properties: { [firstNamePropertyType.id]: person.firstName, [familyNamePropertyType.id]: person.familyName, }, }); personLookup.set(person.familyName, personRecord); }
Once again, you have the data and the appropriate item and property types. Now we can use the new data to create some more records, starting with events.
In the tracked mutation handler, just after the
for
loop above, insert the following code:for (const event of eventData) { const startDateTime = mutations.valueFactory.createZonedDateTime( event.dateTime, getTimeZone(api.allTimeZones, event.timeZoneId), false ); const eventRecord = mutations.addEntityRecord({ itemType: eventEntityType, properties: { [eventTypePropertyType.id]: event.type, [startDateTimePropertyType.id]: startDateTime, }, }); }
The
mutations
object provides a number of factory methods that you can use to create valid values for record properties. This code usescreateZonedDateTime()
to set the event's date-and-time property.Next, add the link records that connect the Event entities to the Person entities, using the lookup map to do so. Put this code inside the
for
loop, just after the call toaddEntityRecord()
:const personSeenRecord = personLookup.get(event.wasSeen); if (personSeenRecord === undefined) { throw new Error(`Person matching ${event.wasSeen} is missing`); } mutations.addLinkRecord({ itemType: observedLinkType, fromEnd: eventRecord, toEnd: personSeenRecord, linkDirection: 'with', });
Reload i2 Notebook, create a chart so that you're starting from fresh, and run the command. This time, the Person and Event records are added to the chart, and they are connected through Observed link records.
Changing chart selection
We've added the events to the chart, but we can also add them to the current selection, which is a common operation in real plug-ins.
Add this code after the call to
addEntityRecord()
:mutations.selection.add(eventRecord);
Reload i2 Notebook again and run the command. This time, the Event records are selected on the chart.
Mutating elements, and mutation rollback
We can use mutations to affect the elements on the chart as well as the records that they contain. Let's try arranging the elements that contain Events into a left-to-right time sequence.
In
entrypoint.js
, before the call toinitializationComplete()
, create another command and surface it in the ribbon:const arrangeEvents = api.commands.createCommand({ id: '00000000-0000-0000-0000-000000000003', name: 'Arrange events', type: 'unscoped', icon: { type: 'inlineSvg', svg: '<svg viewBox="0 0 16 16"><rect width="8" height="8" x="4" y="4"/></svg>', }, onExecute(application) { // Execute code goes here }, }); api.commands.applicationRibbon.homeTab.after(addItems).surfaceCommands(arrangeEvents);
Before we arrange the elements, we should first validate that the current selection contains only Event records. If it does not, we'll roll back the mutation and display a description of the problem to the user.
Start implementing the new
onExecute()
method like this:api.runTrackedMutations((application, mutations) => { const selection = application.chart.selection; if ( !selection.entityRecords.every((record) => record.itemType.analyzeId === eventEntityTypeId) ) { return { type: 'rollback', report: { details: 'Selection must contain only Event record types', title: 'Cannot arrange items', type: 'error', }, }; } return { type: 'commit', actionDisplayName: 'Arrange events', }; });
Reload i2 Notebook, start a new chart, and run the two commands in sequence.
Running the second command results in a system error. The selected records are all Events, but the mutation handler involves no operations, and committing a mutation that contains no operations is not valid. We'll address that in a moment.
Select all the elements on the chart, and run the Arrange Events command again.
This time, your error notification appears, because the selection contains more than just Event records. Rolling back a mutation that contains no operations does not result in a system error, and is valid in a scenario like this when you want to display a notification.
With the type check in place, we can inspect the date-and-time properties of the selected Event records, sort the records according to the values of those properties, add finally perform the mutation that arranges the elements into that sort order.
Add the following code after the selection check. It sorts the selected records and uses
setNodeCenter()
to move them into their new positions.const { getItemType } = application.chart.schema; const eventEntityType = getItemType(eventEntityTypeId); const startDateTimePropertyType = eventEntityType.getPropertyType(startDateTimePropertyTypeId); /** * @param {import("@i2analyze/notebook-sdk").visual.INode} node */ function getEventDate(node) { const eventRecord = node.records.firstOrDefault(undefined); if (eventRecord === undefined) { throw new Error('Unexpected missing record'); } const eventDateTime = eventRecord.getProperty(startDateTimePropertyType); if (eventDateTime !== undefined && !eventRecord.isValueUnfetched(eventDateTime)) { return /** @type {import("@i2analyze/notebook-sdk").data.IZonedDateTime} */ (eventDateTime); } } /** * @param {import("@i2analyze/notebook-sdk").visual.INode} nodeA * @param {import("@i2analyze/notebook-sdk").visual.INode} nodeB */ function compareEvents(nodeA, nodeB) { const eventADate = getEventDate(nodeA); const eventBDate = getEventDate(nodeB); if (eventADate !== undefined && eventBDate !== undefined) { const dateA = eventADate.dateTime.toJSDate().getTime(); const dateB = eventBDate.dateTime.toJSDate().getTime(); return dateA - dateB; } else { return 0; } } const firstEvent = selection.affectedNodes.firstOrDefault(undefined); if (!firstEvent) { return { type: 'rollback', report: { title: 'Nothing selected', details: 'Nothing was selected, so no events were arranged.', type: 'information', }, }; } const sortedEvents = Array.from(selection.affectedNodes).sort(compareEvents); const targetLocation = firstEvent.center; let targetX = targetLocation.x; for (const event of sortedEvents) { mutations.setNodeCenter(event, { x: targetX, y: targetLocation.y }); targetX += 200; }
Finally, mutate the view so that the newly rearranged elements fit inside it. Add this code just before we return from the mutation handler:
mutations.view.fitToSelection();
Reload i2 Notebook, start another new chart, and run the commands in sequence. The selected elements are rearranged into a left-to-right temporal layout.
Next steps
By following the procedure in this tutorial, you've created an i2 Notebook plug-in that demonstrates some of the fundamental techniques for changing the information on an i2 Notebook chart. You've seen how to construct tracked and untracked mutations, how to commit them or roll them back, and how to use them for operations including creating and editing records and moving elements on the chart surface.
Many real plug-ins that you write for the i2 Notebook web client will use mutations as the means to achieving their purpose. But no matter how complicated the behavior of your plug-in becomes, the basic principles of interacting with the API do not change.