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",
"version": "2024.4.1",
"version": "2024.1.0",
"private": true,
"description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments",
"license": "MIT",

View File

@ -187,13 +187,26 @@ export class GUI
{
atLeastOneIncludedKey = true;
markup += `<label for='${keyId}'> ${key} </label>`;
// if the field is required:
// deal with field options:
// - if the field is required:
if (key.slice(-4) === "|req")
{
key = `${key.slice(0, -4)}*`;
}
if (key.slice(-1) === "*")
{
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 (Array.isArray(value))
@ -240,7 +253,6 @@ export class GUI
markup += "<div class='progress-container'><div id='progressBar' class='progress-bar'></div></div>";
// buttons:
markup += "<hr>";
markup += "<div class='dialog-button-group'>";
markup += "<button id='dialogCancel' class='dialog-button' aria-label='Cancel Experiment'>Cancel</button>";
if (self._requireParticipantClick)
@ -417,13 +429,13 @@ export class GUI
markup += "</div>";
}
if (showOK || showCancel)
{
markup += "<hr>";
}
// if (showOK || showCancel)
// {
// markup += "<hr>";
// }
if (showCancel || showOK)
{
markup += "<div class='button-group'>";
markup += "<div class='dialog-button-group'>";
if (showCancel)
{
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-content'>";
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 += "</div>";
// progress bar:
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='dialog-button-group'></div>";
markup += "</div></div>";
@ -608,8 +624,11 @@ export class GUI
// clear all events (and keypresses) accumulated until now:
this._psychoJS.eventManager.clearEvents();
this._dialog.hide();
this._dialog = null;
if (this._dialog)
{
this._dialog.hide();
this._dialog = null;
}
this._dialogComponent.status = PsychoJS.Status.FINISHED;
}
@ -710,7 +729,6 @@ export class GUI
return;
}
// 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:
if (allRequirementsFulfilled)

View File

@ -144,8 +144,8 @@ export class PsychoJS
});
// add the pavlovia server to the list of hosts:
const hostsWithPavlovia = new Set([...hosts, "https://pavlovia.org/run/", "https://run.pavlovia.org/"]);
this._hosts = Array.from(hostsWithPavlovia);
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(pavloviaHosts);
// GUI:
this._gui = new GUI(this);
@ -166,6 +166,9 @@ export class PsychoJS
this._cancellationUrl = undefined;
this._completionUrl = undefined;
// survey id, if applicable:
this._surveyId = undefined;
// status:
this.status = PsychoJS.Status.NOT_CONFIGURED;
@ -377,6 +380,7 @@ export class PsychoJS
if (typeof surveyId !== "undefined")
{
params.surveyId = surveyId;
this._surveyId = surveyId;
}
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
delete self._config.experiment.resultsUpload.lastUploadTimestamp;
self._experiment.save({ sync: true });
self._experiment.save({
sync: true
});
}
// close the session:
@ -634,7 +640,7 @@ export class PsychoJS
if (showOK)
{
let text = "Thank you for your patience.";
let text = "Thank you for your patience. ";
text += (typeof message !== "undefined") ? message : "Goodbye!";
this._gui.dialog({
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
*/
/**
* 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
*
@ -236,10 +236,10 @@ export class ServerManager extends PsychObject
* 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} [sync= false] - whether or not to communicate with the server in a synchronous manner
* @param {boolean} [isCompleted= false] - whether the experiment was completed
* @param {boolean} [sync= false] - whether to communicate with the server in a synchronous manner
* @returns {Promise<ServerManager.CloseSessionPromise> | void} the response
*/
async closeSession(isCompleted = false, sync = false)
@ -260,6 +260,10 @@ export class ServerManager extends PsychObject
+ "/sessions/" + this._psychoJS.config.session.token + "/delete";
const formData = new FormData();
formData.append("isCompleted", isCompleted);
if (typeof this._psychoJS._surveyId !== "undefined")
{
formData.append("surveyId", this._psychoJS._surveyId);
}
navigator.sendBeacon(url, formData);
this._psychoJS.config.session.status = "CLOSED";
@ -272,10 +276,18 @@ export class ServerManager extends PsychObject
{
try
{
const data = {
isCompleted
};
if (typeof this._psychoJS._surveyId !== "undefined")
{
data["surveyId"] = this._psychoJS._surveyId;
}
const deleteResponse = await this._queryServerAPI(
"DELETE",
`experiments/${this._psychoJS.config.gitlab.projectId}/sessions/${this._psychoJS.config.session.token}`,
{ isCompleted },
data,
"FORM"
);
@ -591,18 +603,24 @@ export class ServerManager extends PsychObject
{
// add the SurveyJS and PsychoJS Survey .js and .css resources:
resources[r] = {
name: "jquery-3.6.0.min.js",
path: "./lib/vendors/jquery-3.6.0.min.js",
name: "jquery-3.5.1.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
};
resources.push({
name: "survey.jquery-1.9.50.min.js",
path: "./lib/vendors/survey.jquery-1.9.50.min.js",
name: "surveyjs.jquery-1.9.126.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
});
resources.push({
name: "survey.defaultV2-1.9.50.min.css",
path: "./lib/vendors/survey.defaultV2-1.9.50.min.css",
name: "surveyjs.defaultV2-1.9.126-OST.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
});
resources.push({
@ -807,9 +825,8 @@ export class ServerManager extends PsychObject
// data upload throttling:
const now = MonotonicClock.getReferenceTime();
if ( (typeof this._psychoJS.config.experiment.resultsUpload.lastUploadTimestamp !== "undefined") &&
(now - this._psychoJS.config.experiment.resultsUpload.lastUploadTimestamp < this._uploadThrottlePeriod * 60)
)
const checkThrottling = (typeof this._psychoJS.config.experiment.resultsUpload.lastUploadTimestamp !== "undefined");
if (checkThrottling && (now - this._psychoJS.config.experiment.resultsUpload.lastUploadTimestamp < this._uploadThrottlePeriod * 60))
{
return Promise.reject({ ...response, error: "upload canceled by throttling"});
}

View File

@ -97,6 +97,9 @@ export class ExperimentHandler extends PsychObject
this._trialsData = [];
this._currentTrialData = {};
// whether a header for the .csv result file is necessary:
this._isCsvHeaderNeeded = true;
this._experimentEnded = false;
}
@ -236,9 +239,9 @@ export class ExperimentHandler extends PsychObject
*
* @param {Object} options
* @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 {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({
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,
// 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
const worksheet = XLSX.utils.json_to_sheet(data);
// prepend BOM
const csv = "\ufeff" + XLSX.utils.sheet_to_csv(worksheet);
const worksheet = XLSX.utils.json_to_sheet(data, {skipHeader: !withHeader});
// note: start with a BOM if necessary
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:
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.
*
* @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 {string[]} options.key key as an array of key components
* @param {string[]} options.groups the names of the groups
* @param {number[]} options.groupSizes the size of the groups
* @return {Promise<{string, boolean}>} an object with the name of the selected group and whether all groups
* have been depleted
* @param {string[]} options.key key as an array of key components
* @return {Promise<{string, boolean, string}>} an object with the name of the selected group,
* whether all groups have been depleted, and a participant token
*/
async counterBalanceSelect({key, groups, groupSizes} = {})
async counterbalanceSelect({
key,
reserveTimeout
} = {})
{
const response = {
origin: 'Shelf.counterBalanceSelect',
context: `when getting the name of a group, using a counterbalanced design, with key: ${JSON.stringify(key)}`
origin: 'Shelf.counterbalanceSelect',
context: `when getting the name of a group, using a counterbalanced design with key: ${JSON.stringify(key)}`
};
try
{
await this._checkAvailability("counterBalanceSelect");
await this._checkAvailability("counterbalanceSelect");
this._checkKey(key);
// 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 = {
key,
groups,
groupSizes
participantToken,
confirmed
};
// query the server:
@ -563,7 +637,7 @@ export class Shelf extends PsychObject
throw ('error' in document) ? document.error : document;
}
// return the updated value:
// return the result:
this._status = Shelf.Status.READY;
return {
group: document.group,
@ -577,7 +651,6 @@ export class Shelf extends PsychObject
}
}
/**
* Update the value associated with the given key.
*

View File

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

View File

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

View File

@ -198,8 +198,8 @@ export class Survey extends VisualStim
model = {
surveys: [model],
embeddedData: [],
surveysMap: {},
questionMapsBySurvey: {},
// surveysMap: {},
// questionMapsBySurvey: {},
surveyFlow: {
name: "root",
type: "SEQUENTIAL_GROUP",
@ -211,7 +211,7 @@ export class Survey extends VisualStim
surveySettings: { showPrevButton: false },
surveyRunLogic: {},
// surveyRunLogic: {},
inQuestionRandomization: {},
questionsOrderRandomization: [],
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)}`);
}
// mark the root (top-most) node:
model.surveyFlow.isRootNode = true;
this._surveyData = model;
this._setAttribute("model", model, log);
this._onChange(true, true)();
@ -478,15 +481,12 @@ export class Survey extends VisualStim
// if a survey div does not exist, create it:
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:
if (typeof this._surveyData !== "undefined")
{
// this._startSurvey(surveyId, this._surveyModel);
// jQuery(`#${surveyId}`).Survey({model: this._surveyModel});
this._runSurveyFlow(this._surveyData.surveyFlow, this._surveyData);
}
}
@ -701,7 +701,6 @@ export class Survey extends VisualStim
_applyInQuestionRandomization (questionData, inQuestionRandomizationSettings, surveyData)
{
let t = performance.now();
let choicesFieldName;
let valueFieldName;
if (questionData.rows !== undefined)
@ -721,7 +720,7 @@ export class Survey extends VisualStim
}
else
{
console.log("[Survey runner]: Uknown choicesFieldName for", questionData);
console.log("[Survey runner]: Unknown choicesFieldName for", questionData);
}
if (inQuestionRandomizationSettings.randomizeAll)
@ -745,8 +744,7 @@ export class Survey extends VisualStim
let choicesMap = {};
// TODO: generalize further i.e. figure out how to calculate the length of array based on availability of sets.
const setIndices = [0, 0, 0];
let i;
for (i = 0; i < questionData[choicesFieldName].length; i++)
for (let i = 0; i < questionData[choicesFieldName].length; 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 shuffledSet1 = this._FisherYatesShuffle(inQuestionRandomizationSettings.set1);
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")
{
@ -797,53 +795,58 @@ export class Survey extends VisualStim
}
}
console.log("applying question randomization took", performance.now() - t);
// 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;
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.
// Hence creating a fresh survey data object with shuffled question order.
newSurveyModel = this._composeModelWithRandomizedQuestions(surveyData.surveys[surveyIdx], surveyData.questionsOrderRandomization[surveyIdx]);
newSurveyModel = this._composeModelWithRandomizedQuestions(
surveyData.surveys[surveyIdx],
surveyData.questionsOrderRandomization[surveyIdx]
);
}
// Checking if there's in-question randomization that needs to be applied.
for (i = 0; i < surveyData.surveys[surveyIdx].pages.length; i++)
// note: we need to check whether the survey model has a "pages" field since empty surveys do not:
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.
// TODO: think of somehting more optimal.
newSurveyModel = JSON.parse(JSON.stringify(surveyData.surveys[surveyIdx]));
if (typeof newSurveyModel === "undefined")
{
// 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.
newSurveyModel = surveyData.surveys[surveyIdx];
}
console.log("survey model preprocessing took", performance.now() - t);
return newSurveyModel;
}
@ -975,7 +978,12 @@ export class Survey extends VisualStim
this._surveyRunningPromiseResolve(completionCode);
}
_onFlowComplete ()
/**
* Callback triggered when we have run through the whole flow.
*
* @protected
*/
_onFlowComplete()
{
this.isFinished = true;
this._onFinishedCallback();
@ -1042,32 +1050,41 @@ export class Survey extends VisualStim
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;
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);
this._expressionsRunner.expressionExecutor.setExpression(surveyBlock.condition);
if (this._expressionsRunner.run(dataset) && surveyBlock.nodes[0] !== undefined)
this._expressionsRunner.expressionExecutor.setExpression(node.condition);
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);
// this._InPlaceFisherYatesShuffle(surveyBlock.nodes, 0, surveyBlock.nodes.length - 1);
util.shuffle(node.nodes, Math.random, 0, node.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();
const surveyBlockData = surveyData.embeddedData[surveyBlock.dataIdx];
const surveyBlockData = surveyData.embeddedData[node.dataIdx];
for (let j = 0; j < surveyBlockData.length; j++)
{
// 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);
}
else if (surveyBlock.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.ENDSURVEY)
else if (node.type === Survey.SURVEY_FLOW_PLAYBACK_TYPES.ENDSURVEY)
{
if (this._surveyModel)
{
@ -1099,9 +1117,10 @@ export class Survey extends VisualStim
console.log("EndSurvey block encountered, exiting.");
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);
// 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 &&
surveyBlock.type !== Survey.SURVEY_FLOW_PLAYBACK_TYPES.CONDITIONAL &&
surveyBlock.nodes instanceof Array)
node.type !== Survey.SURVEY_FLOW_PLAYBACK_TYPES.CONDITIONAL &&
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)
{
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();
}

View File

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