Skip to content

Element developer guide

See elements/ for example elements.

Element code uses the prairielearn module for common functionality like parsing attributes and computing scores.

Info

When deciding on the attributes used by your element, avoid using attribute names that are generally interpreted as boolean attributes in HTML elements. These attribute names may be incorrectly interpreted and their values converted to mean something different than your original intent. In particular, you should avoid these attributes: checked, compact, declare, defer, disabled, ismap, multiple, nohref, noresize, noshade, nowrap, readonly, selected.

Additionally, you are encouraged to avoid attribute names that have special meaning in HTML (e.g., onclick or style), as they may be misinterpreted by IDEs.

Anatomy of an element

The system-wide elements available in the current build of the PrairieLearn server live in [PrairieLearn directory]/elements inside a folder corresponding to the element name. You can also have course-specific elements in a directory inside the root of your course repository, such as [course directory]/elements. See exampleCourse/elements for a real example of this.

By convention, all element files are named the same as the element they belong to. That directory should contain an info.json file that contains metadata about the element, including which file is the element controller and any dependencies of the element. See the section on dependencies for more information.

Each element should have a .py controller that contains the functions listed in the next section. This controller is responsible for rendering the element, parsing the student's submission, and optionally grading the submission.

As a simple example, element pl-my-element would have the following file structure:

