Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

146 changes: 146 additions & 0 deletions src/surveyFactory.cy.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -180,4 +222,108 @@ describe("SurveyFactory", () => {
expect(spyCall.secondsElapsed).to.be.closeTo(4, 1);
});
});

it("stores question order in localStorage", () => {
cy.mount(<Survey onComplete={dummy.set} storageName={storageName} />);

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(<Survey onComplete={dummy.set} storageName={storageName} />);

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(<SurveyWithRandomization onComplete={dummy.set} storageName={randomStorageName} />);

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(<SurveyWithRandomization onComplete={dummy.set} storageName={randomStorageName} />);

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));
});
});
});
61 changes: 57 additions & 4 deletions src/surveyFactory.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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));
Expand All @@ -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"],
Expand All @@ -61,6 +114,7 @@ export default function SurveyFactory(surveyName, surveyJson, scoreFunc, sha) {
surveyName,
responses,
result,
questionOrder,
secondsElapsed: (prevTimeSpent + newTimeSpent) / 1000,
};

Expand All @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion surveys/CRT/sha.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"survey":"4ae36797ea34ed7c4cb004447623e22d4a5315bc","score":"4df0f09da3993bdca1a11c534442042d2054d2b2"}
{"survey":"063caac4425b739d6a89b605ed621d2abd2ebd6c","score":"4df0f09da3993bdca1a11c534442042d2054d2b2"}