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