@jspsych/plugin-survey-text
Version:
a jspsych plugin for free response survey questions
343 lines (311 loc) • 11.9 kB
text/typescript
import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
import { version } from "../package.json";
const info = <const>{
name: "survey-text",
version: version,
parameters: {
/**
* An array of objects, each object represents a question that appears on the screen. Each object contains a prompt,
* options, required, and horizontal parameter that will be applied to the question. See examples below for further
* clarification.`prompt`: Type string, default value is *undefined*. The string is prompt/question that will be
* associated with a group of options (radio buttons). All questions will get presented on the same page (trial).
* `options`: Type array, defualt value is *undefined*. An array of strings. The array contains a set of options to
* display for an individual question.`required`: Type boolean, default value is null. The boolean value indicates
* if a question is required('true') or not ('false'), using the HTML5 `required` attribute. If this parameter is
* undefined, the question will be optional. `horizontal`:Type boolean, default value is false. If true, then the
* question is centered and the options are displayed horizontally. `name`: Name of the question. Used for storing
* data. If left undefined then default names (`Q0`, `Q1`, `...`) will be used for the questions.
*/
questions: {
type: ParameterType.COMPLEX,
array: true,
default: undefined,
nested: {
/** Question prompt. */
prompt: {
type: ParameterType.HTML_STRING,
default: undefined,
},
/** Placeholder text in the response text box. */
placeholder: {
type: ParameterType.STRING,
default: "",
},
/** The number of rows for the response text box. */
rows: {
type: ParameterType.INT,
default: 1,
},
/** The number of columns for the response text box. */
columns: {
type: ParameterType.INT,
default: 40,
},
/** Whether or not a response to this question must be given in order to continue. */
required: {
type: ParameterType.BOOL,
default: false,
},
/** Name of the question in the trial data. If no name is given, the questions are named Q0, Q1, etc. */
name: {
type: ParameterType.STRING,
default: "",
},
},
},
/**
* If true, the display order of `questions` is randomly determined at the start of the trial. In the data
* object, `Q0` will still refer to the first question in the array, regardless of where it was presented
* visually.
*/
randomize_question_order: {
type: ParameterType.BOOL,
default: false,
},
/** HTML formatted string to display at the top of the page above all the questions. */
preamble: {
type: ParameterType.HTML_STRING,
default: null,
},
/** Label of the button to submit responses. */
button_label: {
type: ParameterType.STRING,
default: "Continue",
},
/** Setting this to true will enable browser auto-complete or auto-fill for the form. */
autocomplete: {
type: ParameterType.BOOL,
default: false,
},
},
data: {
/** An object containing the response for each question. The object will have a separate key (variable) for each question, with the first question in the trial being recorded in `Q0`, the second in `Q1`, and so on. The responses are recorded as integers, representing the position selected on the likert scale for that question. If the `name` parameter is defined for the question, then the response object will use the value of `name` as the key for each question. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions. */
response: {
type: ParameterType.COMPLEX,
nested: {
identifier: {
type: ParameterType.STRING,
},
response: {
type:
ParameterType.STRING |
ParameterType.INT |
ParameterType.FLOAT |
ParameterType.BOOL |
ParameterType.OBJECT,
},
},
},
/** The response time in milliseconds for the participant to make a response. The time is measured from when the questions first appear on the screen until the participant's response(s) are submitted. */
rt: {
type: ParameterType.INT,
},
/** An array with the order of questions. For example `[2,0,1]` would indicate that the first question was `trial.questions[2]` (the third item in the `questions` parameter), the second question was `trial.questions[0]`, and the final question was `trial.questions[1]`. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions. */
question_order: {
type: ParameterType.INT,
array: true,
},
},
};
type Info = typeof info;
/**
*
* The survey-text plugin displays a set of questions with free response text fields. The participant types in answers.
*
* @author Josh de Leeuw
* @see {@link https://www.jspsych.org/latest/plugins/survey-text/ survey-text plugin documentation on jspsych.org}
*/
class SurveyTextPlugin implements JsPsychPlugin<Info> {
static info = info;
constructor(private jsPsych: JsPsych) {}
trial(display_element: HTMLElement, trial: TrialType<Info>) {
for (var i = 0; i < trial.questions.length; i++) {
if (typeof trial.questions[i].rows == "undefined") {
trial.questions[i].rows = 1;
}
}
for (var i = 0; i < trial.questions.length; i++) {
if (typeof trial.questions[i].columns == "undefined") {
trial.questions[i].columns = 40;
}
}
for (var i = 0; i < trial.questions.length; i++) {
if (typeof trial.questions[i].value == "undefined") {
trial.questions[i].value = "";
}
}
var html = "";
// show preamble text
if (trial.preamble !== null) {
html +=
'<div id="jspsych-survey-text-preamble" class="jspsych-survey-text-preamble">' +
trial.preamble +
"</div>";
}
// start form
if (trial.autocomplete) {
html += '<form id="jspsych-survey-text-form">';
} else {
html += '<form id="jspsych-survey-text-form" autocomplete="off">';
}
// generate question order
var question_order = [];
for (var i = 0; i < trial.questions.length; i++) {
question_order.push(i);
}
if (trial.randomize_question_order) {
question_order = this.jsPsych.randomization.shuffle(question_order);
}
// add questions
for (var i = 0; i < trial.questions.length; i++) {
var question = trial.questions[question_order[i]];
var question_index = question_order[i];
html +=
'<div id="jspsych-survey-text-' +
question_index +
'" class="jspsych-survey-text-question" style="margin: 2em 0em;">';
html += '<p class="jspsych-survey-text">' + question.prompt + "</p>";
var autofocus = i == 0 ? "autofocus" : "";
var req = question.required ? "required" : "";
if (question.rows == 1) {
html +=
'<input type="text" id="input-' +
question_index +
'" name="#jspsych-survey-text-response-' +
question_index +
'" data-name="' +
question.name +
'" size="' +
question.columns +
'" ' +
autofocus +
" " +
req +
' placeholder="' +
question.placeholder +
'"></input>';
} else {
html +=
'<textarea id="input-' +
question_index +
'" name="#jspsych-survey-text-response-' +
question_index +
'" data-name="' +
question.name +
'" cols="' +
question.columns +
'" rows="' +
question.rows +
'" ' +
autofocus +
" " +
req +
' placeholder="' +
question.placeholder +
'"></textarea>';
}
html += "</div>";
}
// add submit button
html +=
'<input type="submit" id="jspsych-survey-text-next" class="jspsych-btn jspsych-survey-text" value="' +
trial.button_label +
'"></input>';
html += "</form>";
display_element.innerHTML = html;
// backup in case autofocus doesn't work
display_element.querySelector<HTMLInputElement>("#input-" + question_order[0]).focus();
display_element.querySelector("#jspsych-survey-text-form").addEventListener("submit", (e) => {
e.preventDefault();
// measure response time
var endTime = performance.now();
var response_time = Math.round(endTime - startTime);
// create object to hold responses
var question_data = {};
for (var index = 0; index < trial.questions.length; index++) {
var id = "Q" + index;
var q_element = document
.querySelector("#jspsych-survey-text-" + index)
.querySelector("textarea, input") as HTMLInputElement;
var val = q_element.value;
var name = q_element.attributes["data-name"].value;
if (name == "") {
name = id;
}
var obje = {};
obje[name] = val;
Object.assign(question_data, obje);
}
// save data
var trialdata = {
rt: response_time,
response: question_data,
};
// next trial
this.jsPsych.finishTrial(trialdata);
});
var startTime = performance.now();
}
simulate(
trial: TrialType<Info>,
simulation_mode,
simulation_options: any,
load_callback: () => void
) {
if (simulation_mode == "data-only") {
load_callback();
this.simulate_data_only(trial, simulation_options);
}
if (simulation_mode == "visual") {
this.simulate_visual(trial, simulation_options, load_callback);
}
}
private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const question_data = {};
let rt = 1000;
for (const q of trial.questions) {
const name = q.name ? q.name : `Q${trial.questions.indexOf(q)}`;
const ans_words =
q.rows == 1
? this.jsPsych.randomization.sampleExponential(0.25)
: this.jsPsych.randomization.randomInt(1, 10) * q.rows;
question_data[name] = this.jsPsych.randomization.randomWords({
exactly: ans_words,
join: " ",
});
rt += this.jsPsych.randomization.sampleExGaussian(2000, 400, 0.004, true);
}
const default_data = {
response: question_data,
rt: rt,
};
const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options);
this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data);
return data;
}
private simulate_data_only(trial: TrialType<Info>, simulation_options) {
const data = this.create_simulation_data(trial, simulation_options);
this.jsPsych.finishTrial(data);
}
private simulate_visual(trial: TrialType<Info>, simulation_options, load_callback: () => void) {
const data = this.create_simulation_data(trial, simulation_options);
const display_element = this.jsPsych.getDisplayElement();
this.trial(display_element, trial);
load_callback();
const answers = Object.entries(data.response).map((x) => {
return x[1] as string;
});
for (let i = 0; i < answers.length; i++) {
this.jsPsych.pluginAPI.fillTextInput(
display_element.querySelector(`#input-${i}`),
answers[i],
((data.rt - 1000) / answers.length) * (i + 1)
);
}
this.jsPsych.pluginAPI.clickTarget(
display_element.querySelector("#jspsych-survey-text-next"),
data.rt
);
}
}
export default SurveyTextPlugin;