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/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/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..9317060 100644
--- a/src/surveyFactory.jsx
+++ b/src/surveyFactory.jsx
@@ -11,11 +11,54 @@ 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
+ .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
+// 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));
+
+ // 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 +70,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 +102,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 +114,7 @@ export default function SurveyFactory(surveyName, surveyJson, scoreFunc, sha) {
surveyName,
responses,
result,
+ questionOrder,
secondsElapsed: (prevTimeSpent + newTimeSpent) / 1000,
};
@@ -78,9 +132,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;
}
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