From c3a2b4b9f689dc43c14c2840e9b3531a37c876b0 Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Wed, 3 Jan 2024 09:35:14 +0100 Subject: [PATCH 1/4] progress with partial saving of results --- package.json | 2 +- src/core/GUI.js | 15 +++++++--- src/core/PsychoJS.js | 53 ++++++++++++++++++++++++++++++----- src/core/ServerManager.js | 32 +++++++++++++++++++++ src/data/ExperimentHandler.js | 6 ++-- src/util/PsychObject.js | 2 +- 6 files changed, 94 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 22526c9..7763518 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psychojs", - "version": "2023.2.3", + "version": "2024.4.1", "private": true, "description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments", "license": "MIT", diff --git a/src/core/GUI.js b/src/core/GUI.js index e3cc571..8a0241f 100644 --- a/src/core/GUI.js +++ b/src/core/GUI.js @@ -80,14 +80,17 @@ export class GUI * @param {Object} options.dictionary - associative array of values for the participant to set * @param {String} options.title - name of the project * @param {boolean} [options.requireParticipantClick=true] - whether the participant must click on the OK - * button, when it becomes enabled, to move on with the experiment + * button, when it becomes enabled, to move on with the experiment + * @param {boolean} [options.OKAlwaysEnabledForLocal=false] - whether the OK button is always enabled + * when the experiment runs locally */ DlgFromDict({ logoUrl, text, dictionary, title, - requireParticipantClick = GUI.DEFAULT_SETTINGS.DlgFromDict.requireParticipantClick + requireParticipantClick = GUI.DEFAULT_SETTINGS.DlgFromDict.requireParticipantClick, + OKAlwaysEnabledForLocal = true }) { this._progressBarMax = 0; @@ -96,6 +99,7 @@ export class GUI this._setRequiredKeys = new Map(); this._progressMessage = " "; this._requireParticipantClick = requireParticipantClick; + this._OKAlwaysEnabledForLocal = OKAlwaysEnabledForLocal; this._dictionary = dictionary; // prepare a PsychoJS component: @@ -276,7 +280,7 @@ export class GUI self._updateProgressBar(); // setup change event handlers for all required keys: - this._requiredKeys.forEach((keyId) => + self._requiredKeys.forEach((keyId) => { const input = document.getElementById(keyId); if (input) @@ -685,7 +689,10 @@ export class GUI if (typeof this._okButton !== "undefined") { // locally the OK button is always enabled, otherwise only if all requirements have been fulfilled: - if (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL || allRequirementsFulfilled) + if ( + (this._OKAlwaysEnabledForLocal && this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL) + || allRequirementsFulfilled + ) { this._okButton.classList.add("dialog-button"); this._okButton.classList.remove("disabled"); diff --git a/src/core/PsychoJS.js b/src/core/PsychoJS.js index c8ca9d1..e14b715 100644 --- a/src/core/PsychoJS.js +++ b/src/core/PsychoJS.js @@ -108,9 +108,9 @@ export class PsychoJS } /** - * @param {Object} options - * @param {boolean} [options.debug= true] whether to log debug information in the browser console - * @param {boolean} [options.collectIP= false] whether to collect the IP information of the participant + * @param {Object} options - options + * @param {boolean} [options.debug= true] - whether to log debug information in the browser console + * @param {boolean} [options.collectIP= false] - whether to collect the IP information of the participant */ constructor({ debug = true, @@ -186,7 +186,7 @@ export class PsychoJS this._saveResults = saveResults; this.logger.info("[PsychoJS] Initialised."); - this.logger.info("[PsychoJS] @version 2022.3.0"); + this.logger.info("[PsychoJS] @version 2024.1.0"); // hide the initialisation message: const root = document.getElementById("root"); @@ -399,9 +399,18 @@ export class PsychoJS { if (self._config.session.status === "OPEN") { + // stop the regular uploading of results, if need be: + if (self._config.experiment.resultsUpload.intervalId > 0) + { + clearInterval(self._config.experiment.resultsUpload.intervalId); + self._config.experiment.resultsUpload.intervalId = -1; + } + // save the incomplete results if need be: if (self._config.experiment.saveIncompleteResults && self._saveResults) { + // note: we set lastUploadTimestamp to undefined to prevent uploadData from throttling this call + delete self._config.experiment.resultsUpload.lastUploadTimestamp; self._experiment.save({ sync: true }); } @@ -414,6 +423,20 @@ export class PsychoJS self._window.close(); } }); + + // upload the data at regular interval, if need be: + if (self._saveResults && self._config.experiment.resultsUpload.period > 0) + { + self._config.experiment.resultsUpload.intervalId = setInterval(() => + { + self._experiment.save({ + tag: "", + clear: true + }); + }, + self._config.experiment.resultsUpload.period * 60 * 1000 + ); + } } // start the asynchronous download of resources: @@ -423,7 +446,7 @@ export class PsychoJS if (this._checkWebGLSupport && !Window.checkWebGLSupport()) { // add an entry to experiment results to warn the designer about a potential WebGL issue: - this._experiment.addData('hardware_acceleration', 'NOT SUPPORTED'); + this._experiment.addData("hardware_acceleration", "NOT SUPPORTED"); this._experiment.nextEntry(); this._gui.dialog({ @@ -519,9 +542,10 @@ export class PsychoJS *

