Logo

6245693
Statements

78944
Actors

598605
Activities

1461
LRSs

Back to TOC

User Manual

Plugins

Veracity Learning 1.6 and later support plugins! Plugins are collections of JavaScript code that can extend the system to add new capabilities or modify how the system works.


Table Of Contents


Overview

A plugin is a collection of JavaScript code that adds or modifies the functionality of the system. System and LRS type plugins can respond to events in the system which are specifically raised to allow for extension of product. Some events are just for you to respond to - they don't accept any return values. Other events expect a plugin to return some sort of data that will be used later. The Events section below describes most of the common events.

Plugins are added to the system simply by placing their code files under a /plugins/ directory. This directory must be inside the directory that hosts the executable file, and the system must have appropriate permission to read the directory. Write permission in not needed, since Veracity never writes to disk.

Once a plugin file is placed in the plugin directory, it must be activated within the system or an LRS. In the free version, it's assumed that all LRS level plugins are available to each LRS. The Enterprise version has a permissions management function for controlling access to plugins per LRS or per user.

A plugin can be activated multiple times, each time with a different collection of settings.

Types of plugins

There are four types of plugins.

  1. System Plugins - These plugins can respond to a set of events that control how system level functions work. They can override the login, add new database tables, attach new routes to the to level paths, or otherwise implement system wide features. System level plugins can also implement any functionality that an LRS level plugins can. System level plugins cannot be controlled per LRS - they are active for the entire system or they are not.

  2. LRS Plugins - These plugins are scoped to a particular LRS within the system. This means that each LRS can be configured to use or not to use any LRS plugin. There is a set of LRS related events that are passed to each installed LRS level plugin. Responding to these events can allow you to integrate with other systems, to modify data in before it's stored or before it's returned to a client, to extend the user interface, or to add features.

  3. Analytic Processors - The old plugin analytics processor feature has been migrated to the new plugin architecture, but generally works as it did before. These plugins create new widgets or expose complex server side logic to back up a graph or chart in the client-side analytics package.

  4. Analytics Dashboards - These plugins work similar to the processors, but instead of creating a single widget, they are used to gather several widgets and parameter pickers into a dashboard. In general, you'll use plugin dashboards and processors together.


Developing plugins

You create a plugin by adding a JavaScript file to the /plugins directory. If this directory does not exist, you can create it manually. Once you've created a plugin file, you must restart the server to load it. After the system has been restarted, it will watch the plugin file for changes and reload it automatically if it is edited. The server console will display errors and feedback if there is a problem with your plugin code.

Defining a plugin

const systemPlugin = require('./utils/plugins/systemPlugin.js');

