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:
parent
c3a2b4b9f6
commit
51674ab073
@ -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",
|
||||
|
@ -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'> </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();
|
||||
|
||||
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)
|
||||
|
@ -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:
|
||||
|
@ -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"});
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
* @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.
|
||||
*
|
||||
|
@ -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;
|
||||
width: calc(100% - 1em);
|
||||
height: 100%;
|
||||
width: auto;
|
||||
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;
|
||||
|
@ -71,6 +71,7 @@ export class ButtonStim extends TextBox
|
||||
win,
|
||||
name,
|
||||
text,
|
||||
placeholder: text,
|
||||
font,
|
||||
pos,
|
||||
size,
|
||||
|
@ -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,36 +795,41 @@ 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)
|
||||
{
|
||||
let t = performance.now();
|
||||
let i, j;
|
||||
let newSurveyModel = undefined;
|
||||
if (surveyData.questionsOrderRandomization[surveyIdx] !== undefined)
|
||||
{
|
||||
// Qualtrics's in-block randomization ignores presense of page breaks within the block.
|
||||
|
||||
// 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.
|
||||
newSurveyModel = this._composeModelWithRandomizedQuestions(surveyData.surveys[surveyIdx], surveyData.questionsOrderRandomization[surveyIdx]);
|
||||
if (typeof surveyData.questionsOrderRandomization[surveyIdx] !== "undefined")
|
||||
{
|
||||
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")
|
||||
{
|
||||
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 somehting more optimal.
|
||||
// TODO: think of something more optimal.
|
||||
newSurveyModel = JSON.parse(JSON.stringify(surveyData.surveys[surveyIdx]));
|
||||
}
|
||||
this._applyInQuestionRandomization(
|
||||
@ -837,13 +840,13 @@ export class Survey extends VisualStim
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,6 +978,11 @@ export class Survey extends VisualStim
|
||||
this._surveyRunningPromiseResolve(completionCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when we have run through the whole flow.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
_onFlowComplete()
|
||||
{
|
||||
this.isFinished = true;
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user