1
0
mirror of https://github.com/psychopy/psychojs.git synced 2025-05-10 02:30:53 +00:00

various fixes and feature improvements

This commit is contained in:
Alain Pitiot 2024-04-01 08:51:27 +02:00
parent c3a2b4b9f6
commit 51674ab073
10 changed files with 312 additions and 141 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "psychojs", "name": "psychojs",
"version": "2024.4.1", "version": "2024.1.0",
"private": true, "private": true,
"description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments", "description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments",
"license": "MIT", "license": "MIT",

View File

@ -187,13 +187,26 @@ export class GUI
{ {
atLeastOneIncludedKey = true; atLeastOneIncludedKey = true;
markup += `<label for='${keyId}'> ${key} </label>`; // deal with field options:
// - if the field is required:
// if the field is required: if (key.slice(-4) === "|req")
{
key = `${key.slice(0, -4)}*`;
}
if (key.slice(-1) === "*") if (key.slice(-1) === "*")
{ {
self._requiredKeys.push(keyId); self._requiredKeys.push(keyId);
} }
// - all other new options are currently discarded
// TODO
// remove the new option extensions:
if (key.slice(-4) === "|req" || key.slice(-4) === "|cfg" || key.slice(-4) === "|fix" || key.slice(-4) === "|opt")
{
key = key.slice(0, -4);
}
markup += `<label for='${keyId}'> ${key} </label>`;
// if value is an array, we create a select drop-down menu: // if value is an array, we create a select drop-down menu:
if (Array.isArray(value)) if (Array.isArray(value))
@ -240,7 +253,6 @@ export class GUI
markup += "<div class='progress-container'><div id='progressBar' class='progress-bar'></div></div>"; markup += "<div class='progress-container'><div id='progressBar' class='progress-bar'></div></div>";
// buttons: // buttons:
markup += "<hr>";
markup += "<div class='dialog-button-group'>"; markup += "<div class='dialog-button-group'>";
markup += "<button id='dialogCancel' class='dialog-button' aria-label='Cancel Experiment'>Cancel</button>"; markup += "<button id='dialogCancel' class='dialog-button' aria-label='Cancel Experiment'>Cancel</button>";
if (self._requireParticipantClick) if (self._requireParticipantClick)
@ -417,13 +429,13 @@ export class GUI
markup += "</div>"; markup += "</div>";
} }
if (showOK || showCancel) // if (showOK || showCancel)
{ // {
markup += "<hr>"; // markup += "<hr>";
} // }
if (showCancel || showOK) if (showCancel || showOK)
{ {
markup += "<div class='button-group'>"; markup += "<div class='dialog-button-group'>";
if (showCancel) if (showCancel)
{ {
markup += "<button id='dialogCancel' class='dialog-button' aria-label='Close dialog'>Cancel</button>"; markup += "<button id='dialogCancel' class='dialog-button' aria-label='Close dialog'>Cancel</button>";
@ -493,11 +505,15 @@ export class GUI
markup += "<div class='dialog-overlay'></div>"; markup += "<div class='dialog-overlay'></div>";
markup += "<div class='dialog-content'>"; markup += "<div class='dialog-content'>";
markup += `<div id='experiment-dialog-title' class='dialog-title dialog-warning'><p>Warning</p></div>`; markup += `<div id='experiment-dialog-title' class='dialog-title dialog-warning'><p>Warning</p></div>`;
markup += "<div class='scrollable-container'>";
markup += `<p>${text}</p>`; markup += `<p>${text}</p>`;
markup += "</div>";
// progress bar: // progress bar:
markup += `<hr><div id='progressMsg' class='progress-msg'>&nbsp;</div>`; markup += `<hr><div id='progressMsg' class='progress-msg'>&nbsp;</div>`;
markup += "<div class='progress-container'><div id='progressBar' class='progress-bar'></div></div>"; markup += "<div class='progress-container'><div id='progressBar' class='progress-bar'></div></div>";
markup += "<div class='dialog-button-group'></div>";
markup += "</div></div>"; markup += "</div></div>";
@ -608,8 +624,11 @@ export class GUI
// clear all events (and keypresses) accumulated until now: // clear all events (and keypresses) accumulated until now:
this._psychoJS.eventManager.clearEvents(); this._psychoJS.eventManager.clearEvents();
this._dialog.hide(); if (this._dialog)
this._dialog = null; {
this._dialog.hide();
this._dialog = null;
}
this._dialogComponent.status = PsychoJS.Status.FINISHED; this._dialogComponent.status = PsychoJS.Status.FINISHED;
} }
@ -710,7 +729,6 @@ export class GUI
return; return;
} }
// if all requirements are fulfilled and the participant is not required to click on the OK button, // if all requirements are fulfilled and the participant is not required to click on the OK button,
// then we close the dialog box and move on with the experiment: // then we close the dialog box and move on with the experiment:
if (allRequirementsFulfilled) if (allRequirementsFulfilled)

