i2 Notebook SDK
Search results for

    Show/hide table of contents

    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:

    1. 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.
    2. Create a custom tool view, and learn about configuring React paths and working with a Content Security Policy.
    3. Do some basic styling and theming.
    4. Access property data, learn about the facilities that the API provides to assist with consistent formatting, and display images and links to 360 views.
    5. (Briefly) cover issues with right-to-left and bidirectional text.
    6. 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.
    7. Learn about persisting tool view state, so that the user interface doesn't reset itself when being docked or floated.
    8. Remove the built-in Record Inspector and replace it with our own.
    9. 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.

    1. 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.

    2. 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.

    3. Add the i2 Notebook SDK package:

      npm install @i2analyze/notebook-sdk

      The package contains a type library that enables inline assistance in your IDE.

    4. 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 in public, 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.

    5. 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"
      
    6. Next, we can create the entry point. Add a file named entrypoint.js to the public 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.

    7. Modify tsconfig.json to configure the environment to use "downlevelIteration".

      In "compilerOptions", add "downlevelIteration": true.

    8. Back on the command line, start the React project in development mode: npm run dev

    9. Make a note of the connection address. For example, http://localhost:5173. We'll need that to connect to, and reference, our plug-in.

    10. Navigate to the project in a web browser. You should see a React demonstration page. Confirm that plugin.json and entrypoint.js are served correctly from their respective paths (for example, http://localhost:5173/plugin.json and http://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.

    1. 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.

    2. Add a value for "server" that is the URL of an existing i2 Analyze deployment. For example, "http://localhost:9082/opal/".

    3. Add a value for "port" to specify the port that the development proxy should listen on. For example, 4000.

    4. 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.

    5. 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

    6. 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/.

    7. Open the browser's developer console and check for our plug-in's message being logged:

      Log message

      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.

    1. In entrypoint.js, just before the call to initializationComplete(), 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) {},
      });
      
    2. 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);
      
    3. 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.

    4. 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 our entrypoint.js file, we'd use import type statements instead.

    5. We can now use the helper function in the implementation of onExecute() that we left empty in the call to CreateCommand(). 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"
          );
        },
      
    6. 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.

    1. After the onExecute() parameter to createCommand(), add an onSurface() 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 an action 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 the action 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 a signal 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 our setEnabled() code, so we specify dispatchNow: 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 the recordscontextchange event when the action is unsurfaced.
    2. 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.

    1. After the existing call to surfaceCommands(), add:

      api.commands.chartItemPopupMenu.surfaceCommands(viewOnMap);
      
    2. 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:

    1. Navigate to the src folder inside the project directory, and create a React component in a new file named ToolView.tsx:

      export default function ToolView() {
        return <div>tool-view1 works!</div>;
      }
      
    2. 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>
        );
      }
      
    3. 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.

    4. In public/entrypoint.js, insert a line before the call to initializationComplete():

      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 './'.

    5. 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
      );
      
    6. 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);
      
    7. 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.

    1. 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 your server is http://someserver:1234/contextpath, then it's /contextpath that we need.

    2. 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.

    3. Modify the path to your plug-in in the development proxy configuration file, adding the plug-in path. For example, replace http://localhost:5173/ with http://localhost:5173/opal/plugins/myplugin.

    4. Open the vite.config.ts file in the project root, and add base: '/opal/plugins/myplugin' to the defineConfig object:

      export default defineConfig({
        plugins: [react()],
        base: '/opal/plugins/myplugin',
      });
      
    5. Stop the development proxy, and then start it again with the same command as before.

    6. 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.

    1. Edit the public/plugin.json file and add a "toolViewCsp" section:

      "toolViewCsp": {
        "script-src": "'unsafe-inline'",
        "style-src": "'unsafe-inline'"
      }
      
    2. 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.

    1. In the src folder, create a React context and helper hook in a new file named useToolViewApi.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)!;
      }
      
    2. 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 the getToolViewApi() function from the @i2analyze/notebook-sdk package.
      • It calls that function inside a useEffect(), so that getToolViewApi() is called when the App component renders for the first time.
      • After getToolViewApi() returns its value asynchronously, that value is put into the toolViewApi state variable.
      • Until toolViewApi has a value, App renders nothing (via null).
      • 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.

    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.

    1. First, in src/main.tsx, remove the import './index.css'.

    2. 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 in dark themes, and to black in light themes.

    3. In src/ToolView.tsx, import useToolViewApi and use it to apply a different CSS class from ToolView.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 or dark-theme class based on theme information from the i2 Notebook web client.

    4. 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:

    1. At the top of the ToolView.tsx file, import the chart namespace from @i2analyze/notebook-sdk and several helpers from React:

      import { useCallback, useEffect, useState } from 'react';
      import type { chart, records } from '@i2analyze/notebook-sdk';
      
    2. Inside the ToolView() component function, after themeClass calculation, set up an initially empty piece of recordLabel state with:

      const [recordLabel, setRecordLabel] = useState('');
      
    3. 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 specified undefined if there isn't a first record. The labelOrFallback property reads the label of the record, falling back to a standard string if empty.

    4. We can use the handler to subscribe to the chartselectionchange event. We must also clean up the event subscription when the ToolView component is unmounted. Just before the return 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.

    5. 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.

    6. 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 a chartselectionchange event is received. Adding the dispatchNow option to the addEventListener subscription arranges for the chartselectionchange handler to be called immediately.

    7. In src/ToolView.tsx, inside the useEffect() addEventListener() call, add a dispatchNow: true option:

      const unsubscribe = toolViewApi.addEventListener(
        'chartselectionchange',
        handleChartSelectionChange,
        { dispatchNow: true }
      );
      return unsubscribe;
      
    8. 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.

    1. In ToolView.tsx, modify the existing import to add the data and schema namespaces:

      import { chart, data, records, schema } from '@i2analyze/notebook-sdk';
      
    2. 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;
      }
      
    3. Next, after the recordLabel useState() call, add a state value to store the record property information:

      const [properties, setProperties] = useState<IProperty[]>([]);
      
    4. 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 type undefined, data.IValueNotFetched, or data.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.

    5. 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>
      );
      
    6. 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.

    7. Extract the formatter so that it's easy for us to use it in multiple places. In ToolView.tsx, just after the useState() calls, add:

      const formatter = toolViewApi.formatter;
      
    8. And add a call to formatter.formatValue() in the property value rendering code:

      <div className="property-value">{formatter.formatValue(property.value)}</div>
      
    9. 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).

    1. First, add state variables in the ToolView component to store the image and the URL, just after the existing useState() calls:

      const [threeSixtyUrl, setThreeSixtyUrl] = useState<string | undefined>();
      const [image, setImage] = useState<data.IImage | undefined>();
      
    2. 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.

    3. 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 recommends rel="noreferrer".

    4. 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.

    1. 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 the formattingLocale, which is used for formatting data for display. The ?translationLocale query parameter simulates both the translation and the formatting locale.

    2. 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.

    3. The API can provide the information that we need. We just have to use it in the right place!

      1. In ToolView.tsx, change the top-level <div> element in the return statement to add a dir attribute:

        <div dir={toolViewApi.locale.flowDirection} className={themeClass}>
        

        The flowDirection property has either the value ltr or rtl, which is perfect for use with the dir HTML attribute.

      2. 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.

    1. In ToolView.tsx, use formatter.wrapForBidi() on both renderings of recordLabel. Replace both instances of:

      {recordLabel}
      

      with:

      {formatter.wrapForBidi(recordLabel, "raw")}
      
    2. 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.

    3. Update the plug-in code to use wrapForBidi() throughout ToolView.tsx. Specifically, the rendering for property-label and property-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.

    1. Add properties for the index of the current record, the total number of records, and the current selection to ToolView, just after the existing useState() calls:

      const [currentRecordIndex, setCurrentRecordIndex] = useState(0);
      const [totalRecords, setTotalRecords] = useState(0);
      const [selection, setSelection] = useState<chart.ISelection | undefined>();
      
    2. We'll store the selection and choose a record based on currentRecordIndex. Replace the implementation of handleChartSelectionChange() 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.

    3. 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('');
      
    4. Add handler functions to update the currentRecordIndex, just above handleChartSelectionChange():

      const handlePreviousButtonClick = () => {
        setCurrentRecordIndex((r) => r - 1);
      };
      const handleNextButtonClick = () => {
        setCurrentRecordIndex((r) => r + 1);
      };
      
    5. And just before the return statement, compute a navigationTitle 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.

    6. 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}
            >
              &lt;
            </button>
            <button
              className="navigation-button"
              onClick={handleNextButtonClick}
              title="Next record"
              disabled={currentRecordIndex === totalRecords - 1}
            >
              &gt;
            </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.

    7. 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.

    8. Remove the selection useState() line from ToolView.tsx. Instead of storing the records, we'll use the current record index to request the record that we want from the application.

    9. Replace the handleChartSelectionChange() function and the useEffect() after it (which read the selection 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 invokes setValuesForRecord() to set the component state for the record.
      • The useEffect() listens for changes in the currentRecordIndex, 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, calling setValuesForRecord().
      • This provides safe access for all of our data.
    10. 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.

    1. 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.)

    1. In ToolView.tsx, add toolview to the @i2analyze/notebook-sdk import:

      import { chart, data, records, schema, toolview } from '@i2analyze/notebook-sdk';
      
    2. In ToolView.tsx, just before the computation of navigationTitle, 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.

    3. Modify the existing useEffect() function that subscribes to chartselectionchange to retrieve the value of currentRecordIndex from the volatile store when the tool view is loaded. Add the following code before the return unsubscribe line:

      const initialIndex = toolViewApi.volatileStore.get<number>('currentRecordIndex') || 0;
      setCurrentRecordIndex(initialIndex);
      
    4. Because we're now fetching data immediately when the tool view starts up, we no longer need to use the dispatchNow option for the chartselectionchange event handler in the above useEffect(). 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.

    1. In ToolView.tsx, change the handleUnload() 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!

    1. Removing the Record Inspector is easy. Edit public/entrypoint.js to insert the following command-removal code just before the call to initializationComplete():

      api.commands.removeCommand(api.commands.systemCommands.toggleRecordInspector);
      
    2. 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
      );
      
    3. 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.

    1. On the server that hosts the i2 Analyze deployment, find the toolkit/configuration directory, and then navigate to the fragments/opal-services directory that it contains.

    2. If the opal-services directory does not already contain a plugins subdirectory, create one.

    3. Copy the build directory so that it becomes a subdirectory of plugins, and rename it as you like. For example, plugins/plugin-react.

    4. Run the following toolkit commands to update the deployed i2 Analyze server:

      setup -t stopLiberty
      setup -t deployLiberty
      setup -t startLiberty
      
    5. 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.

    In this article
    Back to top © N. Harris Computer Corporation