From 8becde378f727c0065a69261d5313a1d5b5fd6ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 16:10:56 +0000 Subject: [PATCH 1/4] Initial plan From eb01660cba71c973b5e56380fb86b9c753f8cca0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 16:15:38 +0000 Subject: [PATCH 2/4] Initial exploration complete - creating implementation plan Co-authored-by: JamesPHoughton <4304478+JamesPHoughton@users.noreply.github.com> --- package-lock.json | 4 ++-- surveys/CRT/sha.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3c4e0ba..925e4f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@watts-lab/surveys", - "version": "1.19.0", + "version": "1.19.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@watts-lab/surveys", - "version": "1.19.0", + "version": "1.19.1", "license": "MIT", "dependencies": { "react": "^18.3.1", diff --git a/surveys/CRT/sha.json b/surveys/CRT/sha.json index 0ad727d..fa323e2 100644 --- a/surveys/CRT/sha.json +++ b/surveys/CRT/sha.json @@ -1 +1 @@ -{"survey":"4ae36797ea34ed7c4cb004447623e22d4a5315bc","score":"4df0f09da3993bdca1a11c534442042d2054d2b2"} \ No newline at end of file +{"survey":"063caac4425b739d6a89b605ed621d2abd2ebd6c","score":"4df0f09da3993bdca1a11c534442042d2054d2b2"} \ No newline at end of file From 6d9a9b43c95644f98b4023542fac31e325430a81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 16:22:38 +0000 Subject: [PATCH 3/4] Implement question order tracking for randomized surveys Co-authored-by: JamesPHoughton <4304478+JamesPHoughton@users.noreply.github.com> --- src/surveyFactory.cy.jsx | 146 +++++++++++++++++++++++++++++++++++++++ src/surveyFactory.jsx | 57 +++++++++++++-- 2 files changed, 199 insertions(+), 4 deletions(-) diff --git a/src/surveyFactory.cy.jsx b/src/surveyFactory.cy.jsx index 72a5030..4d5fade 100644 --- a/src/surveyFactory.cy.jsx +++ b/src/surveyFactory.cy.jsx @@ -36,6 +36,41 @@ const surveyJson = { ], }; +const surveyJsonWithRandomization = { + pages: [ + { + name: "page1", + elements: [ + { + type: "panel", + name: "randomPanel", + elements: [ + { + type: "radiogroup", + name: "q1", + title: "Question 1", + choices: ["yes", "no"], + }, + { + type: "radiogroup", + name: "q2", + title: "Question 2", + choices: ["yes", "no"], + }, + { + type: "radiogroup", + name: "q3", + title: "Question 3", + choices: ["yes", "no"], + }, + ], + questionsOrder: "random", + }, + ], + }, + ], +}; + const sha = { survey: "00fa3f25c5ad64eec5f82cb1fa87124c36e02e7c", score: "ba0f44e4945160b5327df9edc7bd5f840443f951", @@ -50,7 +85,14 @@ const dummy = { }; const Survey = SurveyFactory("testSurvey", surveyJson, scoreFunc, sha); +const SurveyWithRandomization = SurveyFactory( + "testSurveyRandom", + surveyJsonWithRandomization, + scoreFunc, + sha +); const storageName = "testLocalStorageKey"; +const randomStorageName = "testRandomStorageKey"; const stored = { currentPageNo: 0, @@ -180,4 +222,108 @@ describe("SurveyFactory", () => { expect(spyCall.secondsElapsed).to.be.closeTo(4, 1); }); }); + + it("stores question order in localStorage", () => { + cy.mount(); + + cy.get('[data-name="color"] input[value="blue"]').click({ + force: true, + }); + + cy.wait(500); + + cy.getLocalStorage(storageName).then((result) => { + const parsed = JSON.parse(result); + console.log("parsed storage", parsed); + expect(parsed).to.have.property("questionOrder"); + expect(parsed.questionOrder).to.be.an("array"); + expect(parsed.questionOrder).to.include("color"); + expect(parsed.questionOrder).to.include("openResponse"); + expect(parsed.questionOrder).to.include("name"); + expect(parsed.questionOrder.length).to.equal(3); + + // Check that surveyJson is stored with order preserved + expect(parsed).to.have.property("surveyJson"); + }); + }); + + it("includes question order in completion callback", () => { + cy.spy(dummy, "set").as("callback"); + cy.mount(); + + cy.get('[data-name="color"] input[value="blue"]').click({ + force: true, + }); + + cy.get("form") + .then(($form) => { + cy.wrap($form.find('input[type="button"][value="Complete"]')).click(); + }); + + cy.get("@callback").then((spy) => { + const spyCall = spy.getCall(-1).args[0]; + console.log("callback with question order", spyCall); + expect(spyCall).to.have.property("questionOrder"); + expect(spyCall.questionOrder).to.be.an("array"); + expect(spyCall.questionOrder).to.include("color"); + expect(spyCall.questionOrder).to.include("openResponse"); + expect(spyCall.questionOrder).to.include("name"); + }); + }); + + it("maintains randomized question order across re-renders", () => { + // First render - capture the order + cy.mount(); + + let firstOrder; + cy.getLocalStorage(randomStorageName).then((result) => { + // Wait for initial save + cy.wait(100); + }); + + cy.getLocalStorage(randomStorageName).then((result) => { + if (result) { + const parsed = JSON.parse(result); + firstOrder = parsed.questionOrder; + console.log("First render order:", firstOrder); + } + }); + + cy.get('[data-name="q1"] input[value="yes"]').click({ + force: true, + }); + + cy.wait(500); + + cy.getLocalStorage(randomStorageName).then((result) => { + const parsed = JSON.parse(result); + firstOrder = parsed.questionOrder; + console.log("Order after interaction:", firstOrder); + expect(firstOrder).to.be.an("array"); + expect(firstOrder.length).to.equal(3); + }); + + // Unmount and remount to simulate re-render + cy.mount(); + + cy.wait(500); + + // Get the visual order from the DOM + const secondOrderFromDOM = []; + cy.get(".sv-question.sv-row__question").each(($el) => { + cy.wrap($el) + .invoke("attr", "data-name") + .then((name) => { + secondOrderFromDOM.push(name); + }); + }); + + cy.wrap(secondOrderFromDOM).then((secondOrderFromDOM) => { + console.log("Second render order from DOM:", secondOrderFromDOM); + console.log("Expected order:", firstOrder); + + // The order should be the same as the first render + expect(JSON.stringify(secondOrderFromDOM)).to.equal(JSON.stringify(firstOrder)); + }); + }); }); diff --git a/src/surveyFactory.jsx b/src/surveyFactory.jsx index fdf1657..85816a8 100644 --- a/src/surveyFactory.jsx +++ b/src/surveyFactory.jsx @@ -11,11 +11,50 @@ import "./customQuestionTypes/labeledRange.css"; // timeSpent doesn't track properly across rerenders, so track it manually +// Helper function to extract question order from survey +function getQuestionOrder(surveyModel) { + const allQuestions = surveyModel.getAllQuestions(); + return allQuestions + .filter(q => q.getType() !== 'html') // exclude html elements + .map(q => q.name); +} + +// Helper function to set questionsOrder to 'initial' in all panels/pages to preserve order +function preserveQuestionOrder(surveyJson) { + const jsonCopy = JSON.parse(JSON.stringify(surveyJson)); + + // Recursively set questionsOrder to 'initial' for all pages and panels + function setInitialOrder(obj) { + if (obj && typeof obj === 'object') { + if (obj.questionsOrder === 'random') { + obj.questionsOrder = 'initial'; + } + // Recursively process nested objects and arrays + Object.keys(obj).forEach(key => { + if (Array.isArray(obj[key])) { + obj[key].forEach(item => setInitialOrder(item)); + } else if (typeof obj[key] === 'object') { + setInitialOrder(obj[key]); + } + }); + } + } + + setInitialOrder(jsonCopy); + return jsonCopy; +} + export default function SurveyFactory(surveyName, surveyJson, scoreFunc, sha) { function BuiltSurvey({ onComplete, storageName, language }) { const timerStartedAt = useRef(Date.now()); - const surveyModel = new SurveyJS.Model(surveyJson); + // Check if we have stored state with question order + var prevData = window.localStorage.getItem(storageName) || null; + var data = prevData ? JSON.parse(prevData) : null; + + // Use stored surveyJson if available (which has order preserved), otherwise use original + const jsonToUse = data?.surveyJson || surveyJson; + const surveyModel = new SurveyJS.Model(jsonToUse); surveyModel.locale = language; // set the language for the survey const saveState = useCallback( @@ -27,10 +66,17 @@ export default function SurveyFactory(surveyName, surveyJson, scoreFunc, sha) { const newTimeSpent = Date.now() - timerStartedAt.current; timerStartedAt.current = Date.now(); // reset timer + // Capture question order and preserve it in the survey JSON + const questionOrder = getQuestionOrder(survey); + const surveyJsonWithOrder = survey.toJSON(); + const preservedJson = preserveQuestionOrder(surveyJsonWithOrder); + var res = { currentPageNo: survey.currentPageNo, data: survey.data, timeSpent: prevTimeSpent + newTimeSpent, + questionOrder: questionOrder, + surveyJson: preservedJson, }; window.localStorage.setItem(storageName, JSON.stringify(res)); @@ -52,6 +98,9 @@ export default function SurveyFactory(surveyName, surveyJson, scoreFunc, sha) { const prevTimeSpent = data?.timeSpent || 0; const newTimeSpent = Date.now() - timerStartedAt.current; + // Get question order from stored state or capture it now + const questionOrder = data?.questionOrder || getQuestionOrder(sender); + const result = scoreFunc(responses); const record = { surveySource: packageJson["name"], @@ -61,6 +110,7 @@ export default function SurveyFactory(surveyName, surveyJson, scoreFunc, sha) { surveyName, responses, result, + questionOrder, secondsElapsed: (prevTimeSpent + newTimeSpent) / 1000, }; @@ -78,9 +128,8 @@ export default function SurveyFactory(surveyName, surveyJson, scoreFunc, sha) { return () => saveState(surveyModel); }, [saveState, scoreResponses, clearStorage]); - var prevData = window.localStorage.getItem(storageName) || null; - if (prevData) { - var data = JSON.parse(prevData); + // Restore previous data if available (already loaded jsonToUse at the top) + if (data) { surveyModel.currentPageNo = data.currentPageNo; surveyModel.data = data.data; } From b2a03be6f00feac6f2065b2abf2eddf3a5969b99 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Oct 2025 16:28:04 +0000 Subject: [PATCH 4/4] Add documentation and improve code comments Co-authored-by: JamesPHoughton <4304478+JamesPHoughton@users.noreply.github.com> --- README.md | 5 +++++ src/surveyFactory.jsx | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/README.md b/README.md index 36da74a..8fadf7c 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,14 @@ record = { rawScore: 6, normScore: 0.6, }, + questionOrder: ["Q1", "Q2"], // Visual order of questions (particularly useful for randomized surveys) + secondsElapsed: 10.5, + // ... other metadata }; ``` +Note: The `questionOrder` field captures the visual order in which questions were displayed to the user. This is particularly important for surveys with randomized question order, as it allows researchers to account for order effects in their analysis. For surveys with randomized questions, the order is preserved across page re-renders to ensure a consistent user experience. + ### Documentation A `README.md` file should be present in the directory, indicating: diff --git a/src/surveyFactory.jsx b/src/surveyFactory.jsx index 85816a8..9317060 100644 --- a/src/surveyFactory.jsx +++ b/src/surveyFactory.jsx @@ -12,6 +12,8 @@ import "./customQuestionTypes/labeledRange.css"; // timeSpent doesn't track properly across rerenders, so track it manually // Helper function to extract question order from survey +// Returns an array of question names in the order they appear visually in the survey +// Excludes HTML elements (like prompts) which are not actual questions function getQuestionOrder(surveyModel) { const allQuestions = surveyModel.getAllQuestions(); return allQuestions @@ -20,6 +22,8 @@ function getQuestionOrder(surveyModel) { } // Helper function to set questionsOrder to 'initial' in all panels/pages to preserve order +// When questionsOrder is 'random', SurveyJS randomizes the order on each render +// By changing it to 'initial', we preserve the current order for subsequent renders function preserveQuestionOrder(surveyJson) { const jsonCopy = JSON.parse(JSON.stringify(surveyJson));