View File

@ -144,8 +144,8 @@ export class PsychoJS
}); });
// add the pavlovia server to the list of hosts: // add the pavlovia server to the list of hosts:
const hostsWithPavlovia = new Set([...hosts, "https://pavlovia.org/run/", "https://run.pavlovia.org/"]); const pavloviaHosts = new Set([...hosts, "https://pavlovia.org/run/", "https://run.pavlovia.org/", "https://devlovia.org/run/", "https://run.devlovia.org/"]);
this._hosts = Array.from(hostsWithPavlovia); this._hosts = Array.from(pavloviaHosts);
// GUI: // GUI:
this._gui = new GUI(this); this._gui = new GUI(this);
@ -166,6 +166,9 @@ export class PsychoJS
this._cancellationUrl = undefined; this._cancellationUrl = undefined;
this._completionUrl = undefined; this._completionUrl = undefined;
// survey id, if applicable:
this._surveyId = undefined;
// status: // status:
this.status = PsychoJS.Status.NOT_CONFIGURED; this.status = PsychoJS.Status.NOT_CONFIGURED;
@ -377,6 +380,7 @@ export class PsychoJS
if (typeof surveyId !== "undefined") if (typeof surveyId !== "undefined")
{ {
params.surveyId = surveyId; params.surveyId = surveyId;
this._surveyId = surveyId;
} }
await this._serverManager.openSession(params); await this._serverManager.openSession(params);
@ -411,7 +415,9 @@ export class PsychoJS
{ {
// note: we set lastUploadTimestamp to undefined to prevent uploadData from throttling this call // note: we set lastUploadTimestamp to undefined to prevent uploadData from throttling this call
delete self._config.experiment.resultsUpload.lastUploadTimestamp; delete self._config.experiment.resultsUpload.lastUploadTimestamp;
self._experiment.save({ sync: true }); self._experiment.save({
sync: true
});
} }
// close the session: // close the session:
@ -634,7 +640,7 @@ export class PsychoJS
if (showOK) if (showOK)
{ {
let text = "Thank you for your patience."; let text = "Thank you for your patience. ";
text += (typeof message !== "undefined") ? message : "Goodbye!"; text += (typeof message !== "undefined") ? message : "Goodbye!";
this._gui.dialog({ this._gui.dialog({
message: text, message: text,

View File

@ -134,7 +134,7 @@ export class ServerManager extends PsychObject
* @property {Object.<string, *>} [error] an error message if we could not open the session * @property {Object.<string, *>} [error] an error message if we could not open the session
*/ */
/** /**
* Open a session for this experiment on the remote PsychoJS manager. * Open a session for this experiment on the pavlovia server.
* *
* @param {Object} params - the open session parameters * @param {Object} params - the open session parameters
* *
@ -236,10 +236,10 @@ export class ServerManager extends PsychObject
* previously been opened) * previously been opened)
*/ */
/** /**
* Close the session for this experiment on the remote PsychoJS manager. * Close the session for this experiment on the pavlovia server.
* *
* @param {boolean} [isCompleted= false] - whether or not the experiment was completed * @param {boolean} [isCompleted= false] - whether the experiment was completed
* @param {boolean} [sync= false] - whether or not to communicate with the server in a synchronous manner * @param {boolean} [sync= false] - whether to communicate with the server in a synchronous manner
* @returns {Promise<ServerManager.CloseSessionPromise> | void} the response * @returns {Promise<ServerManager.CloseSessionPromise> | void} the response
*/ */
async closeSession(isCompleted = false, sync = false) async closeSession(isCompleted = false, sync = false)
@ -260,6 +260,10 @@ export class ServerManager extends PsychObject
+ "/sessions/" + this._psychoJS.config.session.token + "/delete"; + "/sessions/" + this._psychoJS.config.session.token + "/delete";
const formData = new FormData(); const formData = new FormData();
formData.append("isCompleted", isCompleted); formData.append("isCompleted", isCompleted);
if (typeof this._psychoJS._surveyId !== "undefined")
{
formData.append("surveyId", this._psychoJS._surveyId);
}
navigator.sendBeacon(url, formData); navigator.sendBeacon(url, formData);
this._psychoJS.config.session.status = "CLOSED"; this._psychoJS.config.session.status = "CLOSED";
@ -272,10 +276,18 @@ export class ServerManager extends PsychObject
{ {
try try
{ {
const data = {
isCompleted
};
if (typeof this._psychoJS._surveyId !== "undefined")
{
data["surveyId"] = this._psychoJS._surveyId;
}
const deleteResponse = await this._queryServerAPI( const deleteResponse = await this._queryServerAPI(
"DELETE", "DELETE",
`experiments/${this._psychoJS.config.gitlab.projectId}/sessions/${this._psychoJS.config.session.token}`, `experiments/${this._psychoJS.config.gitlab.projectId}/sessions/${this._psychoJS.config.session.token}`,
{ isCompleted }, data,
"FORM" "FORM"
); );
@ -591,18 +603,24 @@ export class ServerManager extends PsychObject
{ {
// add the SurveyJS and PsychoJS Survey .js and .css resources: // add the SurveyJS and PsychoJS Survey .js and .css resources:
resources[r] = { resources[r] = {
name: "jquery-3.6.0.min.js", name: "jquery-3.5.1.min.js",
path: "./lib/vendors/jquery-3.6.0.min.js", path: "./lib/vendors/jquery-3.5.1.min.js",
// name: "jquery-3.6.0.min.js",
// path: "./lib/vendors/jquery-3.6.0.min.js",
download: true download: true
}; };
resources.push({ resources.push({
name: "survey.jquery-1.9.50.min.js", name: "surveyjs.jquery-1.9.126.min.js",
path: "./lib/vendors/survey.jquery-1.9.50.min.js", path: "./lib/vendors/surveyjs.jquery-1.9.126.min.js",
// name: "survey.jquery-1.9.50.min.js",
// path: "./lib/vendors/survey.jquery-1.9.50.min.js",
download: true download: true
}); });
resources.push({ resources.push({
name: "survey.defaultV2-1.9.50.min.css", name: "surveyjs.defaultV2-1.9.126-OST.min.css",
path: "./lib/vendors/survey.defaultV2-1.9.50.min.css", path: "./lib/vendors/surveyjs.defaultV2-1.9.126-OST.min.css",
// name: "survey.defaultV2-1.9.50.min.css",
// path: "./lib/vendors/survey.defaultV2-1.9.50.min.css",
download: true download: true
}); });
resources.push({ resources.push({
@ -807,9 +825,8 @@ export class ServerManager extends PsychObject
// data upload throttling: // data upload throttling:
const now = MonotonicClock.getReferenceTime(); const now = MonotonicClock.getReferenceTime();
if ( (typeof this._psychoJS.config.experiment.resultsUpload.lastUploadTimestamp !== "undefined") && const checkThrottling = (typeof this._psychoJS.config.experiment.resultsUpload.lastUploadTimestamp !== "undefined");
(now - this._psychoJS.config.experiment.resultsUpload.lastUploadTimestamp < this._uploadThrottlePeriod * 60) if (checkThrottling && (now - this._psychoJS.config.experiment.resultsUpload.lastUploadTimestamp < this._uploadThrottlePeriod * 60))
)
{ {
return Promise.reject({ ...response, error: "upload canceled by throttling"}); return Promise.reject({ ...response, error: "upload canceled by throttling"});
} }

View File

@ -97,6 +97,9 @@ export class ExperimentHandler extends PsychObject
this._trialsData = []; this._trialsData = [];
this._currentTrialData = {}; this._currentTrialData = {};
// whether a header for the .csv result file is necessary:
this._isCsvHeaderNeeded = true;
this._experimentEnded = false; this._experimentEnded = false;
} }
@ -236,9 +239,9 @@ export class ExperimentHandler extends PsychObject
* *
* @param {Object} options * @param {Object} options
* @param {Array.<Object>} [options.attributes] - the attributes to be saved * @param {Array.<Object>} [options.attributes] - the attributes to be saved
* @param {boolean} [options.sync=false] - whether or not to communicate with the server in a synchronous manner * @param {boolean} [options.sync=false] - whether to communicate with the server in a synchronous manner
* @param {string} [options.tag=''] - an optional tag to add to the filename to which the data is saved (for CSV and XLSX saving options) * @param {string} [options.tag=''] - an optional tag to add to the filename to which the data is saved (for CSV and XLSX saving options)
* @param {boolean} [options.clear=false] - whether or not to clear all experiment results immediately after they are saved (this is useful when saving data in separate chunks, throughout an experiment) * @param {boolean} [options.clear=false] - whether to clear all experiment results immediately after they are saved (this is useful when saving data in separate chunks, throughout an experiment)
*/ */
async save({ async save({
attributes = [], attributes = [],
@ -289,10 +292,24 @@ export class ExperimentHandler extends PsychObject
{ {
// note: we use the XLSX library as it automatically deals with header, takes care of quotes, // note: we use the XLSX library as it automatically deals with header, takes care of quotes,
// newlines, etc. // newlines, etc.
// we need a header if it is asked for and there is actual data to save:
const withHeader = this._isCsvHeaderNeeded && (data.length > 0);
// if we are outputting a header on this occasion, we won't need one thereafter:
if (this._isCsvHeaderNeeded)
{
this._isCsvHeaderNeeded = !withHeader;
}
// TODO only save the given attributes // TODO only save the given attributes
const worksheet = XLSX.utils.json_to_sheet(data); const worksheet = XLSX.utils.json_to_sheet(data, {skipHeader: !withHeader});
// prepend BOM // note: start with a BOM if necessary
const csv = "\ufeff" + XLSX.utils.sheet_to_csv(worksheet); let csv = ( (withHeader) ? "\ufeff" : "" ) + XLSX.utils.sheet_to_csv(worksheet);
if (data.length > 0)
{
csv += "\n";
}
// upload data to the pavlovia server or offer them for download: // upload data to the pavlovia server or offer them for download:
const filenameWithoutPath = this._dataFileName.split(/[\\/]/).pop(); const filenameWithoutPath = this._dataFileName.split(/[\\/]/).pop();

View File

@ -514,31 +514,105 @@ export class Shelf extends PsychObject
/** /**
* Get the name of a group, using a counterbalanced design. * Get the name of a group, using a counterbalanced design.
* *
* @note the participant token returned by this call is useful when confirming or cancelling that participant's
* participation with counterBalanceConfirm/Cancel
*
* @param {Object} options * @param {Object} options
* @param {string[]} options.key key as an array of key components * @param {string[]} options.key key as an array of key components
* @param {string[]} options.groups the names of the groups * @return {Promise<{string, boolean, string}>} an object with the name of the selected group,
* @param {number[]} options.groupSizes the size of the groups * whether all groups have been depleted, and a participant token
* @return {Promise<{string, boolean}>} an object with the name of the selected group and whether all groups
* have been depleted
*/ */
async counterBalanceSelect({key, groups, groupSizes} = {}) async counterbalanceSelect({
key,
reserveTimeout
} = {})
{ {
const response = { const response = {
origin: 'Shelf.counterBalanceSelect', origin: 'Shelf.counterbalanceSelect',
context: `when getting the name of a group, using a counterbalanced design, with key: ${JSON.stringify(key)}` context: `when getting the name of a group, using a counterbalanced design with key: ${JSON.stringify(key)}`
}; };
try try
{ {
await this._checkAvailability("counterBalanceSelect"); await this._checkAvailability("counterbalanceSelect");
this._checkKey(key); this._checkKey(key);
// prepare the request: // prepare the request:
const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/counterbalance`; const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/counterbalance/select`;
const data = {
key
};
if (typeof reserveTimeout !== "undefined")
{
data.reserveTimeout = reserveTimeout;
}
// query the server:
const putResponse = await fetch(url, {
method: 'PUT',
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
redirect: 'follow',
referrerPolicy: 'no-referrer',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
// convert the response to json:
const document = await putResponse.json();
if (putResponse.status !== 200)
{
throw ('error' in document) ? document.error : document;
}
// return the result:
this._status = Shelf.Status.READY;
return {
group: document.group,
finished: document.finished,
participantToken: document.participantToken
};
}
catch (error)
{
this._status = Shelf.Status.ERROR;
throw {...response, error};
}
}
/**
* Confirm or cancel a participant's participation to a counterbalanced design.
*
* @note the required participant token is the one returned by a call to counterBalanceSelect
*
* @param {string[]} key - key as an array of key components
* @param {string} participantToken - the participant token
* @param {boolean} confirmed - when the participant's participation is confirmed or cancelled
* @return {Promise<{string, boolean, string}>} an object with the name of the participant group, and
* whether all groups have been depleted
*/
async counterbalanceConfirm(key, participantToken, confirmed)
{
const response = {
origin: 'Shelf.counterBalanceConfirm',
context: `when confirming or cancelling a participant's participation to the counterbalanced design with key: ${JSON.stringify(key)}`
};
try
{
await this._checkAvailability("counterbalanceConfirm");
this._checkKey(key);
// prepare the request:
const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/counterbalance/confirm`;
const data = { const data = {
key, key,
groups, participantToken,
groupSizes confirmed
}; };
// query the server: // query the server:
@ -563,7 +637,7 @@ export class Shelf extends PsychObject
throw ('error' in document) ? document.error : document; throw ('error' in document) ? document.error : document;
} }
// return the updated value: // return the result:
this._status = Shelf.Status.READY; this._status = Shelf.Status.READY;
return { return {
group: document.group, group: document.group,
@ -577,7 +651,6 @@ export class Shelf extends PsychObject
} }
} }
/** /**
* Update the value associated with the given key. * Update the value associated with the given key.
* *

View File

@ -36,16 +36,16 @@ body {
.dialog-container input.text, .dialog-container input.text,
.dialog-container select.text { .dialog-container select.text {
margin-bottom: 1em; margin-bottom: 1em;
padding: 0.5em; padding: 0.5em;
width: 100%; width: 100%;
height: 34px; height: 34px;
border: 1px solid #767676; border: 1px solid #767676;
border-radius: 2px; border-radius: 2px;
background: #ffffff; background: #ffffff;
color: #333; color: #333;
font-size: 14px; font-size: 14px;
} }
.dialog-container fieldset { .dialog-container fieldset {
@ -90,7 +90,7 @@ body {
/*max-height: 90vh;*/ /*max-height: 90vh;*/
max-height: 93%; max-height: 93%;
padding: 0.5em; padding: 2px; /*0.5em;*/
border-radius: 2px; border-radius: 2px;
font-family: 'Open Sans', sans-serif; font-family: 'Open Sans', sans-serif;
@ -103,22 +103,29 @@ body {
.dialog-content .scrollable-container { .dialog-content .scrollable-container {
height: 100%; height: 100%;
padding: 0 0.5em; padding: 0.5em; /*0 0.5em;*/
box-shadow: inset rgba(0, 0, 0, 0.2) 1px 1px 2px, inset rgba(255, 255, 255, 1) -1px -1px 2px;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
} }
.dialog-content .scrollable-container p {
margin: 0;
padding: 0;
}
.dialog-content hr { .dialog-content hr {
width: 100%; margin: 0.5em 2px 2px 2px;
width: calc(100% - 4px);
} }
.dialog-title { .dialog-title {
padding: 0.5em; padding: 0.5em;
margin-bottom: 1em; margin-bottom: 2px; /*0.5em;*/
background-color: #00dd00; background-color: #008500;
/*background-color: #009900;*/
border-radius: 2px; border-radius: 2px;
} }
@ -133,6 +140,8 @@ body {
.dialog-title p { .dialog-title p {
margin: 0; margin: 0;
padding: 0; padding: 0;
color: #FFFFFF;
font-weight: bold; font-weight: bold;
} }
@ -140,17 +149,17 @@ body {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
line-height: 1.1em; line-height: 1em;
position: absolute; position: absolute;
top: 0.7em; top: 5px;
right: 0.7em; right: 5px;
border: 0; border: 0;
padding: 0; padding: 0;
border-radius: 2px; border-radius: 2px;
width: 1.1em; width: calc(2em - 12px);
height: 1.1em; height: calc(2em - 12px);
color: #333333; color: #333333;
background-color: #FFFFFF; background-color: #FFFFFF;
@ -163,12 +172,14 @@ body {
} }
.progress-msg { .progress-msg {
margin: 0 0.2em;
box-sizing: border-box; box-sizing: border-box;
padding: 0.5em 0; padding: 0.5em 0;
} }
.progress-container { .progress-container {
padding: 0.2em; margin: 0 0.2em;
padding: 2px;
border: 1px solid #555555; border: 1px solid #555555;
border-radius: 2px; border-radius: 2px;
@ -208,11 +219,16 @@ body {
} }
.dialog-button-group { .dialog-button-group {
margin: 0.5em 0 0 0;
padding: 0.5em 1em calc(0.5em - 2px) 0.2em;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: flex-start; align-items: flex-start;
column-gap: 0.5em; column-gap: 0.5em;
border-top: 1px solid rgba(0, 0, 0, 0.2);
} }
.disabled { .disabled {
@ -227,8 +243,16 @@ body {
.logo { .logo {
display: flex; display: flex;
flex: 0 1 auto; flex: 0 1 auto;
height: 100%; width: calc(100% - 1em);
width: auto; height: 100%;
max-height: 25vh;
object-fit: contain;
/*width: auto;*/
/*margin: 0 auto;*/
padding: 0.5em 0.5em 1em 0.5em;
margin-bottom: 0.5em;
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
/*display: block; /*display: block;
margin: 0 auto 1em; margin: 0 auto 1em;

View File

@ -71,6 +71,7 @@ export class ButtonStim extends TextBox
win, win,
name, name,
text, text,
placeholder: text,
font, font,
pos, pos,
size, size,

View File

@ -198,8 +198,8 @@ export class Survey extends VisualStim
model = { model = {
surveys: [model], surveys: [model],
embeddedData: [], embeddedData: [],
surveysMap: {}, // surveysMap: {},
questionMapsBySurvey: {}, // questionMapsBySurvey: {},
surveyFlow: { surveyFlow: {
name: "root", name: "root",
type: "SEQUENTIAL_GROUP", type: "SEQUENTIAL_GROUP",
@ -211,7 +211,7 @@ export class Survey extends VisualStim
surveySettings: { showPrevButton: false }, surveySettings: { showPrevButton: false },
surveyRunLogic: {}, // surveyRunLogic: {},
inQuestionRandomization: {}, inQuestionRandomization: {},
questionsOrderRandomization: [], questionsOrderRandomization: [],
questionSkipLogic: {}, questionSkipLogic: {},
@ -224,6 +224,9 @@ export class Survey extends VisualStim
this.psychoJS.logger.debug(`converted the legacy model to the new super-flow model: ${JSON.stringify(model)}`); this.psychoJS.logger.debug(`converted the legacy model to the new super-flow model: ${JSON.stringify(model)}`);
} }
// mark the root (top-most) node:
model.surveyFlow.isRootNode = true;
this._surveyData = model; this._surveyData = model;
this._setAttribute("model", model, log); this._setAttribute("model", model, log);
this._onChange(true, true)(); this._onChange(true, true)();
@ -478,15 +481,12 @@ export class Survey extends VisualStim
// if a survey div does not exist, create it: // if a survey div does not exist, create it:
if (document.getElementById(this._surveyDivId) === null) if (document.getElementById(this._surveyDivId) === null)
{ {
document.body.insertAdjacentHTML("beforeend", `<div id=${this._surveyDivId} class='survey'></div>`) document.body.insertAdjacentHTML("beforeend", `<div id=${this._surveyDivId} class='survey'></div>`);
} }
// start the survey flow: // start the survey flow:
if (typeof this._surveyData !== "undefined") if (typeof this._surveyData !== "undefined")
{ {
// this._startSurvey(surveyId, this._surveyModel);
// jQuery(`#${surveyId}`).Survey({model: this._surveyModel});
this._runSurveyFlow(this._surveyData.surveyFlow, this._surveyData); this._runSurveyFlow(this._surveyData.surveyFlow, this._surveyData);
} }
} }
@ -701,7 +701,6 @@ export class Survey extends VisualStim
_applyInQuestionRandomization (questionData, inQuestionRandomizationSettings, surveyData) _applyInQuestionRandomization (questionData, inQuestionRandomizationSettings, surveyData)
{ {
let t = performance.now();
let choicesFieldName; let choicesFieldName;
let valueFieldName; let valueFieldName;
if (questionData.rows !== undefined) if (questionData.rows !== undefined)
@ -721,7 +720,7 @@ export class Survey extends VisualStim
} }
else else
{ {
console.log("[Survey runner]: Uknown choicesFieldName for", questionData); console.log("[Survey runner]: Unknown choicesFieldName for", questionData);
} }
if (inQuestionRandomizationSettings.randomizeAll) if (inQuestionRandomizationSettings.randomizeAll)
@ -745,8 +744,7 @@ export class Survey extends VisualStim
let choicesMap = {}; let choicesMap = {};
// TODO: generalize further i.e. figure out how to calculate the length of array based on availability of sets. // TODO: generalize further i.e. figure out how to calculate the length of array based on availability of sets.
const setIndices = [0, 0, 0]; const setIndices = [0, 0, 0];
let i; for (let i = 0; i < questionData[choicesFieldName].length; i++)
for (i = 0; i < questionData[choicesFieldName].length; i++)
{ {
choicesMap[questionData[choicesFieldName][i][valueFieldName]] = questionData[choicesFieldName][i]; choicesMap[questionData[choicesFieldName][i][valueFieldName]] = questionData[choicesFieldName][i];
} }
@ -758,7 +756,7 @@ export class Survey extends VisualStim
// const shuffledSet0 = this._FisherYatesShuffle(inQuestionRandomizationSettings.set0); // const shuffledSet0 = this._FisherYatesShuffle(inQuestionRandomizationSettings.set0);
// const shuffledSet1 = this._FisherYatesShuffle(inQuestionRandomizationSettings.set1); // const shuffledSet1 = this._FisherYatesShuffle(inQuestionRandomizationSettings.set1);
const reversedSet = Math.round(Math.random()) === 1 ? inQuestionRandomizationSettings.reverseOrder.reverse() : inQuestionRandomizationSettings.reverseOrder; const reversedSet = Math.round(Math.random()) === 1 ? inQuestionRandomizationSettings.reverseOrder.reverse() : inQuestionRandomizationSettings.reverseOrder;
for (i = 0; i < inQuestionRandomizationSettings.layout.length; i++) for (let i = 0; i < inQuestionRandomizationSettings.layout.length; i++)
{ {
if (inQuestionRandomizationSettings.layout[i] === "set0") if (inQuestionRandomizationSettings.layout[i] === "set0")
{ {
@ -797,53 +795,58 @@ export class Survey extends VisualStim
} }
} }
console.log("applying question randomization took", performance.now() - t);
// console.log(questionData); // console.log(questionData);
} }
/** /**
* @desc: Go over required surveyModelData and apply randomization settings. * Go over required surveyModelData and apply randomization settings.
* @protected
*/ */
_processSurveyData (surveyData, surveyIdx) _processSurveyData(surveyData, surveyIdx)
{ {
let t = performance.now();
let i, j;
let newSurveyModel = undefined; let newSurveyModel = undefined;
if (surveyData.questionsOrderRandomization[surveyIdx] !== undefined)
// Qualtrics's in-block randomization ignores the presence of page breaks within the block.
// Hence creating a fresh survey data object with shuffled question order.
if (typeof surveyData.questionsOrderRandomization[surveyIdx] !== "undefined")
{ {
// Qualtrics's in-block randomization ignores presense of page breaks within the block. newSurveyModel = this._composeModelWithRandomizedQuestions(
// Hence creating a fresh survey data object with shuffled question order. surveyData.surveys[surveyIdx],
newSurveyModel = this._composeModelWithRandomizedQuestions(surveyData.surveys[surveyIdx], surveyData.questionsOrderRandomization[surveyIdx]); surveyData.questionsOrderRandomization[surveyIdx]
);
} }
// Checking if there's in-question randomization that needs to be applied. // note: we need to check whether the survey model has a "pages" field since empty surveys do not:
for (i = 0; i < surveyData.surveys[surveyIdx].pages.length; i++) if ("pages" in surveyData.surveys[surveyIdx])
{ {
for (j = 0; j < surveyData.surveys[surveyIdx].pages[i].elements.length; j++) // checking whether in-question randomization needs to be applied:
for (let i = 0; i < surveyData.surveys[surveyIdx].pages.length; ++i)
{ {
if (surveyData.inQuestionRandomization[surveyData.surveys[surveyIdx].pages[i].elements[j].name] !== undefined) for (let j = 0; j < surveyData.surveys[surveyIdx].pages[i].elements.length; ++j)
{ {
if (newSurveyModel === undefined) if (typeof surveyData.inQuestionRandomization[surveyData.surveys[surveyIdx].pages[i].elements[j].name] !== "undefined")
{ {
// Marking a deep copy of survey model input data, to avoid data loss if randomization returns a subset of choices. if (typeof newSurveyModel === "undefined")
// TODO: think of somehting more optimal. {
newSurveyModel = JSON.parse(JSON.stringify(surveyData.surveys[surveyIdx])); // Marking a deep copy of survey model input data, to avoid data loss if randomization returns a subset of choices.
// TODO: think of something more optimal.
newSurveyModel = JSON.parse(JSON.stringify(surveyData.surveys[surveyIdx]));
}
this._applyInQuestionRandomization(
newSurveyModel.pages[i].elements[j],
surveyData.inQuestionRandomization[newSurveyModel.pages[i].elements[j].name],
surveyData
);
} }
this._applyInQuestionRandomization(
newSurveyModel.pages[i].elements[j],
surveyData.inQuestionRandomization[newSurveyModel.pages[i].elements[j].name],
surveyData
);
} }
} }
} }
if (newSurveyModel === undefined) if (typeof newSurveyModel === "undefined")
{ {
// No changes were made, just return original data. // No changes were made, just return original data.
newSurveyModel = surveyData.surveys[surveyIdx]; newSurveyModel = surveyData.surveys[surveyIdx];
} }
console.log("survey model preprocessing took", performance.now() - t);
return newSurveyModel; return newSurveyModel;
} }
@ -975,7 +978,12 @@ export class Survey extends VisualStim
this._surveyRunningPromiseResolve(completionCode); this._surveyRunningPromiseResolve(completionCode);
} }
_onFlowComplete () /**
* Callback triggered when we have run through the whole flow.
*
* @protected
*/
_onFlowComplete()
{ {
this.isFinished = true; this.isFinished = true;
this._onFinishedCallback(); this._onFinishedCallback();
@ -1042,32 +1050,41 @@ export class Survey extends VisualStim
return this._surveyRunningPromise; return this._surveyRunningPromise;
} }
async _runSurveyFlow(surveyBlock, surveyData, prevBlockResults = {}) /**
*
* @param node
* @param surveyData
* @param prevBlockResults
* @return {Promise<number>}
* @private
*/
async _runSurveyFlow(node, surveyData, prevBlockResults = {})
{ {
let nodeExitCode = Survey.NODE_EXIT_CODES.NORMAL; let nodeExitCode = Survey.NODE_EXIT_CODES.NORMAL;
if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.CONDITIONAL) if (node.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.CONDITIONAL)
{ {
const dataset = Object.assign({}, this._overallSurveyResults, this._variables); const dataset = Object.assign({}, this._overallSurveyResults, this._variables);
this._expressionsRunner.expressionExecutor.setExpression(surveyBlock.condition); this._expressionsRunner.expressionExecutor.setExpression(node.condition);
if (this._expressionsRunner.run(dataset) && surveyBlock.nodes[0] !== undefined) if (this._expressionsRunner.run(dataset) && node.nodes[0] !== undefined)
{ {
nodeExitCode = await this._runSurveyFlow(surveyBlock.nodes[0], surveyData, prevBlockResults); nodeExitCode = await this._runSurveyFlow(node.nodes[0], surveyData, prevBlockResults);
} }
else if (surveyBlock.nodes[1] !== undefined) else if (node.nodes[1] !== undefined)
{ {
nodeExitCode = await this._runSurveyFlow(surveyBlock.nodes[1], surveyData, prevBlockResults); nodeExitCode = await this._runSurveyFlow(node.nodes[1], surveyData, prevBlockResults);
} }
} }
else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.RANDOMIZER)
else if (node.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.RANDOMIZER)
{ {
util.shuffle(surveyBlock.nodes, Math.random, 0, surveyBlock.nodes.length - 1); util.shuffle(node.nodes, Math.random, 0, node.nodes.length - 1);
// this._InPlaceFisherYatesShuffle(surveyBlock.nodes, 0, surveyBlock.nodes.length - 1);
} }
else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.EMBEDDED_DATA)
else if (node.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.EMBEDDED_DATA)
{ {
let t = performance.now(); let t = performance.now();
const surveyBlockData = surveyData.embeddedData[surveyBlock.dataIdx]; const surveyBlockData = surveyData.embeddedData[node.dataIdx];
for (let j = 0; j < surveyBlockData.length; j++) for (let j = 0; j < surveyBlockData.length; j++)
{ {
// TODO: handle the rest data types. // TODO: handle the rest data types.
@ -1090,7 +1107,8 @@ export class Survey extends VisualStim
} }
console.log("embedded data variables accumulation took", performance.now() - t); console.log("embedded data variables accumulation took", performance.now() - t);
} }
else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.ENDSURVEY)
else if (node.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.ENDSURVEY)
{ {
if (this._surveyModel) if (this._surveyModel)
{ {
@ -1099,9 +1117,10 @@ export class Survey extends VisualStim
console.log("EndSurvey block encountered, exiting."); console.log("EndSurvey block encountered, exiting.");
nodeExitCode = Survey.NODE_EXIT_CODES.BREAK_FLOW; nodeExitCode = Survey.NODE_EXIT_CODES.BREAK_FLOW;
} }
else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.DIRECT)
else if (node.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.DIRECT)
{ {
const surveyCompletionCode = await this._beginSurvey(surveyData, surveyBlock); const surveyCompletionCode = await this._beginSurvey(surveyData, node);
Object.assign({}, prevBlockResults, this._surveyModel.data); Object.assign({}, prevBlockResults, this._surveyModel.data);
// SkipLogic had destination set to ENDOFSURVEY. // SkipLogic had destination set to ENDOFSURVEY.
@ -1111,13 +1130,14 @@ export class Survey extends VisualStim
} }
} }
// run through the children nodes of this node:
if (nodeExitCode === Survey.NODE_EXIT_CODES.NORMAL && if (nodeExitCode === Survey.NODE_EXIT_CODES.NORMAL &&
surveyBlock.type !== Survey.SURVEY_FLOW_PLAYBACK_TYPES.CONDITIONAL && node.type !== Survey.SURVEY_FLOW_PLAYBACK_TYPES.CONDITIONAL &&
surveyBlock.nodes instanceof Array) node.nodes instanceof Array)
{ {
for (let i = 0; i < surveyBlock.nodes.length; i++) for (const childNode of node.nodes)
{ {
nodeExitCode = await this._runSurveyFlow(surveyBlock.nodes[i], surveyData, prevBlockResults); nodeExitCode = await this._runSurveyFlow(childNode, surveyData, prevBlockResults);
if (nodeExitCode === Survey.NODE_EXIT_CODES.BREAK_FLOW) if (nodeExitCode === Survey.NODE_EXIT_CODES.BREAK_FLOW)
{ {
break; break;
@ -1125,9 +1145,9 @@ export class Survey extends VisualStim
} }
} }
if (surveyBlock.name === "root") // if we have just run through the top node, mark the whole flow as completed:
if (node.isRootNode)
{ {
// At this point we went through the entire survey flow tree.
this._onFlowComplete(); this._onFlowComplete();
} }

View File

@ -573,21 +573,16 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin)
{ {
this._needPixiUpdate = false; this._needPixiUpdate = false;
let enteredText = ""; // note: destroying _pixi will get rid of _pixi.text, which will, in turn, remove information about
// at this point this._pixi might exist but is removed from the scene, in such cases this._pixi.text // new lines etc., so we get a copy here, which we will restore on the new _pixi
// does not retain the information about new lines etc. so we go with a local copy of entered text const prevText = (this._pixi !== undefined && this._pixi.parent !== null) ? this._pixi.text : this._text;
if (this._pixi !== undefined && this._pixi.parent !== null) {
enteredText = this._pixi.text;
} else {
enteredText = this._text;
}
if (typeof this._pixi !== "undefined") if (typeof this._pixi !== "undefined")
{ {
this._pixi.destroy(true); this._pixi.destroy(true);
} }
// Create new TextInput // create a new TextInput
this._pixi = new TextInput(this._getTextInputOptions()); this._pixi = new TextInput(this._getTextInputOptions());
// listeners required for regular textboxes, but may cause problems with button stimuli // listeners required for regular textboxes, but may cause problems with button stimuli
@ -610,7 +605,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin)
} }
if (this._editable) if (this._editable)
{ {
this.text = enteredText; this.text = prevText;
this._pixi.placeholder = this._placeholder; this._pixi.placeholder = this._placeholder;
} }
else else