Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-survey-multi-choice-response-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@jspsych/plugin-survey-multi-choice": minor
---

Add `response_index` to survey-multi-choice trial data to record selected option indices and disambiguate duplicate option labels.
3 changes: 2 additions & 1 deletion contributors.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ The following people have contributed to the development of jsPsych by writing c
* Andy Heusser - https://github.com/andrewheusser
* Angus Hughes - https://github.com/awhug
* jade - https://github.com/jadeddelta
* Stephen M. Jerge - https://github.com/stephenmjerge
* Gustavo Juantorena - https://github.com/GEJ1
* Chris Jungerius - https://github.com/cjungerius
* George Kachergis - https://github.com/kachergis
Expand Down Expand Up @@ -72,4 +73,4 @@ The following people have contributed to the development of jsPsych by writing c
* Shaobin Jiang - https://github.com/Shaobin-Jiang
* Haotian Tu - https://github.com/thtTNT
* Joshua Unrau - https://github.com/joshunrau
* Victor Zhang - https://github.com/vzhang03
* Victor Zhang - https://github.com/vzhang03
3 changes: 2 additions & 1 deletion docs/plugins/survey-multi-choice.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ In addition to the [default data collected by all plugins](../overview/plugins.m

Name | Type | Value
-----|------|------
response | object | 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 the name of the option label selected (string). 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 | object | 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 the name of the option label selected (string). 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. Unanswered questions are recorded as empty strings. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions. |
response_index | array | An array containing the index of the selected option for each question. Unanswered questions are recorded as -1. |
rt | numeric | 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. |
question_order | array | 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. |

Expand Down
109 changes: 81 additions & 28 deletions packages/plugin-survey-multi-choice/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,54 @@
import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils";

import { initJsPsych } from "jspsych";

import surveyMultiChoice from ".";

jest.useFakeTimers();

const getInputElement = (
choiceId: number,
value: string,
const getInputElement = (choiceId: number, value: string, displayElement: HTMLElement) =>
displayElement.querySelector(
`#jspsych-survey-multi-choice-${choiceId} input[value="${value}"]`
) as HTMLInputElement;

const getInputElementByIndex = (
choiceId: number,
optionIndex: number,
displayElement: HTMLElement
) =>
displayElement.querySelector(
`#jspsych-survey-multi-choice-${choiceId} input[value="${value}"]`
`#jspsych-survey-multi-choice-response-${choiceId}-${optionIndex}`
) as HTMLInputElement;

describe("survey-multi-choice plugin", () => {
test("properly ends when has sibling form", async () => {

const container = document.createElement('div')
const outerForm = document.createElement('form')
outerForm.id = 'outer_form'
container.appendChild(outerForm)
const innerDiv = document.createElement('div')
innerDiv.id = 'target_id';
const container = document.createElement("div");
const outerForm = document.createElement("form");
outerForm.id = "outer_form";
container.appendChild(outerForm);
const innerDiv = document.createElement("div");
innerDiv.id = "target_id";
container.appendChild(innerDiv);
document.body.appendChild(container)
const jsPsychInst = initJsPsych({ display_element: innerDiv })
document.body.appendChild(container);
const jsPsychInst = initJsPsych({ display_element: innerDiv });
const options = ["a", "b", "c"];

const { displayElement, expectFinished } = await startTimeline([
{
type: surveyMultiChoice,
questions: [
{ prompt: "Q0", options },
{ prompt: "Q1", options },
]
},
], jsPsychInst);
const { displayElement, expectFinished } = await startTimeline(
[
{
type: surveyMultiChoice,
questions: [
{ prompt: "Q0", options },
{ prompt: "Q1", options },
],
},
],
jsPsychInst
);

getInputElement(0, "a", displayElement).checked = true;
await clickTarget(displayElement.querySelector("#jspsych-survey-multi-choice-next"));
await expectFinished();
})
});

test("data are logged with the right question when randomize order is true", async () => {
var scale = ["a", "b", "c", "d", "e"];
Expand Down Expand Up @@ -76,6 +83,42 @@ describe("survey-multi-choice plugin", () => {
expect(surveyData.Q3).toBe("d");
expect(surveyData.Q4).toBe("e");
});

test("records response_index for duplicate options", async () => {
const options = ["Little", "", "", "Much"];
const { getData, expectFinished, displayElement } = await startTimeline([
{
type: surveyMultiChoice,
questions: [{ prompt: "How much", options, required: false }],
},
]);

getInputElementByIndex(0, 2, displayElement).checked = true;

await clickTarget(displayElement.querySelector("#jspsych-survey-multi-choice-next"));
await expectFinished();

const surveyData = getData().values()[0];
expect(surveyData.response.Q0).toBe("");
expect(surveyData.response_index[0]).toBe(2);
});

test("records -1 in response_index for unanswered questions", async () => {
const options = ["Little", "", "", "Much"];
const { getData, expectFinished, displayElement } = await startTimeline([
{
type: surveyMultiChoice,
questions: [{ prompt: "How much", options, required: false }],
},
]);

await clickTarget(displayElement.querySelector("#jspsych-survey-multi-choice-next"));
await expectFinished();

const surveyData = getData().values()[0];
expect(surveyData.response.Q0).toBe("");
expect(surveyData.response_index[0]).toBe(-1);
});
});

describe("survey-multi-choice plugin simulation", () => {
Expand All @@ -97,11 +140,16 @@ describe("survey-multi-choice plugin simulation", () => {

await expectFinished();

const surveyData = getData().values()[0].response;
const all_valid = Object.entries(surveyData).every((x) => {
const surveyData = getData().values()[0];
const all_valid = Object.entries(surveyData.response).every((x) => {
return scale.includes(x[1] as string);
});
expect(all_valid).toBe(true);
expect(surveyData.response_index).toHaveLength(scale.length);
const indices_valid = surveyData.response_index.every(
(index) => Number.isInteger(index) && index >= 0 && index < scale.length
);
expect(indices_valid).toBe(true);
});

test("visual mode works", async () => {
Expand Down Expand Up @@ -129,10 +177,15 @@ describe("survey-multi-choice plugin simulation", () => {

await expectFinished();

const surveyData = getData().values()[0].response;
const all_valid = Object.entries(surveyData).every((x) => {
const surveyData = getData().values()[0];
const all_valid = Object.entries(surveyData.response).every((x) => {
return scale.includes(x[1] as string);
});
expect(all_valid).toBe(true);
expect(surveyData.response_index).toHaveLength(scale.length);
const indices_valid = surveyData.response_index.every(
(index) => Number.isInteger(index) && index >= 0 && index < scale.length
);
expect(indices_valid).toBe(true);
});
});
55 changes: 39 additions & 16 deletions packages/plugin-survey-multi-choice/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ const info = <const>{
response: {
type: ParameterType.OBJECT,
},
/** An array containing the index of the selected option for each question. Unanswered questions are recorded as -1. */
response_index: {
type: ParameterType.INT,
array: true,
},
/** 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,
Expand Down Expand Up @@ -111,10 +116,9 @@ const plugin_id_name = "jspsych-survey-multi-choice";
class SurveyMultiChoicePlugin implements JsPsychPlugin<Info> {
static info = info;

constructor(private jsPsych: JsPsych) { }
constructor(private jsPsych: JsPsych) {}

trial(display_element: HTMLElement, trial: TrialType<Info>) {

const trial_form_id = `${plugin_id_name}_form`;

var html = "";
Expand Down Expand Up @@ -164,7 +168,9 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin<Info> {
question_classes.push(`${plugin_id_name}-horizontal`);
}

html += `<div id="${plugin_id_name}-${question_id}" class="${question_classes.join(" ")}" data-name="${question.name}">`;
html += `<div id="${plugin_id_name}-${question_id}" class="${question_classes.join(
" "
)}" data-name="${question.name}">`;

// add question text
html += `<p class="${plugin_id_name}-text survey-multi-choice">${question.prompt}`;
Expand All @@ -186,7 +192,7 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin<Info> {
html += `
<div id="${option_id_name}" class="${plugin_id_name}-option">
<label class="${plugin_id_name}-text" for="${input_id}">
<input type="radio" name="${input_name}" id="${input_id}" value="${question.options[j]}" ${required_attr} />
<input type="radio" name="${input_name}" id="${input_id}" value="${question.options[j]}" data-option-index="${j}" ${required_attr} />
${question.options[j]}
</label>
</div>`;
Expand All @@ -196,7 +202,9 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin<Info> {
}

// add submit button
html += `<input type="submit" id="${plugin_id_name}-next" class="${plugin_id_name} jspsych-btn"${trial.button_label ? ' value="' + trial.button_label + '"' : ""} />`;
html += `<input type="submit" id="${plugin_id_name}-next" class="${plugin_id_name} jspsych-btn"${
trial.button_label ? ' value="' + trial.button_label + '"' : ""
} />`;
html += "</form>";

// render
Expand All @@ -212,12 +220,16 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin<Info> {

// create object to hold responses
var question_data = {};
var response_index = [];
for (var i = 0; i < trial.questions.length; i++) {
var match = display_element.querySelector(`#${plugin_id_name}-${i}`);
var id = "Q" + i;
var val: String;
if (match.querySelector("input[type=radio]:checked") !== null) {
val = match.querySelector<HTMLInputElement>("input[type=radio]:checked").value;
var val: String = "";
var selected_index = -1;
var checked = match.querySelector<HTMLInputElement>("input[type=radio]:checked");
if (checked !== null) {
val = checked.value;
selected_index = Number(checked.dataset.optionIndex);
} else {
val = "";
}
Expand All @@ -228,11 +240,13 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin<Info> {
}
obje[name] = val;
Object.assign(question_data, obje);
response_index.push(selected_index);
}
// save data
var trial_data = {
rt: response_time,
response: question_data,
response_index: response_index,
question_order: question_order,
};

Expand Down Expand Up @@ -260,16 +274,21 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin<Info> {

private create_simulation_data(trial: TrialType<Info>, simulation_options) {
const question_data = {};
const response_index = [];
let rt = 1000;

for (const q of trial.questions) {
const name = q.name ? q.name : `Q${trial.questions.indexOf(q)}`;
question_data[name] = this.jsPsych.randomization.sampleWithoutReplacement(q.options, 1)[0];
for (let i = 0; i < trial.questions.length; i++) {
const q = trial.questions[i];
const name = q.name ? q.name : `Q${i}`;
const option_index = this.jsPsych.randomization.randomInt(0, q.options.length - 1);
question_data[name] = q.options[option_index];
response_index.push(option_index);
rt += this.jsPsych.randomization.sampleExGaussian(1500, 400, 1 / 200, true);
}

const default_data = {
response: question_data,
response_index: response_index,
rt: rt,
question_order: trial.randomize_question_order
? this.jsPsych.randomization.shuffle([...Array(trial.questions.length).keys()])
Expand Down Expand Up @@ -298,13 +317,17 @@ class SurveyMultiChoicePlugin implements JsPsychPlugin<Info> {
load_callback();

const answers = Object.entries(data.response);
const response_index = Array.isArray(data.response_index) ? data.response_index : [];
for (let i = 0; i < answers.length; i++) {
let option_index = response_index[i];
if (typeof option_index !== "number" || option_index < 0) {
option_index = trial.questions[i].options.indexOf(answers[i][1]);
}
if (option_index < 0) {
continue;
}
this.jsPsych.pluginAPI.clickTarget(
display_element.querySelector(
`#${plugin_id_name}-response-${i}-${trial.questions[i].options.indexOf(
answers[i][1]
)}`
),
display_element.querySelector(`#${plugin_id_name}-response-${i}-${option_index}`),
((data.rt - 1000) / answers.length) * (i + 1)
);
}
Expand Down