module.exports = class systemDemo extends systemPlugin {
    constructor(odm, settings) {
        super(odm, settings);
        ...

Plugins must inherit from a given base class in order to be recognized by the system. A plugin file MUST export a class that inherits from one of these known types, or it will not be loaded. The above example shows how to create a system plugin.

an LRS plugins should inherit from lrsPlugin like so:

const lrsPlugin = require('./utils/plugins/lrsPlugin.js');
module.exports = class systemDemo extends lrsPlugin {
    constructor(lrs, dal, settings) {
        super(lrs, dal, settings);
        ...

A plugin Dashboard should inherit from the global Dashboard. Note that the base class Dashboard is not included by a require call.

module.exports = class CustomDash extends Dashboard {
    constructor(params, db) {
        super(params, db);
    }
    ...

A plugin Analytic Processor should inherit from the global AnalyticProcessor. Note that the base class AnalyticProcessor is not included by a require call.

module.exports = class CustomProcessor extends AnalyticProcessor {
    constructor(params, db, lrs) {
        super(params, db, lrs);
    }
    ...

A plugin class must also declare a unique name for itself by attaching a property pluginName to the class definition.

module.exports.pluginName = 'demo';

Importing other libraries

You can use the typical NodeJS require keyword to import other libraries, with the follow caveats.

  1. Each required file has its own global scope.
  2. Every call to require returns a new object - there is no cache of require'd objects.
  3. Calls use relative paths as normal, except when calling for bundled resources. These resources should always be addressed as if you are at the top level directory, regardless of the location of the plugin

You can also install NPM modules alongside a plugin. Require these as you normally would.

Importing system components

Certain components of the system can be accessed at runtime by requiring them. You can use this method to get the ODM models or routers and controllers that drive the system. When requiring these objects, use the full path from the executable root. The plugin base classes are a good example of this process.

const lrsPlugin = require('./utils/plugins/lrsPlugin.js');

While you won't find that file on the filesystem, the call will still return the object, since it's part of the running software. Be very careful - this give you the ability to interface with the system beyond the defined plugin architecture, and should be avoided except for a few specific use cases.

Plugin services (for LRS or system LRS or system plugins)

The system provides several services to plugins.

Responding to events

Most of the functionality is exposed via events that a plugin can listen to. These events include a name and a data payload, and can optionally accept a return value. All plugins are offered the opportunity to listen to all relevant events, and can attach a handler via this.on as below.

    this.on('statementBatch', (event, batch) => {
        console.log(batch);
    });

The above code handles the statementBatch event as part of an LRS plugin.

A system level plugin differs only in the list of events it has the option to handle. Additionally, system level plugins can hear all events on any lrs. For LRS events, when they are received by a system level plugin, the specific lrs instance which triggered the event is passed as a parameter.

    this.on('statementBatch', (lrs, event, batch) => {
        console.log(batch);
    });

All event handling is asynchronous, and accepts an async function, or a function that returns a promise.

    this.on('statementBatch', async (event, batch) => {
        await postBatchToMyBISystem(batch);
    });

Event parameters are immutable. Attempting to modify the value of an incoming parameter will throw a runtime exception. Use the clone module to copy parameters if you need to modify them.

It's possible that several plugins will return values for a given event. If this is the case, that last plugin to respond sets the final value that will be used in further processing. Some events are designed to collect all responses and use them as a list.

Timing and scheduling

In order to run logic on a timer, plugins can hook into the system's scheduling system. This is done by requesting an event to fire on an interval, then listening for that event. There is a special API to listen to timing events, onInterval.

this.every('10 seconds', '10Int');
this.schedule('190 seconds', 'FiresOnce');

The above code sets up a recurring event that will be sent to the plugin every 10 seconds, and one event that will fire only once, in 190 seconds. The plugin should handle this like any other event.

    this.onInterval('10Int', async (e) => {
        console.log('This prints every 10 seconds, or the shortest interval.);
    });
    this.onInterval('FiresOnce', async (e) => {
        console.log('This prints once);
    });

While there is currently no API for clearing intervals, removal or deactivation of the plugin will clear all pending events in the schedule.

State

Plugins must be stateless. You cannot count on data that you stored in global memory being present the next time a handler is called. This restriction allow plugins to work in a multi-server environment, and allows the system to manage the lifecycle and resources consumed by plugins. To keep track of some value beyond the invocation of a handler, you can persist that data in the plugin state.

    const state = await this.getState();
    if (!state.count) { state.count = 1; } else { state.count++; }
    await this.persistState(state);

State is managed on a per plugin activation basis, so if the same plugin is added to the system or an LRS twice, each gets its own state. When a plugin activation is removed, state is irretrievably destroyed.

Routers, HTTP and HTML

You can use a standard express.js router to handle requests and responses, in order to hook up paths on the server to services you plugin provides. These routers must be registered via an API call to tell the system where they should be mounted in the request handling process.

    router = express();
    this.router.get('/settings', (req, res, next) => {
        res.send(settings);
    });
    this.setRouter('lrs', router);

The setRouter command tell the system that the path should be attached at the LRS UI level. So, for the path /settings, the actual url on the server would be
/ui/lrs/lrsname/plugins/{pluginuuid}/settings

You can get the value of {pluginuuid} via this.uuid. For convenience, we also offer this.getLink(path,'lrs').

LRS plugins can attach routers at 'lrs' and 'portal', while system plugins can attach routers at 'lrs', 'portal' and 'system'.

You can also render Handlerbars templates. User provided templates should be placed in ./views/templates, and should end in the '.hbs' extension.

    this.router.get('/showAPage', (req, res, next) => {
        res.render("/templates/mypage");
    });

Install and Uninstall

Plugins can request to run code when they are activated in an LRS or in the system, or when removed. The system handles orchestrating these events in a multi processor or multi-server system such that they run only once, regardless of the number of servers.

    async install() {
        console.log('install me ' + this.uuid);
    }
    async uninstall() {
        console.log('uninstall me ' + this.uuid);
        super.uninstall();
    }

It's important to call super.uninstall() in order to clean up scheduled events.

Settings

Every activation of a plugin comes with a block of settings data. If a plugin is activated multiple times, each gets its own settings block. These settings are defined by the user in the UI or the API, and passed to the class constructor.

    constructor(lrs, dal, **settings**) {
        super(lrs, dal, **settings**);

The plugin author can define the form that is shown for populating the settings. You do this by implementing a static getter function for settingsForm, which returns a list of controls.

static get settingsForm() {
    return [
        {
            label: 'String with client side validation',
            id: 'nameOfProperty',
            helptext: 'User should type a string',
            validation: "val !== undefined && val !== '' && val.length > 2 && val.length < 100",
            validationMessage: 'Enter a string',
            placeholder: 'Show this as the place holder',
            type: { isText: true, type: 'text' },
        },
        {
            label: 'Checkbox',
            id: 'checkbox',
            helptext: 'This is either true or false',
            type: { isCheck: true },
        },
        {
            label: 'A Select',
            id: 'select',
            helptext: 'What should you pick',
            type:
            { isSelect: true },
            options: [
                {
                    text: 'I attempted it',
                    value: 'http://adlnet.gov/expapi/verbs/attempted',
                },
                {
                    text: 'I attended it',
                    value: 'http://adlnet.gov/expapi/verbs/attended',
                },
            ],
        },
    ];
}

Configuration and Metadata

Finally, in order to display the plugin in the plugin activation GUI, the plugin must implement some metadata fields. These are not optional, and must be defined as below

    static get display() {
        return {
            title: 'Demo',
            description: 'A demo plugin loaded from the filesystem',
        };
    }
    // Additional metadata for display
    static get metadata() {
        return {
            author: 'Veracity Technology Consultants',
            version: '1.0.0',
            moreInfo: 'https://www.veracity.it',
        };
    }


Custom Plugin Analytics Processors

A plugin Analytic Processor is a more tightly constrained tool. It does not have access to any of the above services.

A basic plugin Analytics Processor looks like this:

module.exports = class MyProcessor extends AnalyticProcessor{
    constructor(params,db,lrs) {
        super(params,db,lrs);
        console.log("Wow, in the  derived constructor!",params);
        
        this.pipeline = [
            ...CommonStages(this,{
                range:true,
                limit:true
            }),
            {
                $match: {
                    "statement.object.id": this.param("activity")
                }
            },
            {
                $limit: 10
            }, 
            {
                $group: {
                    _id: "$statement.actor.id",
                    count: {
                        $sum: 1
                    }
                }
            }
        ];

        this.chartSetup = new BarChart("_id","count");
        this.map = MapToActorNameAsync("_id");
    }
    map(val)
    {
      //  console.log(val);
        return  val;
    }
    filter(val)
    {
        return Math.random() > .5;
    }
    exec(results)
    {
        console.log(results);
        return results;
    }
    static getConfiguration() {
        let conf =  new ProcessorConfiguration("Demo", ProcessorConfiguration.widgetType.graph, ProcessorConfiguration.widgetSize.small);
        conf.addParameter("activity", new ActivityPicker("Activity","Choose the activity to plot"), true);
        return conf;
    }
}

Notice how the constructor extends AnalyticProcessor, and sets this.pipeline. This is a MongoDB Aggregation processor. Unlike the Aggregation Widget above, it "parameterizes" a part of the query by adding this.param("activity") at a certain point. The static method getConfiguration tells the GUI a bit about how to display the parameters. It says, basically, that the user should pick a value for the "activity" parameter, and that they should be offered choices from the systems registry of xAPI "activities". There are various picker types available, listed below. Note that this technology is behind all the built-in widget types!

You can also see that the class has map, filter,and exec functions. These allow you to execute some JavaScript on the results of the Aggregation query, for cases where you just can't get the logic into a Mongo Aggregation Pipeline. Each of these functions may even perform asynchronous work using the async keyword in ES6.

  • map(val)

    Takes in each value from the result stream and transforms it, returning a new object that will replace the result.
  • filter(val)

    Takes each value and returns a boolean. If false, the value is removed from the result set.
  • exec(results)

    Takes the whole set of results at once, and returns a new set of results.

These functions are called in the order: filter, map, exec.

In the example above, you can see that in the constructor, the map function (this.map) is replaced. We generate a new mapping function by calling the utility MapToActorNameAsync. This utility will replace the value of "_id" in each result with the actor's name, by looking up the actor from the system's registry where the results '_id' property value is the IFI for the actor. So, an object that looks like this:

{
    _id:"mailto:[email protected]",
    averageScore:"100",
    daysMissed:0,
    count:100
}

becomes this:

{
    _id:"Rob Chadwick",
    averageScore:"100",
    daysMissed:0,
    count:100
}

The constructor also sets the chartConfig in the line this.chartSetup = new BarChart("_id","count"); This generates a new BarChart where the bars are named by the value of the _id field, and the height of the bars is read from the count field. This mirrors the configuration of the Chart Config in the Aggregation Widget, so read over that documentation for more info. The structure of the chartConfig field depends on the engine value, and is documented below.

So, now you can see a basic set up, let's list the various utilities we provide for you to use.


Processor Configuration

Every processor must export a configuration object from a static method called getConfiguration

static getConfiguration() {
        let conf =  new ProcessorConfiguration("Demo", ProcessorConfiguration.widgetType.graph, ProcessorConfiguration.widgetSize.small);
        conf.addParameter("activity", new ActivityPicker("Activity","Choose the activity to plot"), true);
        return conf;
    }

This configuration sets a few things.

ProcessorConfiguration(title:String,type:Enum,Size:Enum)
  • Title
    The displayed name of the associated Widget.
  • type
    An enumeration of types. Valid values are
    • ProcessorConfiguration.widgetType.graph
    • ProcessorConfiguration.widgetType.table
    • ProcessorConfiguration.widgetType.iconList
    • ProcessorConfiguration.widgetType.statementViewer
  • size
    An enumeration of sizes. These sizes are just requests - the system will attempt to fill the space available.
    • ProcessorConfiguration.widgetSize.small
    • ProcessorConfiguration.widgetSize.medium
    • ProcessorConfiguration.widgetSize.large
    • ProcessorConfiguration.widgetSize.xlarge
    • ProcessorConfiguration.widgetSize.xxlarge

A processor configuration also has a few functions you can call to set other options.

  • setDescription (text)
    The description text block on the widgets page.
  • setCacheLifetime (time:String)
    A human interval like 5 seconds or 10 minutes or 3 days. Sets the amount of time the analytics will be cached.
  • setRefreshSeconds (time:Number)
    The chart will automatically refresh after this interval.
  • setEnableWidgetChrome (show:Boolean)
    Add or remove the title bar and other chrome on the GUI.
  • addParameter (parameterName, paramType, default_value, required)
    Add a parameter picker to the configuration page.
    • parameterName
      The name of the param. You'll access the value sent by calling this.param(parameterName)
    • paramType
      A parameter type object. Defines the type of picker available to the user. See below.
    • default_value
      The value that will be returned from this.param when the user does not supply a value
    • required
      A boolean that tells the GUI that the parameter is required. If the widget has any parameters that are required and not set, the GUI will prompt the user to configure the widget

Parameter Types

Used to tell the system what sort of GUI to present the user when they are configuring a widget based on the processor. Each should be created with new. The values are in the global scope, so you can type new Text("user sees this").

  • ActivityPicker (title, required, description)
    This picker type will allow the user to search for xAPI Objects or activities. The value returned will be the ID of the activity.
  • ActorPicker (title, required, description)
    This picker type will allow the user to search for xAPI Actors or Agents. The value returned will be the IFI of the agent.
  • ClassPicker (title, required, description)
    This picker type will allow the user to search the "classes" that are set up in the LRS. Classes are groups of learners. The value returned will be the UUID of the class.
  • CoursePicker (title, required, description)
    This picker type will allow the user to search the "courses" that are registered in the LRS. Courses are lists of "content". The value returned will be the UUID of the course.
  • LessonPicker (title, required, description)
    This picker type will allow the user to search the "content" that are registered in the LRS. Content is an xAPI activity that is registered in the system with additional metadata. The UUID of the content object will be returned.
  • Text (title, description)
    This picker type will allow the user input any text value.
  • NumberText (title, description)
    The picker will let the user enter text into a textbox. This value will be parsed into a number for you.
  • TimeSpan (title, description)
    This picker will allow the user to select from hourly, daily, monthly, or yearly. The value returned will be a string that can be used with Date.toString to cast a date to the given span. This operates by taking the "floor" of the DateTime at a given value. For instance, '%Y-%m-%dT12:00:00Z' is returned for "daily". Calling Date.toString('%Y-%m-%dT12:00:00Z') returns the same value for all timestamps on a given day. This can be used with a $group operator to group up all statements on a particular day.
  • TimeRange (title, description)
    This picker will allow a user to choose a time range. They are offered either a predefined string like "today" or "this week", or they can choose a specific range. The value returned is a JS object in the form {from:Date, to:Date}.
  • Verb (title, description)
    This picker lets the user choose from a predefined list of common xAPI verbs.
  • ChoicePicker (title, required, description,choices)
    This picker renders a "select". The choices parameter should be an array in the form [{text:String, value:String}]. The user is shown the text, but the value you receive is the value.

Mapping functions

These utilities make it easier for you to specify common mapping transforms. Each is a function in the global scope. The return value of these functions are themselves functions, and should be assigned to this.map. For instance:

this.map = MapToCourseName("_id");
  • MapToActorName (inkey,outkey)
    The value of the result where the key is inkey should be an xAPI Agent. This function will find the actor name in the xAPI Agent object, and place it in the result under the key outkey. If outkey is undefined, it is assumed to be the same as inkey. For instance if this.map = MapToActorName("_id","name") and the input object is:
      {
          _id: {
              mbox:"mailto:[email protected]",
              account:{
                  name:"Rob C",
                  homePage:"https://www.veracity.it"
              }
          }
      }
    
    
    The mapping function would output
      {
          _id: {
              mbox:"mailto:[email protected]",
              account:{
                  name:"Rob C",
                  homePage:"https://www.veracity.it"
              }
          },
          name:"Rob C"
      }
    
    
  • MapToActorEmail (inkey,outkey)
    Similar to the above MapToActorName, but for email.
  • MapToVerbDisplay (inkey,outkey)
    Similar to the above MapToActorName, but for verbs. Finds the proper verb display string in a verb definition.
  • MapToCourseName (inkey,outkey)
    Similar to the above MapToActorName, but for xAPI activities. Finds and attaches the best title for an activity by looking in the activity definition language maps.
  • VerbIDToDisplay (inkey,outkey)
    Given a verb IRI, select the last segment of the IRI for display. Splits the value by "/" then return the last portion.
  • MapToActorNameAsync (inkey,outkey)
    Finds the actor name by examining the canonical tables. This is an asynchronous operation, and should only be used when you have an actor IFI without the rest of the actor definition. The canonical tables keep track of the last display name used in an xAPI statement for the given IFI.
  • MapToCoursesNameAsync (inkey,outkey)
    Finds the object name by examining the canonical tables. This is an asynchronous operation, and should only be used when you have an object id without the rest of the object definition. The canonical tables keep track of the last display name used in an xAPI statement for the given ID.


Chart Configuration

Processors use the chartConfig field to store configuration for their widget renderer. Most widgets in Veracity Learning LRS are of the type graph. This is set in the constructor of the ProcessorConfiguration object. A widget with the type graph or table requires additional information on how to build the graph, and how it maps to the results of the query. Similar to the constructor field in the Chart Setup in the Aggregation Widget, we allow you to call on some common constructors to build the JSON object that represents the graph that is drawn into the widget. We expose some common types with global constructors, but you aren't limited to these. Check out AmCharts for full documentation on the possibilities. You can set the value of this.chartConfig in the constructor or in the exec function. Call these utilities with the new keyword in the global scope.

this.chartConfig = new BarChart("_id","count");

This creates an AmCharts configuration object that looks like this. Setting the value to the below JSON is identical.

{ forWidgetType: 'graph',
  balloon:
   { borderThickness: 0,
     borderAlpha: 0,
     fillAlpha: 0,
     horizontalPadding: 0,
     verticalPadding: 0,
     shadowAlpha: 0 },
  export: { enabled: true, fileName: 'Veracity_data' },
  type: 'XYChart',
  labelsEnabled: false,
  engine: 'amcharts4',
  colors:
   { list:
      [ '#00BBBB',
        '#006E6E',
        '#159800',
        '#001F7C',
        '#1FE200',
        '#0133C8',
        '#00BBBB',
        '#006E6E',
        '#159800',
        '#001F7C',
        '#1FE200',
        '#0133C8',
        '#00BBBB',
        '#006E6E',
        '#159800',
        '#001F7C',
        '#1FE200',
        '#0133C8' ] },
  xAxes:
   [ { id: 'c1',
       type: 'CategoryAxis',
       dataFields: { category: '_id' },
       renderer:
        { minGridDistance: 60,
          grid: { strokeOpacity: 0.05 },
          labels:
           { rotation: 45,
             truncate: true,
             maxWidth: 200,
             verticalCenter: 'top',
             horizontalCenter: 'left' } } } ],
  exporting: { menu: {} },
  yAxes:
   [ { id: 'v1',
       type: 'ValueAxis',
       dataFields: { value: 'count' },
       renderer: { grid: { strokeOpacity: 0.05 } } } ],
  series:
   [ { id: 's1',
       xAxis: 'c1',
       yAxis: 'v1',
       type: 'ColumnSeries',
       name: 'Series Title',
       stacked: false,
       columns: { tooltipText: '{categoryX}:{valueY}' },
       colors:
        { list:
           [ '#00BBBB',
             '#006E6E',
             '#159800',
             '#001F7C',
             '#1FE200',
             '#0133C8',
             '#00BBBB',
             '#006E6E',
             '#159800',
             '#001F7C',
             '#1FE200',
             '#0133C8',
             '#00BBBB',
             '#006E6E',
             '#159800',
             '#001F7C',
             '#1FE200',
             '#0133C8' ] },
       dataFields: { categoryX: '_id', valueY: 'count' } } ],
   }

Notice the final line, dataFields: { categoryX: '_id', valueY: 'count' } } ]. This is where the BarChart constructor uses its parameters. The rest of this is the default configuration for a BarChart in Veracity. It sets up the colors, patterns, themes and legend that are used for all BarCharts in our system. There are a few additional values on this object that are worth discussing:

  • engine - Veracity Learning LRS actually includes several graphing libraries. We've deprecated AmCharts3 and D3, so please always set this to "amcharts4".
  • forWidgetType - this tells the system that this config is intended for "graphs". Widgets can actually have a few other types that are not graphs, like the Table. You don't need to set this. When using the utilities, it's set automatically. This is to prevent assigning a BarChart config to a graph whose static configuration sets the type to "Table"

Chart Config Utilities

  • BarChart (category,value)
    A bar chart where the bars are labeled by the category field, and the value comes from the value field.

  • PieChart (category,value)
    A pie chart where the bars are labeled by the category field, and the value comes from the value field.

  • SerialChart (category,value)
    An XY chart where the X axis is the value of "category", and the Y axis is the value of "value" from each result document.

  • MultilineChart (lines,categoryField)
    An XY chart with multiple lines. categoryField is the name of the X value for each datapoint, and lines is an array of string that represent each Y value. For instance, if the data looks like this:

    [
     {line1:10,line2:20,line3:23,Xval:1},
     {line1:11,line2:24,line3:3,Xval:2}
     ...
     {line1:103,line2:2,line3:365,Xval:10}
    ]
    

    Then to generate a proper multiline chart, you would set the chart config as:

    this.chartSetup = new MultilineChart(["line1","line2","line3"],"Xval");
    
  • ErrorBars (categoryField, valueField, errorField)
    An XY chart that shows crosses to represent a value and a range around the value. categoryField and valueField work just like a BarChart, and errorField represents the range size around valueField in the center.

  • StackedBarChart (categoryField,stacks)
    A bar chart where each bar is subdivided into stacks. CategoryField is the name (and grouping key) for each bar, and stacks is an array of strings that are the keys for the value fields. Data will be automatically processed, so you can provide a series of documents like this:

    [
      {name:"Rob",courseA:10}
      {name:"Tim",courseB:0}
      {name:"Rob",courseB:130}
      {name:"Tim",courseA:50}
    ]
    

    Then to generate a proper stacked bar chart, you would set the chart config as:

    this.chartSetup = new StackedBarChart("name",["courseA","courseB"]);
    
  • Table (column1,column2,...)
    A data table, where each column is one of the parameters from the constructor. This ChartConfig is only useful when the WidgetType is "table".

Other Widget Types

Not every widget needs to be a graph rendered by a chart engine. We have a handful of other Widget Types that render various HTML.

  • Graph
    A graph, rendered by a graph engine (generally "AmCharts4"). Described in detail above.
  • Table
    Renders a data table. Set the chartConfig to new Table(...) when using this type. The constructor for Table is documented above.
  • ProgressChange
    Renders an single large icon, a large value, and a string underneath. Use this to show a single value on the widget. It requires no configuration, but assumes your data is formatted as below. Remember, if the values that are returned from your query don't match this format, you can fix them in the this.exec function. Only the first value in the array is used.
    [
      {
          icon: 'fa-check', //A Font Awesome icon class
          change: '10 Attempts', //The title value
          subtext: '', //A smaller line of text underneath
      },
    ];
    
  • IconList
    A list of several entries, each with an icon, title and subtext.
    [
      {
          icon: 'fa-check', //A Font Awesome icon class
          title: '10 Attempts', //The title value
          subtext: '', //A smaller line of text underneath
          color:"red" //A CSS color value
      }
      ...
    ];
    
  • StatementViewer
    Renders statements with a special renderer. Each document in the result set should be a full xAPI statement.

Using ElasticSearch

You can use ElasticSearch instead of MongoDB for your queries. To do so, you should extend your Processor from a different base class, ElasticAnalyticProcessor. When using this base class, the value of this.pipeline is meaningless. Instead, you must populate this.query and this.aggregation. The this.query value is an array of ElasticSearch DSL fragments, and this.aggregation is a full ElasticSearch aggregation. Note that you must include an aggregation. The results from this aggregation are piped into your map,filter, and exec functions.

Be sure to initialize the query with this.query = this.commonQueryStages({ range: true }); if you want to obey the dashboard global time range.

Using ElasticSearch is VASTLY faster for most common queries and analytics, so prefer this option unless you need to execute complex queries that can only be represented as a MongoDB Aggregation Pipeline.

class ESCompletionsByStudent extends ElasticAnalyticProcessor {
    constructor(parameters, db, lrs) {
        super(parameters, db, lrs);

        this.query = this.commonQueryStages({ range: true });

        if (this.param('verb')) {
            this.query.push({
                term: {
                    'verb.id': this.param('verb'),
                },
            });
        }
        if (this.param('object')) {
            this.query.push({
                term: {
                    'object.id': this.param('object'),
                },
            });
        }
        this.aggregation = {
            terms: {
                field: 'actor.id',
                size: 10,
                order: {
                    _count: 'desc',
                },
            },
        };

        // find the name of each actor by actorID
        if (this.param('verb')) this.map = multiMap(mapToActorNameAsync('key', 'actorName'), mapToVerbDisplay('verb', 'verb'));
        if (!this.param('verb')) this.map = mapToActorNameAsync('key', 'actorName');

        this.chartSetup = new BarChart('actorName', 'doc_count');
   
    }

Overriding this.compute

If you wish not to use the MongoDB or ElasticSearch interfaces we provide, you can override the compute function. This function is async and should return an array of values that will be piped into map,filter, and exec.

this.compute = async function GetDataFromAnotherServer()
{
    let url = this.param('url');
    let request = require('request');
    let values = await request.get(url);
    return values;
}

This notional example returns some value from another server, where the server address is a parameter. You can use this method to generate any analysis algorithm you wish.

System Events

Plugins interact with the system primarily by responding to events. Each event brings with it a certain set of data, and expects a certain output. Here's an incomplete list of common events that you may want to respond to. Note that not every plugin will receive every event - it depends on the plugin type and permission settings.

statementBatchStored

This is the single most used event for plugin authors. This event informs your plugin that a new xAPI statement batch was received, validated and stored into the database. The only parameter (batch in the example below) is an array of statement records. These records include the indexes we create like "voided" and the "duration" in milliseconds, as well as the normalized statement. The normalized statement is found at batch[*].statement;

Handle this event in order to synchronize your data with another system like a BI (Business Intelligence) tool, database, search engine, or AI processor. You can also watch for specific events and trigger some event, like posting an HTTP request when a given student completes a given class.

Example

this.on('statementBatchStored', (e, batch) => { this.processBatch(batch); });

Expects Return

undefined - no data should be returned

Parameters

  • Batch
    An array of statement records.

systemODMEvent

This event is fired when something changes in the system level database. This is for informational purposes only.

Example

this.on('systemODMEvent', (e, method, type, model) 

Expects Return

undefined - no data should be returned

Parameters

  • Method
    What happened: a string that is is either "created","deleted","updated"
  • Type
    The object type that was changed. This is a string representing the model name.
  • Model
    The object that changed. This can be a user, an LRS, a message, or other system database entity.

LRSODMEvent

This event is fired when something changes in the LRS level database for a particular LRS. This is for informational purposes only.

Example

this.on('LRSODMEvent', (e, method, type, lrs, model) 

Expects Return

undefined - no data should be returned

Parameters

  • Method
    What happened: a string that is is either "created","deleted","updated"
  • Type
    The object type that was changed. This is a string representing the model name.
  • Lrs
    The uuid of the lrs in which the entity was modified.
  • Model
    The object that changed. This can be a user, an LRS, a message, or other system database entity.

systemStartup

The system has started.

Example

this.on('systemStartup', (e) ={})

Expects Return

undefined - no data should be returned

Parameters
*no parameters"

uiRequest

The system has received an HTTP request to one of the UI paths.

Example

this.on('uiRequest', (e, req) => {
    this.prodLog(colors.cyan('Logger:') + colors.underline('UI'), colors.green(req.method), req.url, colors.red(req.user ? req.user.email : ''));
});

Expects Return

undefined - no data should be returned

Parameters

  • req
    Data about the request. Includes .url, .body, .user, and .method