Note: if the resource manager is busy, we inform the participant * that he or she needs to wait for a bit.

* - * @param {Object} options + * @param {Object} options - options * @param {string} [options.message] - optional message to be displayed in a dialog box before quitting * @param {boolean} [options.isCompleted = false] - whether the participant has completed the experiment + * @return {void} */ async quit({ message, isCompleted = false, closeWindow = true, showOK = true } = {}) { @@ -545,6 +569,14 @@ export class PsychoJS window.removeEventListener("beforeunload", this.beforeunloadCallback); } + // stop the regular uploading of results, if need be: + if (this._config.experiment.resultsUpload.intervalId > 0) + { + clearInterval(this._config.experiment.resultsUpload.intervalId); + this._config.experiment.resultsUpload.intervalId = -1; + } + delete this._config.experiment.resultsUpload.lastUploadTimestamp; + // save the results and the logs of the experiment: this.gui.finishDialog({ text: "Terminating the experiment. Please wait a few moments...", @@ -629,6 +661,7 @@ export class PsychoJS * @protected * @param {string} configURL - the URL of the configuration file * @param {string} name - the name of the experiment + * @return {void} */ async _configure(configURL, name) { @@ -701,10 +734,15 @@ export class PsychoJS name, saveFormat: ExperimentHandler.SaveFormat.CSV, saveIncompleteResults: true, - keys: [], + keys: [] }, }; } + // init the partial results upload options + this._config.experiment.resultsUpload = { + period: -1, + intervalId: -1 + }; // get the server parameters (those starting with a double underscore): this._serverMsg = new Map(); @@ -737,6 +775,7 @@ export class PsychoJS * *

