Creating a React plug-in
This is a long tutorial that will take a couple of hours to work through. It's designed to function more like a complete hands-on workshop than a simple 'Hello World' example, and takes you from nothing to creating a working (but simplified) replacement for the Record Inspector, using React.
The tutorial covers the following concepts:
- Configuring React correctly
- Starting a plug-in project
- Running the i2 Notebook plug-in development proxy
- Creating and surfacing commands in the application ribbon and the pop-up menu
- Subscribing to change and selection events, and using them to control the state of commands
- Creating a tool view
- Accessing chart data
- Accessing user data, such as theme choice and locale
- Formatting, localization, and right-to-left language support
- Common mistakes when writing plug-ins, with solutions for fixing them
And it does so as you build the example through these stages:
- Create the simplest possible plug-in, without a custom user interface. We'll get it working in a development environment, and then add a ribbon command that accesses chart data and sends some of it to an external website for viewing.
- Create a custom tool view, and learn about configuring React paths and working with a Content Security Policy.
- Do some basic styling and theming.
- Access property data, learn about the facilities that the API provides to assist with consistent formatting, and display images and links to 360 views.
- (Briefly) cover issues with right-to-left and bidirectional text.
- Add record-navigation support to the tool view, and learn about restrictions on when you can access i2 Notebook data and best practices for working within those constraints.
- Learn about persisting tool view state, so that the user interface doesn't reset itself when being docked or floated.
- Remove the built-in Record Inspector and replace it with our own.
- Deploy our completed plug-in onto an i2 Analyze server.
You can find a full version of the source code for this tutorial in the samples/react-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 UI-free 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 an empty React TypeScript application. For example:
npm create vite@latest plugin-react -- --template react-ts
Note: The Vite build tool comprises a development server and a build command for creating and testing modern web applications. For more information, see https://vitejs.dev.
You don't have to use TypeScript to develop a plug-in for i2 Notebook, but the extra type information makes lots of things more robust, and so we do use it in this tutorial.
Change directory to the new plug-in and install the dependencies. For example:
cd plugin-react
And then:
npm install
It will likely take several minutes to configure and install the dependencies.
Add the i2 Notebook SDK package:
npm install @i2analyze/notebook-sdk
The package contains a type library that enables inline assistance in your IDE.
Now you're in position to write the plug-in itself, starting with the manifest. Using an IDE such as VSCode, create a file named
plugin.json
inpublic
, 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 i2 Notebook 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 thepublic
directory, and populate it:// @ts-check /* global notebook */ /// <reference types="@i2analyze/notebook-sdk" /> 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.
Modify
tsconfig.json
to configure the environment to use"downlevelIteration"
.In
"compilerOptions"
, add"downlevelIteration": true
.Back on the command line, start the React project in development mode:
npm run dev
Make a note of the connection address. For example,
http://localhost:5173
. We'll need that to connect to, and reference, our plug-in.Navigate to the project in a web browser. You should see a React demonstration page. Confirm that
plugin.json
andentrypoint.js
are served correctly from their respective paths (for example,http://localhost:5173/plugin.json
andhttp://localhost:5173/entrypoint.js
).
Test the plug-in in the i2 Notebook web client
While 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:5173/"]
for the server that we configured above.On the command line, navigate to the folder that contains the configuration file, and start 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, log in to the i2 Notebook web client and open a new chart. For example,
http://localhost:4000/opal/
.Open the browser's developer console and check for our plug-in's message being logged:
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 that opens geospatial locations in Google Maps to the application ribbon.
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', icon: { type: 'inlineSvg', svg: '<svg viewBox="0 0 16 16"><rect width="8" height="8" x="4" y="4"/></svg>', }, type: 'records', 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 the i2 Notebook web client, 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 the web client 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 the i2 Notebook web client. 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.chartItemPopupMenu.surfaceCommands(viewOnMap);
Reload the web client. The same command, with the same enablement rules, is now present in the chart's pop-up menu.
Create a tool view
The first part of this tutorial didn't use any features from the React framework! That changes now, as we use React to create a custom user interface for our plug-in:
Navigate to the
src
folder inside the project directory, and create a React component in a new file namedToolView.tsx
:export default function ToolView() { return <div>tool-view1 works!</div>; }
In the same folder, replace the contents of the generated
App.tsx
file with:import ToolView from './ToolView'; export default function App() { return ( <div> <ToolView /> </div> ); }
In a web browser, load the root page (
/
) from your React development environment (http://localhost:5173/
). You should see the message: "tool-view1 works!".We'll now turn the React component into an i2 Notebook tool view.
In
public/entrypoint.js
, insert a line before the call toinitializationComplete()
:const toolView = api.createToolView('My tool view', './');
This line declares a tool view by specifying its display name ('My tool view') and its location relative to the entry point, which is '
./
'.Still in the entry point code, tell i2 Notebook to create a command that opens and closes the tool view:
const toggle = api.commands.createToolViewToggleCommand( { id: '00000000-0000-0000-0000-000000000003', name: 'My tool', icon: { type: 'inlineSvg', svg: '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="4" /></svg>', }, }, toolView );
Finally, surface the command in the application ribbon's Home tab. This code places it after the View on map command that we developed above:
api.commands.applicationRibbon.homeTab.after(viewOnMap.id).surfaceCommands(toggle);
Reload the i2 Notebook web client, and you'll see the new button in the ribbon. Click it to display the tool view in the user interface; click it a second time to hide it again.
However, while the tool view pane is correctly opening and closing, there's a problem: we don't see the contents of our tool view in the pane.
To see what's happening, open up the developer tools in your browser, and look at the network diagnostics. When you click to display the tool view, you'll see that requests to load resources are failing due to Content Security Policy restrictions.
We'll fix those problems in the next section.
Configure React to serve content from the correct place
New
React
applications are hard-wired to serve their content from the root (/
)
of the server they're running on.
i2 Notebook plug-ins, on the other hand, are served from <context root>/plugins/<some directory>
on the i2 Analyze server, and our plug-in development proxy is simulating that for us.
When you display the
React
tool view, the .html
file loads correctly, but the .tsx
file fail to load because the .html
file refers to them relative to the root - for example, /src/main.tsx
.
This would work if the plug-in were served from /
, but as it won't be, we need to make
React
generate the paths differently.
We'll adjust the Vite configuration to serve the content from an explicit, hard-coded path.
Find the context path that your Liberty server is using. This is the path part of the
server
URL in your development proxy configuration file. For example, if yourserver
ishttp://someserver:1234/contextpath
, then it's/contextpath
that we need.Pick a short plug-in folder name, such as
myplugin
. The name needs to be different from every other plug-in in your deployment, but remember that you won't have to use it at deployment time; only during development.The path to your plug-in will be
/<contextpath>/plugins/<shortname>
. For example,/opal/plugins/myplugin
.Modify the path to your plug-in in the development proxy configuration file, adding the plug-in path. For example, replace
http://localhost:5173/
withhttp://localhost:5173/opal/plugins/myplugin
.Open the
vite.config.ts
file in the project root, and addbase: '/opal/plugins/myplugin'
to thedefineConfig
object:export default defineConfig({ plugins: [react()], base: '/opal/plugins/myplugin', });
Stop the development proxy, and then start it again with the same command as before.
Reload the i2 Notebook web client and try the tool view again.
There's still no message.
Look in the browser console, and you'll see some errors about the browser refusing to load the script within the
.html
file because of a violation of a content security policy (CSP) directive. We must fix that before we'll be able to apply any styling successfully.
Configure the content security policy
i2 Notebook runs with a heavily locked-down content security policy (CSP) to limit the attack surface.
Vite,
however, loads the main.tsx
file through a <script>
element. It also uses inline styling. Both of these features require a looser CSP.
We can use the plug-in manifest to loosen the CSP for our tool view.
Edit the
public/plugin.json
file and add a"toolViewCsp"
section:"toolViewCsp": { "script-src": "'unsafe-inline'", "style-src": "'unsafe-inline'" }
Once again, reload the i2 Notebook web client and display the tool view. The 'tool-view1 works!' text should now be displayed in the tool view panel. The browser console will now be free of CSP errors associated with styling and scripting for our plug-in, so we can proceed with some styling.
Note: If you open the developer tools at this point and look at the console, you will still see other CSP errors where Vite is trying to connect to
ws://...
. These violations are due to the Vite application developer environment, and you can safely ignore them.
Create a hook that provides the tool view API
We'll need to access the i2 Notebook tool view API from multiple components in our React application. Also, because fetching the API is asynchronous, we'll want to prevent the React application from loading until it is ready and available to use.
To address both of these requirements, we can make the tool view API available through a React hook.
In the
src
folder, create a React context and helper hook in a new file nameduseToolViewApi.ts
:import { toolview } from '@i2analyze/notebook-sdk'; import { createContext, useContext } from 'react'; export const ToolViewApiContext = createContext<toolview.IToolViewApi | undefined>(undefined); export function useToolViewApi() { return useContext(ToolViewApiContext)!; }
In the same folder, replace the contents of
App.tsx
with the following:import { getToolViewApi, toolview } from '@i2analyze/notebook-sdk'; import { useEffect, useState } from 'react'; import ToolView from './ToolView'; import { ToolViewApiContext } from './useToolViewApi'; export default function App() { const [toolViewApi, setToolViewApi] = useState<toolview.IToolViewApi | undefined>(); useEffect(() => { getToolViewApi().then((api) => { setToolViewApi(api); }); }, []); if (toolViewApi) { return ( <ToolViewApiContext.Provider value={toolViewApi}> <ToolView /> </ToolViewApiContext.Provider> ); } else { return null; } }
Let's walk through that code:
- The
App
component imports thegetToolViewApi()
function from the@i2analyze/notebook-sdk
package. - It calls that function inside a
useEffect()
, so thatgetToolViewApi()
is called when theApp
component renders for the first time. - After
getToolViewApi()
returns its value asynchronously, that value is put into thetoolViewApi
state variable. - Until
toolViewApi
has a value,App
renders nothing (vianull
). - When
toolViewApi
is available (in practice this will be almost immediately), it renders<ToolView />
inside a custom React context, which makes the API value available to every component in the sub-tree.
- The
Configure the tool view for i2 Notebook web client themes
The i2 Notebook web client supports a number of visual themes. If you're using a light theme, the "tool-view1 works!" text should have been clear in your tool view. But if you're using a dark theme, the text was probably harder to read. We haven't yet configured the tool view to respect and respond to themes.
Note: To change the theme in the i2 Notebook web client, go to the user menu and select Settings to open the Settings dialog.
To style our tool view correctly, we need to access the theme information and use it to change the appearance of the text.
First, in
src/main.tsx
, remove theimport './index.css'
.Create a file named
src/ToolView.css
that contains:.dark-theme { color: white; } .light-theme { color: black; }
In other words, we want to set the font color to
white
indark
themes, and toblack
inlight
themes.In
src/ToolView.tsx
, importuseToolViewApi
and use it to apply a different CSS class fromToolView.css
, based on the current theme:import { useToolViewApi } from './useToolViewApi'; import './ToolView.css'; export default function ToolView() { const toolViewApi = useToolViewApi(); const themeClass = toolViewApi.theme.appearance === 'dark' ? 'dark-theme' : 'light-theme'; return <div className={themeClass}>tool-view1 works!</div>; }
Now, our tool view will apply the
light-theme
ordark-theme
class based on theme information from the i2 Notebook web client.Reload the i2 Notebook web client and display the tool view again. Change the theme by swapping between light and dark, and notice how the tool view automatically adjusts its text coloring to match.
Access record data
We've done a lot of work to make our tool view integrate nicely with the i2 Notebook web client. Now it needs to do something useful. Let's enable it to access the data in selected records, which starts by working out what the current selection is:
At the top of the
ToolView.tsx
file, import thechart
namespace from@i2analyze/notebook-sdk
and several helpers from React:import { useCallback, useEffect, useState } from 'react'; import type { chart, records } from '@i2analyze/notebook-sdk';
Inside the
ToolView()
component function, afterthemeClass
calculation, set up an initially empty piece ofrecordLabel
state with:const [recordLabel, setRecordLabel] = useState('');
When the chart selection changes, we'll set our label state to be the label of the first record in the selection. Continue in the same file, defining a selection change handler and a helper function:
const setValuesForRecord = useCallback((record: records.IChartRecord | undefined) => { if (record) { setRecordLabel(record.labelOrFallback); } else { setRecordLabel('No records selected.'); } }, []); const handleChartSelectionChange = useCallback<chart.SelectionListener>( (selection) => { const record = selection.records.firstOrDefault(undefined); setValuesForRecord(record); }, [setValuesForRecord] );
The
firstOrDefault
function returns the first record from the selection, falling back to the specifiedundefined
if there isn't a first record. ThelabelOrFallback
property reads the label of the record, falling back to a standard string if empty.We can use the handler to subscribe to the
chartselectionchange
event. We must also clean up the event subscription when theToolView
component is unmounted. Just before thereturn
statement, add:useEffect(() => { const unsubscribe = toolViewApi.addEventListener( 'chartselectionchange', handleChartSelectionChange ); return unsubscribe; }, [handleChartSelectionChange, toolViewApi]);
Note: This code isn't quite right, but we'll explain why (and come back and fix it) shortly.
Now we'll display the extracted record label. Replace the existing
return
statement with:return ( <div className={themeClass}> <h1 className="record-header">{recordLabel}</h1> </div> );
Note: This and later HTML code uses CSS classes to apply styling to the tool view. To add the classes to your project, replace the existing file at
src/ToolView.css
with its equivalent from Github.Try the tool view again. Click around on the chart, selecting different items. It does now display the label of the first record in the chart selection, but there's still a problem.
Close the tool view, select an item on the chart, and then open the tool view again.
Notice that when the tool view opens, it's not displaying the record label. After you change the selection, the tool view does display the label correctly, but it's not displaying correctly at startup.
The problem is that we're only setting the
recordLabel
field after achartselectionchange
event is received. Adding thedispatchNow
option to theaddEventListener
subscription arranges for thechartselectionchange
handler to be called immediately.In
src/ToolView.tsx
, inside theuseEffect()
addEventListener()
call, add adispatchNow: true
option:const unsubscribe = toolViewApi.addEventListener( 'chartselectionchange', handleChartSelectionChange, { dispatchNow: true } ); return unsubscribe;
Reload the web client again, select an item, and open the tool view. This time, it immediately displays the label of the first selected record (or our "No records selected." message) without waiting for a selection change.
Display property data
We have the record label, but we can build up our tool view to display data from more record properties.
In
ToolView.tsx
, modify the existing import to add thedata
andschema
namespaces:import { chart, data, records, schema } from '@i2analyze/notebook-sdk';
Declare an interface that represents how we'll display the properties that we're going to retrieve:
interface IProperty { id: schema.ChartPropertyTypeId; label: string; value: data.PropertyValue; }
Next, after the
recordLabel
useState()
call, add a state value to store the record property information:const [properties, setProperties] = useState<IProperty[]>([]);
And edit
setValuesForRecord()
to extract property as well as label information from the record:const setValuesForRecord = useCallback((record: records.IChartRecord | undefined) => { if (record) { setRecordLabel(record.labelOrFallback); const properties: IProperty[] = []; for (const propertyType of record.itemType.propertyTypes) { const property = record.getProperty(propertyType); if (property !== undefined && !record.isValueUnfetched(property)) { properties.push({ id: propertyType.id, label: propertyType.displayName, value: property, }); } } setProperties(properties); } else { setRecordLabel('No records selected.'); setProperties([]); } }, []);
The
record.getProperty()
method can return values of typeundefined
,data.IValueNotFetched
, ordata.PropertyValue
:An
undefined
value indicates that the record has no value for the specified property type.A not-fetched property value (
data.IValueNotFetched
) indicates that the record does have a value for the property, but we don't have that value in the client at the moment.Note: You can ensure the property is fetched by using the
chart.ensurePropertiesFetched()
method.A property value (
data.PropertyValue
) can be an object representing any of the legal data types in the schema. It might be a string, or a number, or a more complicated object such as a date, time, date-time, decimal, or geospatial structure.
When we have a record, we filter out the
undefined
and not-fetched properties before displaying the remainder in our tool view. When we have no record at all, we set the properties to be an empty array.Modify the rendering code (the
return
statement) to display the property data after the record label:return ( <div className={themeClass}> <h1 className="record-header">{recordLabel}</h1> <div className="record-properties"> {properties.map((property) => ( <div key={property.id} className="property"> <div className="property-label">{property.label}</div> <div className="property-value">{property.value}</div> </div> ))} </div> </div> );
Reload the i2 Notebook web client and try out the tool view. Depending on what data you have in your chart, it might or might not work!
If you have only string data, it'll work just fine. However, if you have date, time, date-time, or geospatial objects in your selected records, you'll get errors from React along the lines of:
Objects are not valid as a React child (found: object with keys {dateTime timeZone, isDst})
.We've attempted to render property values without considering what they are or how to format them. Take a closer look at the typing for
data.PropertyValue
. It can represent any of the legal data types in the schema! These values might be objects, and in displaying them we should respect the user's locale and formatting settings.Extract the formatter so that it's easy for us to use it in multiple places. In
ToolView.tsx
, just after theuseState()
calls, add:const formatter = toolViewApi.formatter;
And add a call to
formatter.formatValue()
in the property value rendering code:<div className="property-value">{formatter.formatValue(property.value)}</div>
Reload the i2 Notebook web client and try out the tool view. Property values are now displayed, and numbers, dates, and times are formatted the same as in the rest of the application.
Display an image, and a link to more information
So far, our tool view displays the label of a selected record and its property values. In this section, we'll go further by displaying an image to represent the record, and by linking to its 360 view (if it has one).
First, add state variables in the
ToolView
component to store the image and the URL, just after the existinguseState()
calls:const [threeSixtyUrl, setThreeSixtyUrl] = useState<string | undefined>(); const [image, setImage] = useState<data.IImage | undefined>();
In the
setValuesForRecord()
function, set these properties if we have a record, and clear them if we don't:if (record) { setRecordLabel(record.labelOrFallback); setThreeSixtyUrl(record.get360ViewUrl()); setImage(record.image ?? (record.isEntity() ? record.itemType.image : undefined)); ... } else { setRecordLabel("No records selected."); setThreeSixtyUrl(undefined); setImage(undefined); ... }
This code uses the record image if one exists, and falls back to the image associated with the record's item type if it does not.
Still in
ToolView.tsx
, edit the<h1>
rendering code to display the image and the link (if it exists):<h1 className="record-header"> {image ? <img className="record-image" src={image.href} alt={image.description} /> : null} {threeSixtyUrl ? ( <a className="record-label" href={threeSixtyUrl} target="_blank" rel="noreferrer"> {recordLabel} </a> ) : ( <span className="record-label">{recordLabel}</span> )} </h1>
The
target="_blank"
attribute on the<a>
element is so that the view opens in a new browser tab or window, rather than in the tool view! React's linting tool recommendsrel="noreferrer"
.Reload the i2 Notebook web client once again, and watch the behavior of the tool view:
- For entity records that came from the Information Store, the label is now also a hyperlink.
- For all records that have images, the tool view displays that image.
Support globalization
Not all users speak English, and not all languages are read from left to right. If your tool view is aimed internationally, it should flow and display its contents correctly in all locales. The i2 Notebook Web API includes support for dealing with localization.
First, let's demonstrate one of the problems by simulating an Arabic locale.
In your browser address bar, after the
contextpath
part of the address, add the query parameter?translationLocale=ar-sa
and reload the page. (For example,http://localhost:4000/opal/?translationLocale=ar-sa#charts
)The i2 Notebook web client responds by simulating the locale of Arabic (Saudi Arabia).
Note: The web client actually provides two locales: the
translationLocale
, which is used for the translation of the text; and theformattingLocale
, which is used for formatting data for display. The?translationLocale
query parameter simulates both the translation and the formatting locale.If you display the built-in Record Inspector tool (using the
R
keyboard shortcut), you'll see how its user interface now flows from right to left.When you open your tool view, however, it still flows from left to right.
The API can provide the information that we need. We just have to use it in the right place!
In
ToolView.tsx
, change the top-level<div>
element in thereturn
statement to add adir
attribute:<div dir={toolViewApi.locale.flowDirection} className={themeClass}>
The
flowDirection
property has either the valueltr
orrtl
, which is perfect for use with thedir
HTML attribute.Reload the i2 Notebook web client and display the tool view again. It's laid out correctly according to the locale. When you add and remove the
?translationLocale=ar-sa
query parameter, the flow alternates between left-to-right and right-to-left behavior.
Note: By using the built-in formatter for property values, we get date, time, and numeric formatting for the Arabic (Saudi Arabia) locale automatically. Select items on your chart that have date, time, or numeric properties to see this in action.
Another globalization issue is the text in the tool view. When simulating the Arabic (Saudi Arabia) locale, the "No records selected." text doesn't display correctly - it actually displays as ".No records selected".
This problem occurs because the browser is trying to lay out the text from right to left, but it doesn't realize that the "." is meant to flow with the text, and shouldn't be considered by itself. (This issue affects most punctuation characters and numbers that aren't considered to be "left-to-right" or "right-to-left", but instead take their directionality from the context around them.)
To get the text to behave correctly, we need to mark it up with bidirectional characters. Once again, we can use the formatter from the tool view API.
In
ToolView.tsx
, useformatter.wrapForBidi()
on both renderings ofrecordLabel
. Replace both instances of:{recordLabel}
with:
{formatter.wrapForBidi(recordLabel, "raw")}
Reload the i2 Notebook web client, and continue to simulate the Arabic (Saudi Arabia) locale. The text is now wrapped correctly, and displays with the period at the end of the text as "No records selected."
In general, you should use
wrapForBidi()
for all read-only text that you display to the user. It's particularly important for text whose contents you don't know in advance because it comes from record data.Update the plug-in code to use
wrapForBidi()
throughoutToolView.tsx
. Specifically, the rendering forproperty-label
andproperty-value
becomes:<div className="property-label"> {formatter.wrapForBidi(property.label, "raw")} </div> <div className="property-value"> {formatter.wrapForBidi(formatter.formatValue(property.value), "raw")} </div>
A further consideration for good globalization is that some images and icons should be reversed in right-to-left locales, while others should not. For example, back and forward navigation buttons should always be mirrored. For more information on this and other topics, consult a resource such as https://material.io/design/usability/bidirectionality.html#mirroring-elements.
Before you continue with this tutorial, remove the ?translationLocale=ar-sa
query parameter to stop simulating the Arabic (Saudi Arabia) locale.
Add record navigation to the tool view
So far, we have looked only at the first record in the selection, which is useful in an example but not realistic. Now let's look at all the records in the selection, and provide the user with a pair of buttons to page through those records and inspect their properties. As part of this work, we'll demonstrate what errors can occur if you access the data incorrectly, and then show you how to access the data correctly.
Add properties for the index of the current record, the total number of records, and the current selection to
ToolView
, just after the existinguseState()
calls:const [currentRecordIndex, setCurrentRecordIndex] = useState(0); const [totalRecords, setTotalRecords] = useState(0); const [selection, setSelection] = useState<chart.ISelection | undefined>();
We'll store the selection and choose a record based on
currentRecordIndex
. Replace the implementation ofhandleChartSelectionChange()
with the following code:const handleChartSelectionChange = useCallback<chart.SelectionListener>((selection) => { setCurrentRecordIndex(0); setTotalRecords(selection.records.size); setSelection(selection); }, []); useEffect(() => { const records = Array.from(selection?.records || []); setValuesForRecord(records[currentRecordIndex]); }, [selection, setValuesForRecord, currentRecordIndex]);
Note: This code isn't quite right, but we'll explain why (and come back and fix it) shortly. Also, ignore any lint warnings you see about the last line for now. They're actually the least of our worries!
This stores the selection each time it changes, and uses a
useEffect()
to update the values for the component each time the current record index changes.Next, we're going to move the responsibility for reporting a lack of records from the record display to a title area. In the
setValuesForRecord()
function, replace this code:setRecordLabel('No records selected.');
with:
setRecordLabel('');
Add handler functions to update the
currentRecordIndex
, just abovehandleChartSelectionChange()
:const handlePreviousButtonClick = () => { setCurrentRecordIndex((r) => r - 1); }; const handleNextButtonClick = () => { setCurrentRecordIndex((r) => r + 1); };
And just before the
return
statement, compute anavigationTitle
value:const navigationTitle = formatter.wrapForBidi( totalRecords > 0 ? `Record ${formatter.formatValue(currentRecordIndex + 1)} of ${formatter.formatValue( totalRecords )}` : 'No records selected.', 'raw' );
This code creates a title with formatted numbers for the selected record and the total record count.
Inside the
return
statement, just above the<h1>
element, add the title and our navigation buttons:<div className="record-navigation"> <div>{navigationTitle}</div> {totalRecords > 0 ? ( <div className="navigation-buttons"> <button className="navigation-button" onClick={handlePreviousButtonClick} title="Previous record" disabled={currentRecordIndex === 0} > < </button> <button className="navigation-button" onClick={handleNextButtonClick} title="Next record" disabled={currentRecordIndex === totalRecords - 1} > > </button> </div> ) : null} </div>
This gives users a pair of buttons for changing the current record, and a title displaying which record they're viewing out of the total. It hides the buttons if there are no records selected.
Reload the i2 Notebook web client and open the plug-in.
It doesn't work.
If you look in the console, you'll see an error message:
Application and chart data MUST only be accessed during event listener or transaction listener callbacks. It is not safe to access data at any other time.
We are trying to access data outside an event or transaction listener. We store the selection data in our component, and attempt to read it when the user clicks Next.
It doesn't matter if you're storing the selection or the records. You cannot access i2 Notebook chart data outside an event, mutation, or transaction listener. This rule ensures that API consumers always see a consistent view of the application data. Whenever you want to read data outside an event handler, you must request access to it from the application through a transaction or a mutation.
Remove the
selection
useState()
line fromToolView.tsx
. Instead of storing the records, we'll use the current record index to request the record that we want from the application.Replace the
handleChartSelectionChange()
function and theuseEffect()
after it (which read theselection
state variable) with this code:const handleChartSelectionChange = useCallback<chart.SelectionListener>( (selection) => { setCurrentRecordIndex(0); setTotalRecords(selection.records.size); setValuesForRecord(selection.records.firstOrDefault(undefined)); }, [setValuesForRecord] ); useEffect(() => { toolViewApi.runTransaction((application) => { const selection = application.chart.selection; const records = Array.from(selection.records); setTotalRecords(selection.records.size); const record = records[currentRecordIndex]; setValuesForRecord(record); }); }, [toolViewApi, setValuesForRecord, currentRecordIndex]);
Working through this new code in detail:
- The i2 Notebook web client calls the
handleChartSelectionChange()
function when the chart selection changes. As such, the function is permitted to access as much application data as it likes. As before, it resets the currently selected record index to 0, stores the total number of selected records, and then invokessetValuesForRecord()
to set the component state for the record. - The
useEffect()
listens for changes in thecurrentRecordIndex
, and in response to these changes schedules an i2 Notebook application transaction. The callback that runs under that transaction accesses the chart selection and retrieves the currently indexed record, callingsetValuesForRecord()
. - This provides safe access for all of our data.
- The i2 Notebook web client calls the
Reload the i2 Notebook web client, select multiple items on the chart, and try using the buttons in our tool view to navigate among their records.
Now, it works!
Extension: Ensure fetched properties
While viewing record properties, eventually a property will be shown as Exists but not fetched
.
You can ensure that the property is fetched.
- Change the
useEffect()
function so that it ensures all properties are fetched before setting the values for records:
useEffect(() => {
toolViewApi.runTransaction(async (application) => {
const selection = application.chart.selection;
const records = Array.from(selection.records);
setTotalRecords(selection.records.size);
const record = records[currentRecordIndex];
if (record) {
const propertyTypes = record.itemType.propertyTypes;
await application.chart.ensurePropertiesFetched(record, propertyTypes);
}
setValuesForRecord(record);
});
}, [toolViewApi, setValuesForRecord, currentRecordIndex]);
Store and restore state when the tool view is floated and docked
Our tool view is almost complete, but for now there are still problems to solve. For example, you can select multiple items on the chart surface, and navigate to the second record in the selection by clicking Next. But if you then float the tool view, it returns to showing the first record again.
When a tool view is docked or floated, its user interface is completely recreated in a different browser window, causing all of its temporary state to be lost.
To address this, we can make use of the volatile store that's available from the tool view API. This store allows us to store state so that it survives tool view re-creation. (It's called the "volatile" store because its state does not persist across application reloads.)
In
ToolView.tsx
, addtoolview
to the@i2analyze/notebook-sdk
import:import { chart, data, records, schema, toolview } from '@i2analyze/notebook-sdk';
In
ToolView.tsx
, just before the computation ofnavigationTitle
, add this code to save the current record index when the tool view is unloading:const handleUnload = useCallback<toolview.ToolViewUnloadListener>(() => { toolViewApi.volatileStore.set('currentRecordIndex', currentRecordIndex); }, [toolViewApi, currentRecordIndex]); useEffect(() => { return toolViewApi.addEventListener('unload', handleUnload); }, [toolViewApi, handleUnload]);
This code means that we store
currentRecordIndex
when the tool view is unloaded.Modify the existing
useEffect()
function that subscribes tochartselectionchange
to retrieve the value ofcurrentRecordIndex
from the volatile store when the tool view is loaded. Add the following code before thereturn unsubscribe
line:const initialIndex = toolViewApi.volatileStore.get<number>('currentRecordIndex') || 0; setCurrentRecordIndex(initialIndex);
Because we're now fetching data immediately when the tool view starts up, we no longer need to use the
dispatchNow
option for thechartselectionchange
event handler in the aboveuseEffect()
. Remove it, leaving the event subscription as follows:const unsubscribe = toolViewApi.addEventListener( 'chartselectionchange', handleChartSelectionChange );
The tool view now correctly maintains its state across docking and floating operations.
Clear state when the tool view is closed
We're now preserving the tool view state across floating and docking operations. However, we're also preserving that state if the user explicitly closes the tool view, which isn't appropriate. When the user closes the tool view, we should reset the state.
The unload
event can tell us whether the tool view is being unloaded because of an explicit close operation.
In
ToolView.tsx
, change thehandleUnload()
function to:const handleUnload = useCallback<toolview.ToolViewUnloadListener>( (isClosing) => { if (isClosing) { toolViewApi.volatileStore.clear(); } else { toolViewApi.volatileStore.set('currentRecordIndex', currentRecordIndex); } }, [toolViewApi, currentRecordIndex] );
Now, if the tool view is being closed, we'll discard our state. Otherwise, we'll persist it. Reload the i2 Notebook web client to test the changes we've made. Closing the tool view will reset the record index.
Replace the built-in Record Inspector with our tool view
We now have a functional tool view that allows users to view some of the details of a record. We can even use it to replace the web client's built-in Record Inspector!
Removing the Record Inspector is easy. Edit
public/entrypoint.js
to insert the following command-removal code just before the call toinitializationComplete()
:api.commands.removeCommand(api.commands.systemCommands.toggleRecordInspector);
Change the tool view toggle command name to "Record inspector plug-in", and add a
keyboardHelp
section so that the tool can be opened using a keyboard shortcut:const toggle = api.commands.createToolViewToggleCommand( { id: '00000000-0000-0000-0000-000000000003', name: 'Record inspector plug-in', icon: { type: 'inlineSvg', svg: '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="4" /></svg>', }, keyboardHelp: { category: 'discover', label: 'Toggle record inspector plug-in', keys: ['shift+r'], }, }, toolView );
Reload the i2 Notebook web client, and you should see that:
- The built-in "Record Inspector" tool has been removed.
- The keyboard help dialog (displayed by pressing "h") lists the keyboard shortcut
Shift+R
for our tool view. - Using the keyboard shortcut
shift+r
also toggles our tool view.
Deploy the plug-in
Before we can deploy our React plug-in to a real server, we need to make a production build. Run the following command from the plugin-react
directory:
npm run build
This creates a production build of the plug-in inside build
.
For the actual deployment, we need to add the built plug-in 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.Copy the
build
directory so that it becomes a subdirectory ofplugins
, and rename it as you like. For example,plugins/plugin-react
.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
In this tutorial, we've:
- Created a command to show a record's position on a map, and added that command to the application ribbon and the chart pop-up menu
- Created a tool view to display record data, and replaced the built-in Record Inspector with this new tool view
- Considered data access, formatting, and globalization
- Deployed our plug-in on the i2 Analyze server
This is just a flavor of what you can do with the @i2analyze/notebook-sdk
package.
To understand more about the capabilities of the i2 Notebook Web API, see the rest of the SDK documentation.