pl-my-element
+-- info.json
|-- pl-my-element.py
|-- pl-my-element.mustache
|-- pl-my-element.js
`-- pl-my-element.css

And an info.json with the following contents:

info.json
{
  "controller": "pl-my-element.py",
  "dependencies": {
    "elementScripts": ["pl-my-element.js"],
    "elementStyles": ["pl-my-element.css"]
  }
}

Element functions

All element functions are of the following form:

def fcn(element_html, data):

Note that not all functions have the same return type. The arguments are:

Argument Type Description
element_html string The template HTML for the element.
data dict Mutable data for the question, which can be modified and returned.

The data dictionary has the following possible keys (not all keys will be present in all element functions):

Key Type Description
data["ai_grading"] boolean Whether the question is being rendered for AI grading.
data["correct_answer_shown"] boolean Whether the correct answer is currently visible (i.e., the answer panel is rendered; for use when rendering other panels)
data["correct_answers"] dict The true answer (if any) for the variant.
data["editable"] boolean Whether the question is currently in an editable state.
data["extensions"] dict A list of extensions that are available to be loaded by this element. For more information see the element extensions documentation.
data["feedback"] dict Any feedback to the student on their submitted answer.
data["format_errors"] dict Any errors encountered while parsing the student input.
data["gradable"] boolean Whether the submission can be graded. Automatically set to false if there are format errors.
data["manual_grading"] boolean Whether manual-grading content should be shown. This is true in the manual grading view, and also for question and answer panels when rendered for AI grading.
data["num_valid_submissions"] int The number of valid (not containing format errors) submissions by the student for the current variant.
data["options"] dict Any options associated with the question, such as to access client files.
data["panel"] string Which panel is being rendered (question, submission, or answer).
data["params"] dict Parameters that describe the question variant.
data["preferences"] dict Read-only question preferences for the current assessment context. Values come from the question's defaults merged with any assessment overrides.
data["partial_scores"] dict Partial scores for individual variables in the question.
data["raw_submitted_answers"] dict The answer submitted by the student before parsing.
data["score"] float The total final score for the question.
data["submitted_answers"] dict The answer submitted by the student (after parsing).
data["variant_seed"] integer The random seed for this question variant.

So that multiple elements can exist together in one question, the convention is that each element instance is associated with one or more variables. These variables are keys in the dictionaries for the data elements. For example, if there are variables x and y then we might have:

data["correct_answers"]["x"] = 4
data["correct_answers"]["y"] = 7
data["submitted_answers"]["x"] = 4
data["submitted_answers"]["y"] = 12

This structure, where dictionaries have variables as keys, is used for all dictionaries in data.

The element functions are:

Function Return object Modifiable data keys Unmodifiable data keys Description
prepare() None correct_answers, params extensions, options, preferences, variant_seed Validates and prepares initial data for the element. Is executed after the question's generate() function has executed, and has access to the data created by that function.
render() str (html) correct_answer_shown, correct_answers, editable, extensions, feedback, format_errors, manual_grading, num_valid_submissions, options, panel, params, partial_scores, preferences, raw_submitted_answers, score, submitted_answers, variant_seed Render the HTML for one panel and return it as a string.
parse() None correct_answers, feedback, format_errors, params, submitted_answers extensions, options, preferences, raw_submitted_answers, variant_seed Parse the data["submitted_answers"][var] data entered by the student, modifying this variable.
grade() None correct_answers, feedback, format_errors, params, partial_scores, score, submitted_answers extensions, options, preferences, raw_submitted_answers, variant_seed Grade data["submitted_answers"][var] to determine a score. Store the score and any feedback in data["partial_scores"][var]["score"] and data["partial_scores"][var]["feedback"] respectively. Note: Avoid modifying the data["feedback"] dictionary, as this is meant to be used by custom questions.
test() None format_errors, partial_scores, raw_submitted_answers, score extensions, gradable, preferences, test_type Creates a test submission for this element, used when running tests from the "Settings" panel. Should set a value in data["raw_submitted_answers"][var] and expected score in data["partial_scores"][var] (or data["format_errors"][var] if invalid). The type of input to test is given in data["test_type"], and can be one of correct, incorrect, or invalid.

Note

Unlike question server.py files, element controllers do not have a generate() function. The question's server.py generate() function is responsible for generating random parameters and correct answers. Elements should use prepare() for any initialization that depends on those parameters.

The above table describes the purpose of each function and the values in data that are allowed to be modified. Any permitted changes to the values in data will be persisted to the database. No function is allowed to add or delete keys in data.

All functions above have equivalents in question code (i.e., the question's own server.py file). When the functions are declared in both the element and the question, the element function is always executed first for each element in the question, in the order the elements appear in the question, followed by the question function. This allows question code to override or modify the behavior of the element if necessary.

Element dependencies

It's likely that your element will depend on certain client-side assets, such as scripts or stylesheets. To keep clean separation of HTML, CSS, and JS, you can place those dependencies in other files. If you depend on libraries like lodash or d3, you can also link to node modules containing these libraries. PrairieLearn will compile a list of all dependencies needed by all elements on a page, deduplicate the dependencies, and ensure they are loaded on the page.

Dependencies are listed in your element's info.json. You can configure them for your element as follows:

info.json
{
  "controller": "pl-my-element.py",
  "dependencies": {
    "nodeModulesScripts": ["three/build/three.min.js"],
    "elementScripts": ["pl-my-element.js"],
    "elementStyles": ["pl-my-element.css"],
    "clientFilesCourseStyles": ["courseStylesheet1.css", "courseStylesheet2.css"]
  }
}

The different types of dependency properties currently available are summarized in this table:

Property Description
nodeModulesStyles The styles required by this element, relative to [PrairieLearn directory]/node_modules.
nodeModulesScripts The scripts required by this element, relative to [PrairieLearn directory]/node_modules.
elementStyles The styles required by this element relative to the element's directory, which is either [PrairieLearn directory]/elements/this-element-name or [course directory]/elements/this-element-name.
elementScripts The scripts required by this element relative to the element's directory, which is either [PrairieLearn directory]/elements/this-element-name or [course directory]/elements/this-element-name.
clientFilesCourseStyles The styles required by this element relative to [course directory]/clientFilesCourse. (Note: This property is only available for elements hosted in a specific course's directory, not system-wide PrairieLearn elements.)
clientFilesCourseScripts The scripts required by this element relative to [course directory]/clientFilesCourse. (Note: This property is only available for elements hosted in a specific course's directory, not system-wide PrairieLearn elements.)

The coreScripts and coreStyles properties are used in legacy elements and questions, but are deprecated and should not be used in new objects. It lists scripts and styles required by this element, relative to [PrairieLearn directory]/public/javascripts and [PrairieLearn directory]/public/stylesheets, respectively. Scripts in [PrairieLearn directory]/public/javascripts are mainly used for compatibility with legacy elements and questions, while styles in [PrairieLearn directory]/public/stylesheets are reserved for styles used by specific pages rather than individual elements.

In addition to static dependencies, elements can also declare dynamic dependencies, corresponding to scripts that are loaded only if they are deemed necessary. For example, if an element may use the d3 library, but only in certain cases, it can declare a dependency on d3:

info.json
{
  "controller": "pl-my-element.py",
  "dependencies": {
    "elementScripts": ["pl-my-element.js"]
  },
  "dynamicDependencies": {
    "nodeModulesScripts": { "d3": "d3/dist/d3.min.js" }
  }
}

Then, the element's own script (e.g., pl-my-element.js) can dynamically import the d3 library only when considered necessary:

if (options.use_d3) {
  import('d3').then((module) => {
    // use d3 here
  });
}

Dynamic dependencies are implemented using import maps, which allow the import call in the element script to refer to the module by the name defined in the info.json file instead of the full URL. This may also be used as an alternative to static dependencies for ESM modules, as it allows the module to be imported using ESM syntax.

Dynamic dependencies may point to:

Property Description
nodeModulesScripts The scripts required by this element, relative to [PrairieLearn directory]/node_modules.
elementScripts The scripts required by this element relative to the element's directory, which is either [PrairieLearn directory]/elements/this-element-name or [course directory]/elements/this-element-name.
clientFilesCourseScripts The scripts required by this element relative to [course directory]/clientFilesCourse. (Note: This property is only available for elements hosted in a specific course's directory, not system-wide PrairieLearn elements.)

Note that the key used in the dynamic dependencies will be shared among all elements available in a question. For example, if two elements in a question both declare a dynamic dependency on d3, then the d3 library will only be loaded once, even if both elements use it. For this reason, it is important that the following convention is used when defining keys for dynamic dependencies:

  • For node modules: use the name of the module, as defined in the package.json file. This will allow multiple elements that use the same module to share the same dependency without loading the module twice. That said, for course elements, you should avoid using node module dependencies directly if possible, as described below.
  • For element scripts: use the name of the element, followed by a slash, followed by the name of the script. For example, if the element is named pl-my-element and the script is named my-element.js, then the key should be pl-my-element/my-element.js.
  • For clientFilesCourse scripts: use any course-specific convention that does not clash with the naming above.

You can also find more detail about the types of dependencies in the schema references:

Using node dependencies in element code

Note that the use of node modules (nodeModulesScripts and nodeModulesStyles) is only supported for dependencies that PrairieLearn itself depends on. These dependencies can be found in the dependencies section of the apps/prairielearn/package.json file in the PrairieLearn repository.

Warning

While the use of node module dependencies in course elements is supported, it should be avoided if possible. In particular, note that node modules may be updated without warning, which in some cases may break your element. Also note that, although transitive dependencies (i.e., dependencies of dependencies) may work in some cases, they are not guaranteed to continue working in the future, as dependency updates or updates in the PrairieLearn configuration may change the availability of transitive dependencies.

If your code relies on a node module, the recommended course of action is that you copy the module into your element directory or clientFilesCourse and link to that module from there instead. This way, you have control over when the module is updated, and you can ensure that updates do not break your element.

To copy a node module into your element directory, first obtain the appropriate bundle for the module (e.g., from the module's home page, from a CDN or by building it yourself), and then place it in your element directory. For example, if you want to use the moment library, you can obtain the bundle from the module web page (https://momentjs.com/) or a CDN (e.g., https://cdn.jsdelivr.net/npm/moment@2.30.1/dist/moment.min.js) and place it in your element directory as moment.min.js. Then, you can link to this file in your info.json as follows:

info.json
{
  "controller": "pl-my-element.py",
  "dependencies": {
    "elementScripts": ["moment.min.js"]
  }
}

Alternatively, if you use a module that will be used by multiple elements, or by questions directly, then it may be better to place the module in clientFilesCourse instead of copying it into each element directory. In this case, you can link to the module from your info.json as follows:

info.json
{
  "controller": "pl-my-element.py",
  "dependencies": {
    "clientFilesCourseScripts": ["moment.min.js"]
  }
}

For dynamic dependencies, the same recommendations apply. In that case, you are encouraged to use a key that is unlikely to clash with other dynamic dependencies, such as pl-my-element/moment for a moment dependency used by pl-my-element, or course-specific-name/moment for a moment dependency used by multiple elements in a course. In that case, make sure that your element script imports the module using the same key as defined in the info.json file, such as import('pl-my-element/moment') for an element script dependency. For example, if you copy the moment library into your element directory as moment.min.js, you can define a dynamic dependency on this file as follows:

info.json
{
  "controller": "pl-my-element.py",
  "dependencies": {
    "elementScripts": ["pl-my-element.js"]
  },
  "dynamicDependencies": {
    "elementScripts": { "pl-my-element/moment": "moment.min.js" }
  }
}

Note

Note that, by using the methods above, you become responsible for ensuring that the module is up-to-date with latest changes and security updates. If you choose to copy a node module into your element directory, you may choose to include the version number in the file name (e.g., moment-2.30.1.min.js) to make it easier to keep track of which version you are using and to update it when necessary.