Note: we use [http://www.geoplugin.net/json.gp]{@link http://www.geoplugin.net/json.gp}.

* @protected + * @return {void} */ async _getParticipantIPInfo() { diff --git a/src/core/ServerManager.js b/src/core/ServerManager.js index d522f6f..d01830e 100644 --- a/src/core/ServerManager.js +++ b/src/core/ServerManager.js @@ -58,6 +58,13 @@ export class ServerManager extends PsychObject this._nbLoadedResources = 0; this._setupPreloadQueue(); + // throttling period for calls to uploadData and uploadLog (in mn): + // note: (a) the period is potentially updated when a session is opened to reflect that associated with + // the experiment on the back-end database + // (b) throttling is also enforced on the back-end: artificially altering the period + // on the participant's browser will result in server errors + this._uploadThrottlePeriod = 5; + this._addAttribute("autoLog", autoLog); this._addAttribute("status", ServerManager.Status.READY); } @@ -194,6 +201,21 @@ export class ServerManager extends PsychObject self._psychoJS.config.experiment.keys = []; } + // partial results upload options: + if ("partialResultsUploadPeriod" in experiment) + { + // note: resultsUpload is initialised in PsychoJS._configure but we reinitialise it here + // all the same (belt and braces approach) + self._psychoJS.config.experiment.resultsUpload = { + period: experiment.partialResultsUploadPeriod, + intervalId: -1 + }; + } + if ("uploadThrottlePeriod" in experiment) + { + this._uploadThrottlePeriod = experiment.uploadThrottlePeriod; + } + self.setStatus(ServerManager.Status.READY); resolve({...response, token: openSessionResponse.token, status: openSessionResponse.status }); } @@ -783,6 +805,16 @@ export class ServerManager extends PsychObject }; this._psychoJS.logger.debug("uploading data for experiment: " + this._psychoJS.config.experiment.fullpath); + // 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) + ) + { + return Promise.reject({ ...response, error: "upload canceled by throttling"}); + } + this._psychoJS.config.experiment.resultsUpload.lastUploadTimestamp = now; + this.setStatus(ServerManager.Status.BUSY); const path = `experiments/${this._psychoJS.config.gitlab.projectId}/sessions/${this._psychoJS.config.session.token}/results`; diff --git a/src/data/ExperimentHandler.js b/src/data/ExperimentHandler.js index 97692b5..16f8ac1 100644 --- a/src/data/ExperimentHandler.js +++ b/src/data/ExperimentHandler.js @@ -266,7 +266,7 @@ export class ExperimentHandler extends PsychObject } } } - for (let a in this.extraInfo) + for (const a in this.extraInfo) { if (this.extraInfo.hasOwnProperty(a)) { @@ -303,7 +303,7 @@ export class ExperimentHandler extends PsychObject && !this._psychoJS._serverMsg.has("__pilotToken") ) { - return /*await*/ this._psychoJS.serverManager.uploadData(key, csv, sync); + return this._psychoJS.serverManager.uploadData(key, csv, sync); } else { @@ -320,7 +320,7 @@ export class ExperimentHandler extends PsychObject for (let r = 0; r < data.length; r++) { - let doc = { + const doc = { __projectId, __experimentName: this._experimentName, __participant: this._participant, diff --git a/src/util/PsychObject.js b/src/util/PsychObject.js index 9a29274..b33bba4 100644 --- a/src/util/PsychObject.js +++ b/src/util/PsychObject.js @@ -21,7 +21,7 @@ export class PsychObject extends EventEmitter { /** * @param {module:core.PsychoJS} psychoJS - the PsychoJS instance - * @param {string} name - the name of the object (mostly useful for debugging) + * @param {string} [name] - the name of the object (mostly useful for debugging) */ constructor(psychoJS, name) { From 51674ab073bc3c41060ca947a0928027719b9ceb Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Mon, 1 Apr 2024 08:51:27 +0200 Subject: [PATCH 2/4] various fixes and feature improvements --- package.json | 2 +- src/core/GUI.js | 42 ++++++++--- src/core/PsychoJS.js | 14 +++- src/core/ServerManager.js | 45 +++++++---- src/data/ExperimentHandler.js | 27 +++++-- src/data/Shelf.js | 101 +++++++++++++++++++++---- src/index.css | 70 +++++++++++------ src/visual/ButtonStim.js | 1 + src/visual/Survey.js | 136 +++++++++++++++++++--------------- src/visual/TextBox.js | 15 ++-- 10 files changed, 312 insertions(+), 141 deletions(-) diff --git a/package.json b/package.json index 7763518..4a73f7c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/GUI.js b/src/core/GUI.js index 8a0241f..2a606f4 100644 --- a/src/core/GUI.js +++ b/src/core/GUI.js @@ -187,13 +187,26 @@ export class GUI { atLeastOneIncludedKey = true; - markup += ``; - - // 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 += ``; // if value is an array, we create a select drop-down menu: if (Array.isArray(value)) @@ -240,7 +253,6 @@ export class GUI markup += "
"; // buttons: - markup += "
"; markup += "
"; markup += ""; if (self._requireParticipantClick) @@ -417,13 +429,13 @@ export class GUI markup += "
"; } - if (showOK || showCancel) - { - markup += "
"; - } + // if (showOK || showCancel) + // { + // markup += "
"; + // } if (showCancel || showOK) { - markup += "
"; + markup += "
"; if (showCancel) { markup += ""; @@ -493,11 +505,15 @@ export class GUI markup += "
"; markup += "
"; markup += `

Warning

`; + + markup += "
"; markup += `

${text}

`; + markup += "
"; // progress bar: markup += `
 
`; markup += "
"; + markup += "
"; markup += "
"; @@ -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) diff --git a/src/core/PsychoJS.js b/src/core/PsychoJS.js index e14b715..46e7f75 100644 --- a/src/core/PsychoJS.js +++ b/src/core/PsychoJS.js @@ -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, diff --git a/src/core/ServerManager.js b/src/core/ServerManager.js index d01830e..a08e365 100644 --- a/src/core/ServerManager.js +++ b/src/core/ServerManager.js @@ -134,7 +134,7 @@ export class ServerManager extends PsychObject * @property {Object.} [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 | 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"}); } diff --git a/src/data/ExperimentHandler.js b/src/data/ExperimentHandler.js index 16f8ac1..bf963c0 100644 --- a/src/data/ExperimentHandler.js +++ b/src/data/ExperimentHandler.js @@ -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.} [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(); diff --git a/src/data/Shelf.js b/src/data/Shelf.js index 4635a8d..31fcb7b 100644 --- a/src/data/Shelf.js +++ b/src/data/Shelf.js @@ -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. * diff --git a/src/index.css b/src/index.css index 8194d84..043f7b4 100644 --- a/src/index.css +++ b/src/index.css @@ -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; diff --git a/src/visual/ButtonStim.js b/src/visual/ButtonStim.js index 5b4d34f..c5ed8dd 100644 --- a/src/visual/ButtonStim.js +++ b/src/visual/ButtonStim.js @@ -71,6 +71,7 @@ export class ButtonStim extends TextBox win, name, text, + placeholder: text, font, pos, size, diff --git a/src/visual/Survey.js b/src/visual/Survey.js index 57bf41f..21eb96a 100644 --- a/src/visual/Survey.js +++ b/src/visual/Survey.js @@ -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", `
`) + document.body.insertAdjacentHTML("beforeend", `
`); } // 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} + * @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(); } diff --git a/src/visual/TextBox.js b/src/visual/TextBox.js index ab06378..6962361 100644 --- a/src/visual/TextBox.js +++ b/src/visual/TextBox.js @@ -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 From ff43ba400d44039b145446e34129eb2b38503c34 Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Thu, 4 Apr 2024 18:29:40 +0200 Subject: [PATCH 3/4] fixed issue with field options, e.g. |cfg, in GUI --- src/core/GUI.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/core/GUI.js b/src/core/GUI.js index 2a606f4..e9a4ea0 100644 --- a/src/core/GUI.js +++ b/src/core/GUI.js @@ -610,6 +610,12 @@ export class GUI const input = document.getElementById("form-input-" + keyIdx); if (input) { + // deal with field options: + if (key.slice(-4) === "|req" || key.slice(-4) === "|cfg" || key.slice(-4) === "|fix" || key.slice(-4) === "|opt") + { + delete this._dictionary[key]; + key = key.slice(0, -4); + } this._dictionary[key] = input.value; } }); From 8a6209b302e929033f458819733f34a1a8b77d7c Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Tue, 21 May 2024 14:20:48 +0200 Subject: [PATCH 4/4] corrected issue with saving of partial results --- src/core/PsychoJS.js | 2 +- src/data/ExperimentHandler.js | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/PsychoJS.js b/src/core/PsychoJS.js index 46e7f75..ba4eca2 100644 --- a/src/core/PsychoJS.js +++ b/src/core/PsychoJS.js @@ -437,7 +437,7 @@ export class PsychoJS { self._experiment.save({ tag: "", - clear: true + clear: false }); }, self._config.experiment.resultsUpload.period * 60 * 1000 diff --git a/src/data/ExperimentHandler.js b/src/data/ExperimentHandler.js index bf963c0..3695f6f 100644 --- a/src/data/ExperimentHandler.js +++ b/src/data/ExperimentHandler.js @@ -296,11 +296,15 @@ export class ExperimentHandler extends PsychObject // we need a header if it is asked for and there is actual data to save: const withHeader = this._isCsvHeaderNeeded && (data.length > 0); +/* INCORRECT: since new attributes can be added throughout the participant session, we need, currently, + to upload the whole result data, on each call to save. + // 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, {skipHeader: !withHeader});