Creating a basic plug-in for the i2 Notebook web client
This tutorial takes you through creating a basic plug-in for the i2 Notebook web client that can take geospatial data from records on a chart and visualize it on a map outside of the application.
Note: The external mapping functionality that the plug-in provides is completely separate from the i2 Notebook web client's built-in Maps feature.
The plug-in adds commands to the user interface for users to interact with, but unlike the more complex plug-ins that you might create later, it does not display a user interface of its own. Other tutorials in the SDK describe how to create a plug-in with a user interface, using a number of popular frameworks:
This tutorial is not a prerequisite for any of the three that are listed above.
You can find a full version of the source code for this tutorial in the samples/basic-plugin
folder.
Prerequisites
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
A basic i2 Notebook plug-in has the same requirements as a more complex one: in the plug-in directory, you must create the manifest and the entry point that enable the i2 Notebook web client to load and integrate your plug-in.
The first part of this tutorial covers those requirements, and also explains how to set up both your development environment and a local Node.js server.
Create a directory for your plug-in. For example,
plugin
.In the new directory, use an IDE such as VSCode to create a
package.json
file that describes the plug-in project:{ "name": "plugin", "version": "1.0.0", "scripts": {} }
On the command line, navigate to the project directory and add the i2 Notebook SDK package to the project:
npm install @i2analyze/notebook-sdk
The package contains a type library that enables inline assistance in your IDE.
To set up auto-completion and other assistance, create and populate a file named
jsconfig.json
in the same directory:{ "compilerOptions": { "checkJs": true, "lib": ["es2020", "dom"], "target": "es2018", "types": ["@i2analyze/notebook-sdk"], "moduleResolution": "node", "strict": true }, "exclude": ["node_modules", "**/node_modules"] }
Note: For more information about the
jsconfig.json
file, see the Microsoft Visual Studio Code documentation.Now you're in position to write the plug-in itself, starting with the manifest. Create a file named
plugin.json
, with the following contents:{ "$schema": "https://i2group.github.io/notebook-sdk/schemas/plugin-manifest.json" }
The schema enforces the structure and content of the file. For more information, see the plug-in manifest documentation.
Continue to populate the manifest with values for
"name"
,"pluginId"
, and"pluginVersion"
; and set"entryPointUrl"
to"./entrypoint.js"
. For example:"name": "My plug-in", "pluginId": "00000000-0000-0000-0000-000000000001", "pluginVersion": "1.0.0", "entryPointUrl": "./entrypoint.js"
Next, we can create the entry point. Add a file named
entrypoint.js
to the plug-in directory, and populate it:async function main() { const api = await notebook.getEntryPointApi('00000000-0000-0000-0000-000000000001', '1.1'); api.logger.info('Plug-in running'); api.initializationComplete(); } void main();
Through this code, the plug-in retrieves the version of the entry point API that it wants to use, and uses it to declare that it has finished initializing. The GUID here must match the one in the plug-in manifest.
If you enter the code by hand, you'll see how the type library provides auto-completion as you go.
Back on the command line, add the
serve
package as a development dependency:npm install -D serve
Add a script to the
package.json
file to serve the project. For example:"scripts": { "start": "serve -l 5001" }
Start the project by running
npm start
.Finally, navigate to the URL where the project is being hosted (http://localhost:5001/, if you used the example) and confirm that
plugin.json
andentrypoint.js
are served correctly.
Test the plug-in in the i2 Notebook web client
As we're developing and testing a plug-in, we won't deploy the plug-in directly to the i2 Analyze server. Instead, we'll use the i2 Notebook SDK plug-in development proxy to add our plug-in to an existing i2 Notebook deployment without needing to modify it.
Anywhere on your workstation, create and populate a file named
devproxy.json
:{ "$schema": "https://i2group.github.io/notebook-sdk/schemas/devproxy.json" }
This forms the basis of the configuration file for the development proxy.
Add a value for
"server"
that is the URL of an existing i2 Analyze deployment. For example,"http://localhost:9082/opal/"
.Add a value for
"port"
to specify the port that the development proxy should listen on. For example,4000
.Add a value for
"plugins"
that contains the root URL of your plug-in. For example,["http://localhost:5001/"]
for the server that we configured above.On the command line, navigate to the folder that contains the configuration file, and run the i2 Notebook plug-in development proxy (no installation is required):
npx @i2analyze/notebook-sdk-plugin-proxy --config devproxy.json
Open the proxy URL in a browser and log in to the i2 Notebook web client. For example,
http://localhost:4000/opal/
.Open the browser's developer console and check for our plug-in's message being logged:
My plug-in Plug-in running
If you see this message, then the plug-in is working correctly.
Add a ribbon command to the plug-in
So far, you have a plug-in that's being loaded into the i2 Notebook web client - but it doesn't do anything. Next, we'll make the plug-in add a command to the application ribbon that opens geospatial locations in Google Maps.
In
entrypoint.js
, just before the call toinitializationComplete()
, create a command:const viewOnMap = api.commands.createCommand({ id: '00000000-0000-0000-0000-000000000002', name: 'View on map', type: 'records', icon: { type: 'inlineSvg', svg: '<svg viewBox="0 0 16 16"><rect width="8" height="8" x="4" y="4"/></svg>', }, onExecute(payload) {}, });
Straight afterward, surface the command on the ribbon's Home tab, just after the Information Store group:
api.commands.applicationRibbon.homeTab .after(api.commands.applicationRibbon.homeTab.systemGroups.searchInfoStore) .surfaceCommands(viewOnMap);
Reload i2 Notebook and check that the action is visible in the application ribbon.
Clicking the button won't do anything yet because we haven't implemented
onExecute()
, so let's do that now.Create this helper function at the top level of the
entrypoint.js
file:/** * @param {import("@i2analyze/notebook-sdk").data.IReadOnlyCollection<import("@i2analyze/notebook-sdk").records.IChartRecord>} records */ function findGeospatialValue(records) { for (const record of records) { for (const propertyType of record.itemType.propertyTypes) { if (propertyType.logicalType === 'geospatial') { const property = record.getProperty(propertyType); if (property !== undefined && !record.isValueUnfetched(property)) { return /** @type {import("@i2analyze/notebook-sdk").data.IGeoPoint} */ (property); } } } } return undefined; }
This function performs a simple scan of the records it receives, looking for a non-empty geospatial property that it returns if it finds one.
Note: The
/** @type {import...} */
and/** @param {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 ourentrypoint.js
file, we'd useimport type
statements instead.We can now use the helper function in the implementation of
onExecute()
that we left empty in the call toCreateCommand()
. We'll take the latitude and longitude to a new Google Maps window:onExecute(payload) { const property = findGeospatialValue(payload.records); if (!property) { return; } window.open( `https://www.google.com/maps/@${property.latitude},${property.longitude},18z`, "_blank" ); },
Reload i2 Notebook again, select an element with a geospatial property, and try the command. Google Maps should open at the relevant location.
Make the command sensitive to selection
There's a problem with our command: the button is enabled even if the selection doesn't actually contain a geospatial property. That would be misleading for a user, but we can fix it by taking control of the surfacing of the command in the user interface.
After the
onExecute()
parameter tocreateCommand()
, add anonSurface()
function definition:onSurface(action, eventApi, signal) { eventApi.addEventListener( "recordscontextchange", (context) => { action.setEnabled(!!findGeospatialValue(context.records)); }, { dispatchNow: true, signal } ); },
There are a few things to notice here:
- i2 Notebook calls our
onSurface()
function with anaction
object that represents the user interface control to which the command is bound each time it is surfaced. A single command might be surfaced in several places, and you can use theaction
object to react differently in each of them. - The function also receives an
eventApi
object through which it can listen to changes in the records to which the action is being applied; and asignal
object that indicates when this particular surfacing of the command is being removed. - In our implementation, we subscribe to the
recordscontextchange
event, which tells us when the current records change. When they do change, we set the enabled state of the action to a value based on whether there is a geospatial value in the current records. - Event listeners are normally invoked when the event occurs. However, we don't want to wait for a
recordscontextchange
event before we run oursetEnabled()
code, so we specifydispatchNow: true
to invoke the callback immediately, without waiting for the event. This in turn causes our action to be enabled or disabled correctly, right away. - We forward the
signal
to the event listener so that it unsubscribes automatically from therecordscontextchange
event when the action is unsurfaced.
- i2 Notebook calls our
Reload i2 Notebook. The button in the application ribbon is now enabled only when a geospatial property exists in the selection.
Use the command in more than one place
As well as adding it to the ribbon, we can add exactly the same command to the chart pop-up menu with a single line of code.
After the existing call to
surfaceCommands()
, add:api.commands.chartPopupMenu.surfaceCommands(viewOnMap);
Reload i2 Notebook. The same command, with the same enablement rules, is now present in the chart's pop-up menu.
Deploy the plug-in
To deploy our plug-in on the server, we need to add it to the server configuration, and then redeploy the server.
Note: If you follow this procedure in a deployment that provides high availability, you must complete each step on every Liberty server in your environment before you move to the next step.
On the server that hosts the i2 Analyze deployment, find the
toolkit/configuration
directory, and then navigate to thefragments/opal-services
directory that it contains.If the
opal-services
directory does not already contain aplugins
subdirectory, create one.Create a directory for our plug-in inside the
plugins
directory (for example,plugins/plugin-basic
), and copy theentrypoint.js
andplugin.json
files into it.Run the following toolkit commands to update the deployed i2 Analyze server:
setup -t stopLiberty setup -t deployLiberty setup -t startLiberty
Stop the development proxy, and use the browser to navigate to your real server address. You'll find that your plug-in was successfully deployed.
Next steps
By following the procedure in this tutorial, you've created an i2 Notebook plug-in that, though basic, is fully integrated with the application. It displays actions in the user interface, it responds to changes in selection, and it sends data from chart records to an external application.
Most real plug-ins go further than this one by adding a tool view that presents users with a custom interface for performing more complex tasks. You can see the fundamentals of how tool views work by following one of the examples that use popular UI frameworks to implement a tool view.