From c678107e2de0cd67f6a6031e59995b57125f4035 Mon Sep 17 00:00:00 2001 From: CJ Green <44074998+okaycj@users.noreply.github.com> Date: Mon, 4 Nov 2024 11:21:20 -0500 Subject: [PATCH] Exit Survey i18n (#97) * Rename exit survey file * Move exit json data to templates * Replace exit survey's text with translation keys * Incorporate node lts v22.x * Update dependencies * Add locale parameter and move survey text code to templates * Remove unneeded file * Update tests * Add function to index * These functions don't need to be exported * Add conditional for choices element * Update exit survey for tests and add tests * Github actions doesn't support v22 just yet. * Update to package lock * Fix test issue were v18 doesn't like "Object.defineProperty" * Changeset * SurveyJS locale (#98) * Remove unneeded survey-jquery package * Set surveyJS locale * Update plugin with new survey function * Remove print statement * Add tests for SurveyJS locale * Changeset * Add locale to consent survey * Update tests --- .changeset/khaki-bobcats-pump.md | 5 + .changeset/shy-cobras-flow.md | 6 + package-lock.json | 77 +++----- packages/record/package.json | 5 - packages/surveys/package.json | 4 +- packages/surveys/src/consentSurvey.ts | 17 +- packages/surveys/src/errors.ts | 7 + packages/surveys/src/exit.spec.ts | 132 -------------- packages/surveys/src/exit.ts | 160 ----------------- packages/surveys/src/exitSurvey.spec.ts | 5 + packages/surveys/src/exitSurvey.ts | 69 +++++++ packages/surveys/src/exit_json.ts | 78 -------- packages/surveys/src/index.spec.ts | 78 ++++---- packages/surveys/src/index.ts | 2 +- packages/surveys/src/utils.spec.ts | 103 +++++++---- packages/surveys/src/utils.ts | 56 ++++-- packages/templates/package.json | 4 + packages/templates/src/exitSurveyTemplate.ts | 180 +++++++++++++++++++ packages/templates/src/index.spec.ts | 18 +- packages/templates/src/index.ts | 3 +- 20 files changed, 491 insertions(+), 518 deletions(-) create mode 100644 .changeset/khaki-bobcats-pump.md create mode 100644 .changeset/shy-cobras-flow.md create mode 100644 packages/surveys/src/errors.ts delete mode 100644 packages/surveys/src/exit.spec.ts delete mode 100644 packages/surveys/src/exit.ts create mode 100644 packages/surveys/src/exitSurvey.spec.ts create mode 100644 packages/surveys/src/exitSurvey.ts delete mode 100644 packages/surveys/src/exit_json.ts create mode 100644 packages/templates/src/exitSurveyTemplate.ts diff --git a/.changeset/khaki-bobcats-pump.md b/.changeset/khaki-bobcats-pump.md new file mode 100644 index 00000000..fd118139 --- /dev/null +++ b/.changeset/khaki-bobcats-pump.md @@ -0,0 +1,5 @@ +--- +"@lookit/surveys": patch +--- + +Set SurveyJS Locale to trial's locale parameter. diff --git a/.changeset/shy-cobras-flow.md b/.changeset/shy-cobras-flow.md new file mode 100644 index 00000000..306f536b --- /dev/null +++ b/.changeset/shy-cobras-flow.md @@ -0,0 +1,6 @@ +--- +"@lookit/templates": patch +"@lookit/surveys": patch +--- + +Add localization to the exit survey trial diff --git a/package-lock.json b/package-lock.json index fcdb0ce0..ffbb3a37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5154,13 +5154,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/node-polyglot": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@types/node-polyglot/-/node-polyglot-2.5.0.tgz", - "integrity": "sha512-GY0UiBTWM3qclfdCs2BM1mwW9Hs5fSksNXeoTkeHHZ90pHXTtjvQ+fe9GR9NdqFhALBiD7CEAGIIqnQ4eQ5VEw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "dev": true, @@ -10024,9 +10017,9 @@ } }, "node_modules/i18next": { - "version": "23.15.1", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.15.1.tgz", - "integrity": "sha512-wB4abZ3uK7EWodYisHl/asf8UYEhrI/vj/8aoSsrj/ZDxj4/UXPOa1KvFt1Fq5hkUHquNqwFlDprmjZ8iySgYA==", + "version": "23.16.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.4.tgz", + "integrity": "sha512-9NIYBVy9cs4wIqzurf7nLXPyf3R78xYbxExVqHLK9od3038rjpyOEzW+XB130kZ1N4PZ9inTtJ471CRJ4Ituyg==", "dev": true, "funding": [ { @@ -11392,10 +11385,6 @@ "node": ">=10" } }, - "node_modules/jquery": { - "version": "3.7.1", - "license": "MIT" - }, "node_modules/js-tokens": { "version": "4.0.0", "dev": true, @@ -19200,13 +19189,6 @@ "version": "1.11.14", "license": "MIT" }, - "node_modules/survey-jquery": { - "version": "1.11.14", - "license": "MIT", - "dependencies": { - "jquery": ">=1.12.4" - } - }, "node_modules/survey-knockout-ui": { "version": "1.11.14", "license": "MIT", @@ -20832,12 +20814,7 @@ "@rollup/plugin-image": "^3.0.3", "@rollup/plugin-replace": "^6.0.1", "@types/audioworklet": "^0.0.60", - "@types/js-yaml": "^4.0.9", - "@types/node-polyglot": "^2.5.0", "handlebars": "^4.7.8", - "i18next": "^23.15.1", - "i18next-icu": "^2.3.0", - "js-yaml": "^4.1.0", "rollup-plugin-dotenv": "^0.5.1", "rollup-plugin-polyfill-node": "^0.13.0", "rollup-plugin-string-import": "^1.2.4", @@ -20871,26 +20848,6 @@ } } }, - "packages/record/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "packages/record/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "packages/record/node_modules/typescript": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", @@ -21050,8 +21007,7 @@ "dependencies": { "@jspsych/plugin-survey": "^2.0.0", "dompurify": "^3.0.11", - "marked": "^12.0.1", - "survey-jquery": "^1.9.136" + "marked": "^12.0.1" }, "devDependencies": { "@jspsych/config": "^2.0.0", @@ -21059,6 +21015,7 @@ }, "peerDependencies": { "@lookit/data": "^0.1.0", + "@lookit/templates": "^1.0.0", "jspsych": "^8.0.2" } }, @@ -21068,7 +21025,11 @@ "license": "ISC", "devDependencies": { "@jspsych/config": "^2.0.0", + "@types/js-yaml": "^4.0.9", "handlebars": "^4.7.8", + "i18next": "^23.16.4", + "i18next-icu": "^2.3.0", + "js-yaml": "^4.1.0", "rollup-plugin-dotenv": "^0.5.1", "rollup-plugin-polyfill-node": "^0.13.0" }, @@ -21076,6 +21037,26 @@ "@lookit/data": "^0.1.0", "jspsych": "^8.0.2" } + }, + "packages/templates/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "packages/templates/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } } } } diff --git a/packages/record/package.json b/packages/record/package.json index 22a692f8..1aef539c 100644 --- a/packages/record/package.json +++ b/packages/record/package.json @@ -34,12 +34,7 @@ "@rollup/plugin-image": "^3.0.3", "@rollup/plugin-replace": "^6.0.1", "@types/audioworklet": "^0.0.60", - "@types/js-yaml": "^4.0.9", - "@types/node-polyglot": "^2.5.0", "handlebars": "^4.7.8", - "i18next": "^23.15.1", - "i18next-icu": "^2.3.0", - "js-yaml": "^4.1.0", "rollup-plugin-dotenv": "^0.5.1", "rollup-plugin-polyfill-node": "^0.13.0", "rollup-plugin-string-import": "^1.2.4", diff --git a/packages/surveys/package.json b/packages/surveys/package.json index 5d2192ce..7b5e7c0e 100644 --- a/packages/surveys/package.json +++ b/packages/surveys/package.json @@ -27,8 +27,7 @@ "dependencies": { "@jspsych/plugin-survey": "^2.0.0", "dompurify": "^3.0.11", - "marked": "^12.0.1", - "survey-jquery": "^1.9.136" + "marked": "^12.0.1" }, "devDependencies": { "@jspsych/config": "^2.0.0", @@ -36,6 +35,7 @@ }, "peerDependencies": { "@lookit/data": "^0.1.0", + "@lookit/templates": "^1.0.0", "jspsych": "^8.0.2" } } diff --git a/packages/surveys/src/consentSurvey.ts b/packages/surveys/src/consentSurvey.ts index b3cd5064..c9eeea81 100644 --- a/packages/surveys/src/consentSurvey.ts +++ b/packages/surveys/src/consentSurvey.ts @@ -1,8 +1,19 @@ import SurveyPlugin from "@jspsych/plugin-survey"; -import { TrialType } from "jspsych"; +import { ParameterType, TrialType } from "jspsych"; import { consentSurveyFunction } from "./utils"; -type Info = typeof SurveyPlugin.info; +const info = { + ...SurveyPlugin.info, + parameters: { + ...SurveyPlugin.info.parameters, + locale: { + type: ParameterType.STRING, + default: "en-us", + }, + }, +}; + +type Info = typeof info; export type Trial = TrialType; /** Consent Survey plugin extends jsPsych's Survey Plugin. */ @@ -18,7 +29,7 @@ export class ConsentSurveyPlugin extends SurveyPlugin { public trial(display_element: HTMLElement, trial: Trial) { super.trial(display_element, { ...trial, - survey_function: consentSurveyFunction(trial.survey_function), + survey_function: consentSurveyFunction(trial), }); } /** diff --git a/packages/surveys/src/errors.ts b/packages/surveys/src/errors.ts new file mode 100644 index 00000000..14294f44 --- /dev/null +++ b/packages/surveys/src/errors.ts @@ -0,0 +1,7 @@ +/** Error thrown when trial is expecting locale parameter and on is not found. */ +export class TrialLocaleParameterUnset extends Error { + /** This will show when the locale is not set in a trial. */ + public constructor() { + super("Locale not set in trial parameters."); + } +} diff --git a/packages/surveys/src/exit.spec.ts b/packages/surveys/src/exit.spec.ts deleted file mode 100644 index 7860f1d8..00000000 --- a/packages/surveys/src/exit.spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { initJsPsych } from "jspsych"; -import { ExitSurveyPlugin, Trial as ExitTrial } from "./exit"; -import { names, surveyJSON } from "./exit_json"; - -afterEach(() => { - jest.clearAllMocks(); -}); - -test("Is Exit Survey's survey parameters calling the correct functions", () => { - const privateLevelOnly = jest.spyOn( - ExitSurveyPlugin.prototype, - "privateLevelOnly", - ); - const showDatabraryOptions = jest.spyOn( - ExitSurveyPlugin.prototype, - "showDatabraryOptions", - ); - const includeWithdrawalExample = jest.spyOn( - ExitSurveyPlugin.prototype, - "includeWithdrawalExample", - ); - const additionalVideoPrivacyText = jest.spyOn( - ExitSurveyPlugin.prototype, - "additionalVideoPrivacyText", - ); - const jsPsych = initJsPsych(); - const display_element = jest.fn() as unknown as HTMLElement; - const trialInfo = { - survey_function: jest.fn(), - survey_json: jest.fn(), - show_databrary_options: true, - } as unknown as ExitTrial; - const exit = new ExitSurveyPlugin(jsPsych); - - Object.defineProperty(global, "window", { - value: { - chs: { study: { attributes: { contact_info: jest.fn() } } }, - }, - }); - Object.defineProperty(global, "document", { - value: { - addEventListener: jest.fn(), - querySelector: jest.fn().mockReturnValue({ style: { display: "" } }), - }, - }); - - exit.trial(display_element, trialInfo); - - expect(privateLevelOnly).toHaveBeenCalledTimes(1); - expect(showDatabraryOptions).toHaveBeenCalledTimes(1); - expect(includeWithdrawalExample).toHaveBeenCalledTimes(1); - expect(additionalVideoPrivacyText).toHaveBeenCalledTimes(1); -}); - -test("Are the Databrary options removed?", () => { - ExitSurveyPlugin.prototype["showDatabraryOptions"]({ - show_databrary_options: true, - } as unknown as ExitTrial); - expect( - surveyJSON.pages[0].elements.find( - (element) => element.name === names.databraryShare, - ), - ).toBeDefined(); - - ExitSurveyPlugin.prototype["showDatabraryOptions"]({ - show_databrary_options: false, - } as unknown as ExitTrial); - - expect( - surveyJSON.pages[0].elements.find( - (element) => element.name === names.databraryShare, - ), - ).toBeUndefined(); -}); - -test("Is the private level only shown?", () => { - const useOfMedia = surveyJSON.pages[0].elements.find( - (el) => el.name === names.useOfMedia, - ); - - ExitSurveyPlugin.prototype["privateLevelOnly"]({ - private_level_only: false, - } as unknown as ExitTrial); - - expect(useOfMedia?.description).toBeUndefined(); - expect(useOfMedia?.defaultValue).toBeUndefined(); - - ExitSurveyPlugin.prototype["privateLevelOnly"]({ - private_level_only: true, - } as unknown as ExitTrial); - - expect(useOfMedia?.description).toEqual( - "Your video data is private and may only be viewed by authorized scientists.", - ); - expect(useOfMedia?.defaultValue).toEqual("private"); -}); - -test("Is additional privacy text shown?", () => { - const useOfMedia = surveyJSON.pages[0].elements.find( - (element) => element.name === names.useOfMedia, - ); - const additionalText = "some additional text"; - expect(useOfMedia?.description).toEqual( - "Your video data is private and may only be viewed by authorized scientists.", - ); - ExitSurveyPlugin.prototype["additionalVideoPrivacyText"]({ - additional_video_privacy_text: additionalText, - } as unknown as ExitTrial); - expect(useOfMedia?.description).toEqual(additionalText); -}); - -test("Is the withdrawal example included?", () => { - const withdrawal = surveyJSON.pages[0].elements.find( - (element) => element.name === names.withdrawal, - ); - - ExitSurveyPlugin.prototype["includeWithdrawalExample"]({ - include_withdrawal_example: false, - } as unknown as ExitTrial); - - expect(withdrawal?.choices[0].text).not.toContain("state secrets"); - - ExitSurveyPlugin.prototype["includeWithdrawalExample"]({ - include_withdrawal_example: true, - } as unknown as ExitTrial); - - expect(withdrawal?.choices[0].text).toContain("state secrets"); -}); - -test("Does exit survey return chsData correctly?", () => { - expect(ExitSurveyPlugin.chsData()).toMatchObject({ chs_type: "exit" }); -}); diff --git a/packages/surveys/src/exit.ts b/packages/surveys/src/exit.ts deleted file mode 100644 index a9e834c1..00000000 --- a/packages/surveys/src/exit.ts +++ /dev/null @@ -1,160 +0,0 @@ -import SurveyPlugin from "@jspsych/plugin-survey"; -import { LookitWindow } from "@lookit/data/dist/types"; -import { ParameterType, TrialType } from "jspsych"; -import { names, surveyJSON } from "./exit_json"; -import { exitSurveyFunction as survey_function } from "./utils"; - -declare let window: LookitWindow; - -const info = { - ...SurveyPlugin.info, - parameters: { - ...SurveyPlugin.info.parameters, - show_databrary_options: { - type: ParameterType.BOOL, - default: true, - pretty_name: "Show databrary options", - }, - include_withdrawal_example: { - type: ParameterType.BOOL, - default: true, - pretty_name: "Include withdrawal example", - }, - private_level_only: { - type: ParameterType.BOOL, - default: false, - pretty_name: "Private level only", - }, - additional_video_privacy_text: { - type: ParameterType.STRING, - default: "", - pretty_name: "Additional video privacy text", - }, - }, -}; - -type Info = typeof info; -export type Trial = TrialType; - -/** Exit Survey Plugin extending jsPsych's Survey Plugin. */ -export class ExitSurveyPlugin extends SurveyPlugin { - public static readonly info = info; - - /** - * Extended trial method supplied with parameters necessary for our Exit - * Survey. - * - * @param display_element - Display element. - * @param trial - Info parameters. - */ - public trial(display_element: HTMLElement, trial: TrialType) { - super.trial(display_element, { - ...trial, - survey_json: this.surveyParameters(trial), - survey_function, - }); - } - - /** - * Process all survey parameter functions. - * - * @param trial - Info parameters. - * @returns Survey JSON. - */ - private surveyParameters(trial: Trial) { - [ - this.showDatabraryOptions, - this.includeWithdrawalExample, - this.additionalVideoPrivacyText, - this.privateLevelOnly, - ].map((fn) => fn(trial)); - return surveyJSON; - } - - /** - * Alter survey to only show "private" on use of media question. - * - * @param trial - Info parameters. - */ - private privateLevelOnly(trial: Trial) { - if (trial.private_level_only) { - const media_use_element = surveyJSON.pages[0].elements.find( - (element) => element.name === names.useOfMedia, - ); - media_use_element && - Object.assign(media_use_element, { - defaultValue: "private", - description: - "Your video data is private and may only be viewed by authorized scientists.", - choicesVisibleIf: "false", // this must be a string expression - isRequired: false, - }); - } - } - - /** - * Alter survey to show Databrary options. - * - * @param trial - Info parameters. - */ - private showDatabraryOptions(trial: Trial) { - if (!trial.show_databrary_options) { - const survey_elements = surveyJSON.pages[0].elements; - const databrary_share_element_idx = survey_elements.findIndex( - (element) => element.name === names.databraryShare, - ); - survey_elements.splice(databrary_share_element_idx, 1); - } - } - - /** - * Alter survey to contain additional video privacy text. - * - * @param trial - Info parameters. - */ - private additionalVideoPrivacyText(trial: Trial) { - const element = surveyJSON.pages[0].elements.find( - (element) => element.name === names.useOfMedia, - ); - element && - Object.assign(element, { - description: trial.additional_video_privacy_text, - }); - } - - /** - * Include parenthetical example in withdrawal language. - * - * @param trial - Info parameters. - */ - private includeWithdrawalExample(trial: Trial) { - const study = window.chs.study; - const withdrawal_element = surveyJSON.pages[0].elements.find( - (element) => element.name === names.withdrawal, - ); - const example = trial.include_withdrawal_example - ? " (your spouse was discussing state secrets in the background, etc.)" - : ""; - - withdrawal_element && - Object.assign(withdrawal_element, { - choices: [ - { - text: `Every video helps us, even if something went wrong! However, if you need your video deleted${example}, check here to completely withdraw your video data from this session from the study. Only your consent video will be retained and it may only be viewed by Lookit project staff and researchers working with ${study.attributes.contact_info} on the study "${study.attributes.name}"; other video will be deleted without viewing.`, - value: true, - }, - ], - }); - } - - /** - * Add CHS type to experiment data. This will enable Lookit API to run the - * "exit" Frame Action Dispatcher method after the experiment has completed. - * It looks like jsPsych uses snake case for these data. - * - * @returns Object containing CHS type. - */ - public static chsData() { - return { chs_type: "exit" }; - } -} diff --git a/packages/surveys/src/exitSurvey.spec.ts b/packages/surveys/src/exitSurvey.spec.ts new file mode 100644 index 00000000..1a9ee4e4 --- /dev/null +++ b/packages/surveys/src/exitSurvey.spec.ts @@ -0,0 +1,5 @@ +import { ExitSurveyPlugin } from "./exitSurvey"; + +test("Does exit survey return chsData correctly?", () => { + expect(ExitSurveyPlugin.chsData()).toMatchObject({ chs_type: "exit" }); +}); diff --git a/packages/surveys/src/exitSurvey.ts b/packages/surveys/src/exitSurvey.ts new file mode 100644 index 00000000..ad372090 --- /dev/null +++ b/packages/surveys/src/exitSurvey.ts @@ -0,0 +1,69 @@ +import SurveyPlugin from "@jspsych/plugin-survey"; +import chsTemplates from "@lookit/templates"; +import { ParameterType, TrialType } from "jspsych"; +import { exitSurveyFunction } from "./utils"; + +const info = { + ...SurveyPlugin.info, + parameters: { + ...SurveyPlugin.info.parameters, + locale: { + type: ParameterType.STRING, + default: "en-us", + }, + show_databrary_options: { + type: ParameterType.BOOL, + default: true, + pretty_name: "Show databrary options", + }, + include_withdrawal_example: { + type: ParameterType.BOOL, + default: true, + pretty_name: "Include withdrawal example", + }, + private_level_only: { + type: ParameterType.BOOL, + default: false, + pretty_name: "Private level only", + }, + additional_video_privacy_text: { + type: ParameterType.STRING, + default: "", + pretty_name: "Additional video privacy text", + }, + }, +}; + +type Info = typeof info; +export type Trial = TrialType; + +/** Exit Survey Plugin extending jsPsych's Survey Plugin. */ +export class ExitSurveyPlugin extends SurveyPlugin { + public static readonly info = info; + + /** + * Extended trial method supplied with parameters necessary for our Exit + * Survey. + * + * @param display_element - Display element. + * @param trial - Info parameters. + */ + public trial(display_element: HTMLElement, trial: Trial) { + super.trial(display_element, { + ...trial, + survey_json: chsTemplates.exitSurvey(trial), + survey_function: exitSurveyFunction(trial), + }); + } + + /** + * Add CHS type to experiment data. This will enable Lookit API to run the + * "exit" Frame Action Dispatcher method after the experiment has completed. + * It looks like jsPsych uses snake case for these data. + * + * @returns Object containing CHS type. + */ + public static chsData() { + return { chs_type: "exit" }; + } +} diff --git a/packages/surveys/src/exit_json.ts b/packages/surveys/src/exit_json.ts deleted file mode 100644 index eede4692..00000000 --- a/packages/surveys/src/exit_json.ts +++ /dev/null @@ -1,78 +0,0 @@ -export const names = { - birthDate: "birthDate", - databraryShare: "databraryShare", - useOfMedia: "useOfMedia", - withdrawal: "withdrawal", - feedback: "feedback", -}; - -export const surveyJSON = { - pages: [ - { - elements: [ - { - description: - "We ask again just to check for typos during registration or accidental selection of a different child at the start of the study.", - inputType: "date", - isRequired: true, - maxValueExpression: "today()", - name: names.birthDate, - title: "Please confirm your child's birthdate.", - type: "text", - }, - { - description: - "Only authorized researchers will have access to information in the library. Researchers who are granted access must agree to maintain confidentiality and not use information for commercial purposes. Data sharing will lead to faster progress in research on human development and behavior. If you have any questions about the data-sharing library, please visit [Databrary](https://nyu.databrary.org/) or email ethics@databrary.org.", - enableIf: "({withdrawal} empty) or ({withdrawal.length} = 0)", - isRequired: true, - name: names.databraryShare, - title: - "Would you like to share your video and other data from this session with authorized users of the secure data library Databrary?", - type: "boolean", - valueFalse: "no", - valueTrue: "yes", - }, - { - choices: [ - { - text: "**Private**: Video may only be viewed by authorized scientists", - value: "private", - }, - { - text: "**Scientific and educational**: Video may be shared for scientific or educational purposes. For example, we might show a video clip in a talk at a scientific conference or an undergraduate class about cognitive development, or include an image or video in a scientific paper. In some circumstances, video or images may be available online, for instance as supplemental material in a scientific paper.", - value: "scientific", - }, - { - text: "**Publicity**: Please select this option if you'd be excited about seeing your child featured on the Lookit website or in a news article about this study! Your video may be shared for publicity as well as scientific and educational purposes; it will never be used for commercial purposes. Video clips shared may be available online to the general public.", - value: "public", - }, - ], - description: "", - enableIf: "({withdrawal} empty) or ({withdrawal.length} = 0)", - isRequired: true, - name: names.useOfMedia, - title: "Use of video clips and images:", - type: "radiogroup", - }, - { - choices: [], - defaultValue: [], - isRequired: false, - name: names.withdrawal, - title: "Withdrawal of video data", - type: "checkbox", - }, - { - autoGrow: true, - name: names.feedback, - rows: 3, - title: - "How did it go? Do you have any suggestions for improving the study?", - type: "comment", - }, - ], - name: "page1", - }, - ], - showQuestionNumbers: "off", -}; diff --git a/packages/surveys/src/index.spec.ts b/packages/surveys/src/index.spec.ts index bc801dae..5464bb5a 100644 --- a/packages/surveys/src/index.spec.ts +++ b/packages/surveys/src/index.spec.ts @@ -1,9 +1,12 @@ import SurveyPlugin from "@jspsych/plugin-survey"; +import { LookitWindow } from "@lookit/data/dist/types"; import { initJsPsych } from "jspsych"; import { Trial as ConsentTrial } from "./consentSurvey"; -import { Trial as ExitTrial } from "./exit"; +import { Trial as ExitTrial } from "./exitSurvey"; import Surveys from "./index"; -import { consentSurveyFunction, exitSurveyFunction } from "./utils"; +import { consentSurveyFunction } from "./utils"; + +declare const window: LookitWindow; jest.mock("@jspsych/plugin-survey"); jest.mock("jspsych"); @@ -11,8 +14,34 @@ jest.mock("./utils"); afterEach(() => { jest.clearAllMocks(); + chsData(); }); +/** + * Helper function to generate a trial object. + * + * @param values - Additonal paramters added to trial object + * @returns Trial object + */ +const getTrial = (values: Record = {}) => + ({ + locale: "en-US", + survey_function: jest.fn(), + survey_json: jest.fn(), + ...values, + }) as unknown as ExitTrial; + +/** + * Update chsData object for testing. + * + * @param values - Data added to global data object + */ +const chsData = (values: typeof window.chs = {}) => { + Object.assign(window, { + chs: values, + }); +}; + test("Consent Survey", () => { const jsPsych = initJsPsych(); const consent = new Surveys.ConsentSurveyPlugin(jsPsych); @@ -28,39 +57,14 @@ test("Consent Survey", () => { expect(SurveyPlugin.prototype.trial).toHaveBeenCalledTimes(1); }); -test("Exit Survey", () => { - Object.defineProperty(global, "window", { - value: { - chs: { study: { attributes: { contact_info: jest.fn() } } }, - }, - }); - - const exit = new Surveys.ExitSurveyPlugin(initJsPsych()); - const display_element = jest.fn() as unknown as HTMLElement; - const trialInfo = { - survey_function: jest.fn(), - survey_json: jest.fn(), - } as unknown as ExitTrial; - - exit.trial(display_element, trialInfo); - - expect(SurveyPlugin.prototype.trial).toHaveBeenCalledTimes(1); - expect(SurveyPlugin.prototype.trial).toHaveBeenCalledWith(display_element, { - ...trialInfo, - survey_function: exitSurveyFunction, - survey_json: - Surveys.ExitSurveyPlugin.prototype["surveyParameters"](trialInfo), - }); -}); - test("Exit Survey private level only", () => { const exit = new Surveys.ExitSurveyPlugin(initJsPsych()); const display_element = jest.fn() as unknown as HTMLElement; - const trialInfo = { - survey_function: jest.fn(), - survey_json: jest.fn(), - private_level_only: true, - } as unknown as ExitTrial; + chsData({ + study: { attributes: { contact_info: "contact info", name: "name" } }, + } as typeof window.chs); + + const trialInfo = getTrial({ private_level_only: true }); expect(exit.trial(display_element, trialInfo)).toBeUndefined(); expect(SurveyPlugin.prototype.trial).toHaveBeenCalledTimes(1); }); @@ -68,11 +72,11 @@ test("Exit Survey private level only", () => { test("Exit Survey include withdrawal example", () => { const exit = new Surveys.ExitSurveyPlugin(initJsPsych()); const display_element = jest.fn() as unknown as HTMLElement; - const trialInfo = { - survey_function: jest.fn(), - survey_json: jest.fn(), - include_withdrawal_example: true, - } as unknown as ExitTrial; + const trialInfo = getTrial({ include_withdrawal_example: true }); + chsData({ + study: { attributes: { contact_info: "contact info", name: "name" } }, + } as typeof window.chs); + expect(exit.trial(display_element, trialInfo)).toBeUndefined(); expect(SurveyPlugin.prototype.trial).toHaveBeenCalledTimes(1); }); diff --git a/packages/surveys/src/index.ts b/packages/surveys/src/index.ts index 5da29037..0248ff81 100644 --- a/packages/surveys/src/index.ts +++ b/packages/surveys/src/index.ts @@ -1,4 +1,4 @@ import { ConsentSurveyPlugin } from "./consentSurvey"; -import { ExitSurveyPlugin } from "./exit"; +import { ExitSurveyPlugin } from "./exitSurvey"; export default { ExitSurveyPlugin, ConsentSurveyPlugin }; diff --git a/packages/surveys/src/utils.spec.ts b/packages/surveys/src/utils.spec.ts index 7e08189d..f56f8c4a 100644 --- a/packages/surveys/src/utils.spec.ts +++ b/packages/surveys/src/utils.spec.ts @@ -1,4 +1,6 @@ import { Model } from "survey-jquery"; +import { TrialLocaleParameterUnset } from "./errors"; +import { Trial } from "./exitSurvey"; import { consentSurveyFunction, exitSurveyFunction, @@ -10,9 +12,35 @@ jest.mock("@lookit/data", () => ({ updateResponse: jest.fn().mockReturnValue("Response"), })); +/** + * Helper function to generate a trial object. + * + * @param values - Additonal paramters added to trial object + * @returns Trial object + */ +const getTrial = (values: Record = {}) => + ({ + locale: "en-US", + survey_function: jest.fn(), + ...values, + }) as unknown as Trial; + +/** + * Helper function to generate surveys for testing. + * + * @param values - Values to add to survey object + * @returns Survey + */ +const getSurvey = (values: Record = {}) => + ({ + onComplete: { add: jest.fn() }, + onTextMarkdown: { add: jest.fn() }, + ...values, + }) as unknown as Model; + test("Markdown to HTML through survey function", () => { const addMock = jest.fn(); - const survey = { onTextMarkdown: { add: addMock } } as unknown as Model; + const survey = getSurvey({ onTextMarkdown: { add: addMock } }); const textValue = "some text"; const options = { text: `**${textValue}**`, html: null }; const rtnSurvey = textMarkdownSurveyFunction(survey); @@ -26,11 +54,9 @@ test("Markdown to HTML through survey function", () => { }); test("Exit survey function", () => { - const survey = { - onComplete: { add: jest.fn() }, - onTextMarkdown: { add: jest.fn() }, - } as unknown as Model; - const rtnSurvey = exitSurveyFunction(survey); + const survey = getSurvey(); + const trial = getTrial(); + const rtnSurvey = exitSurveyFunction(trial)(survey); expect(survey.onComplete.add).toHaveBeenCalledTimes(1); expect(survey.onTextMarkdown.add).toHaveBeenCalledTimes(1); @@ -39,13 +65,11 @@ test("Exit survey function", () => { test("Anonymous function within exit survey function where withdrawal > 0", () => { const addMock = jest.fn(); - const survey = { - onComplete: { add: addMock }, - onTextMarkdown: { add: jest.fn() }, - } as unknown as Model; + const survey = getSurvey({ onComplete: { add: addMock } }); const sender = { setValue: jest.fn() }; + const trial = getTrial(); - exitSurveyFunction(survey); + exitSurveyFunction(trial)(survey); const anonFn = addMock.mock.calls[0][0]; @@ -58,13 +82,11 @@ test("Anonymous function within exit survey function where withdrawal > 0", () = test("Anonymous function within exit survey function where withdrawal is 0", () => { const addMock = jest.fn(); - const survey = { - onComplete: { add: addMock }, - onTextMarkdown: { add: jest.fn() }, - } as unknown as Model; + const survey = getSurvey({ onComplete: { add: addMock } }); const sender = { setValue: jest.fn() }; + const trial = getTrial(); - exitSurveyFunction(survey); + exitSurveyFunction(trial)(survey); const anonFn = addMock.mock.calls[0][0]; @@ -77,11 +99,9 @@ test("Anonymous function within exit survey function where withdrawal is 0", () }); test("Consent survey function", () => { - const survey = { - onComplete: { add: jest.fn() }, - onTextMarkdown: { add: jest.fn() }, - } as unknown as Model; - const survey_function = consentSurveyFunction(); + const survey = getSurvey(); + const trial = getTrial(); + const survey_function = consentSurveyFunction(trial); const rtnSurvey = survey_function(survey); expect(survey.onComplete.add).toHaveBeenCalledTimes(1); @@ -90,26 +110,21 @@ test("Consent survey function", () => { }); test("User function for consent survey function", () => { - const survey = { - onComplete: { add: jest.fn() }, - onTextMarkdown: { add: jest.fn() }, - } as unknown as Model; - const userFn = jest.fn(); - const survey_function = consentSurveyFunction(userFn); + const survey = getSurvey(); + const trial = getTrial(); + const survey_function = consentSurveyFunction(trial); survey_function(survey); - expect(userFn).toHaveBeenCalledTimes(1); + expect(trial.survey_function).toHaveBeenCalledTimes(1); }); test("Anonymous function within consent survey function", () => { const addMock = jest.fn(); - const survey = { - onComplete: { add: addMock }, - onTextMarkdown: { add: jest.fn() }, - } as unknown as Model; + const survey = getSurvey({ onComplete: { add: addMock } }); + const trial = getTrial(); - consentSurveyFunction()(survey); + consentSurveyFunction(trial)(survey); const anonFn = addMock.mock.calls[0][0]; @@ -120,3 +135,25 @@ test("Anonymous function within consent survey function", () => { expect(survey.onComplete.add).toHaveBeenCalledTimes(1); expect(survey.onTextMarkdown.add).toHaveBeenCalledTimes(1); }); + +test("Set SurveyJS locale parameter", () => { + const trial = getTrial(); + const survey = getSurvey(); + exitSurveyFunction(trial)(survey); + expect(survey.locale).toStrictEqual("en-US"); +}); + +test("Set SurveyJS locale parameter to French", () => { + const trial = getTrial({ locale: "fr" }); + const survey = getSurvey(); + exitSurveyFunction(trial)(survey); + expect(survey.locale).toStrictEqual(trial.locale); +}); + +test("Survey will throw error if locale is not set", () => { + const trial = getTrial({ locale: undefined }); + const survey = getSurvey(); + expect(() => exitSurveyFunction(trial)(survey)).toThrow( + TrialLocaleParameterUnset, + ); +}); diff --git a/packages/surveys/src/utils.ts b/packages/surveys/src/utils.ts index 91fb3638..d3343038 100644 --- a/packages/surveys/src/utils.ts +++ b/packages/surveys/src/utils.ts @@ -2,10 +2,16 @@ import Data from "@lookit/data"; import { LookitWindow } from "@lookit/data/dist/types"; import DOMPurify from "dompurify"; import { marked } from "marked"; -import { Model } from "survey-jquery"; +import { Model } from "survey-core"; +import "survey-core/survey.i18n"; +import { Trial as ConsentSurveyTrial } from "./consentSurvey"; +import { TrialLocaleParameterUnset } from "./errors"; +import { Trial as ExitSurveyTrial } from "./exitSurvey"; declare let window: LookitWindow; +type LocaleTrial = ConsentSurveyTrial | ExitSurveyTrial; + const CONFIG = { marked: { async: false }, dompurify: { USE_PROFILES: { html: true } }, @@ -28,6 +34,19 @@ export const textMarkdownSurveyFunction = (survey: Model) => { return survey; }; +/** + * Set locale parameter on SurveyJS Model. + * + * @param survey - SurveyJS model + * @param trial - Trial data including user supplied parameters. + */ +const setSurveyLocale = (survey: Model, trial: LocaleTrial) => { + if (!trial.locale) { + throw new TrialLocaleParameterUnset(); + } + survey.locale = new Intl.Locale(trial.locale).baseName; +}; + /** * Survey function used in exit survey. Adds markdown support through * "textMarkdownSurveyFunction". For the withdrawal checkbox question, this @@ -36,19 +55,22 @@ export const textMarkdownSurveyFunction = (survey: Model) => { * question type rather than boolean with "renderAs: checkbox" because the * latter doesn't allow both a question title and label next to the checkbox. * - * @param survey - Survey model provided by SurveyJS. + * @param trial - Trial data including user supplied parameters. * @returns Survey model. */ -export const exitSurveyFunction = (survey: Model) => { - textMarkdownSurveyFunction(survey); +export const exitSurveyFunction = + (trial: ExitSurveyTrial) => (survey: Model) => { + setSurveyLocale(survey, trial); + textMarkdownSurveyFunction(survey); - survey.onComplete.add((sender) => { - const trueFalseValue = - sender.getQuestionByName("withdrawal").value.length > 0; - sender.setValue("withdrawal", trueFalseValue); - }); - return survey; -}; + survey.onComplete.add((sender) => { + const trueFalseValue = + sender.getQuestionByName("withdrawal").value.length > 0; + sender.setValue("withdrawal", trueFalseValue); + }); + + return survey; + }; /** * Survey function used by Consent Survey. Adds markdown support through @@ -56,11 +78,12 @@ export const exitSurveyFunction = (survey: Model) => { * that consent was completed, and that the consent was through a survey (rather * than video). * - * @param userfn - Survey function provided by user. + * @param trial - Trial data including user supplied parameters. * @returns Survey model. */ -export const consentSurveyFunction = (userfn?: (x: Model) => Model) => { - return function (survey: Model) { +export const consentSurveyFunction = + (trial: ConsentSurveyTrial) => (survey: Model) => { + setSurveyLocale(survey, trial); textMarkdownSurveyFunction(survey); survey.onComplete.add(async () => { @@ -70,10 +93,9 @@ export const consentSurveyFunction = (userfn?: (x: Model) => Model) => { }); }); - if (typeof userfn === "function") { - userfn(survey); + if (typeof trial.survey_function === "function") { + trial.survey_function(survey); } return survey; }; -}; diff --git a/packages/templates/package.json b/packages/templates/package.json index 0ebbfd6f..6911066a 100644 --- a/packages/templates/package.json +++ b/packages/templates/package.json @@ -26,7 +26,11 @@ }, "devDependencies": { "@jspsych/config": "^2.0.0", + "@types/js-yaml": "^4.0.9", "handlebars": "^4.7.8", + "i18next": "^23.16.4", + "i18next-icu": "^2.3.0", + "js-yaml": "^4.1.0", "rollup-plugin-dotenv": "^0.5.1", "rollup-plugin-polyfill-node": "^0.13.0" }, diff --git a/packages/templates/src/exitSurveyTemplate.ts b/packages/templates/src/exitSurveyTemplate.ts new file mode 100644 index 00000000..7d161310 --- /dev/null +++ b/packages/templates/src/exitSurveyTemplate.ts @@ -0,0 +1,180 @@ +import { LookitWindow } from "@lookit/data/dist/types"; +import i18next from "i18next"; +import { PluginInfo, TrialType } from "jspsych"; +import { setLocale } from "./utils"; + +declare const window: LookitWindow; + +/** + * Class to augment and translate the exit survey JSON based on user set + * parameters. + */ +class ExitSurveyJson { + private names = { + birthDate: "birthDate", + databraryShare: "databraryShare", + useOfMedia: "useOfMedia", + withdrawal: "withdrawal", + feedback: "feedback", + }; + public survey = { + pages: [ + { + elements: [ + { + description: "exp-lookit-exit-survey.why-birthdate", + inputType: "date", + isRequired: true, + maxValueExpression: "today()", + name: this.names.birthDate, + title: "exp-lookit-exit-survey.confirm-birthdate", + type: "text", + }, + { + description: "exp-lookit-exit-survey.databrary-info", + enableIf: "({withdrawal} empty) or ({withdrawal.length} = 0)", + isRequired: true, + name: this.names.databraryShare, + title: "exp-lookit-exit-survey.q-databrary", + type: "boolean", + valueFalse: "no", + valueTrue: "yes", + }, + { + choices: [ + { + text: "exp-lookit-exit-survey.private-option-part-1", + value: "private", + }, + { + text: "exp-lookit-exit-survey.scientific-option", + value: "scientific", + }, + { + text: "exp-lookit-exit-survey.publicity-option", + value: "public", + }, + ], + description: "", + enableIf: "({withdrawal} empty) or ({withdrawal.length} = 0)", + isRequired: true, + name: this.names.useOfMedia, + title: "exp-lookit-exit-survey.acceptable-use-header", + type: "radiogroup", + }, + { + choices: [ + { + text: "exp-lookit-exit-survey.withdrawal-details", + value: true, + }, + ], + defaultValue: [], + isRequired: false, + name: this.names.withdrawal, + title: "exp-lookit-exit-survey.withdrawal-header", + type: "checkbox", + }, + { + autoGrow: true, + name: this.names.feedback, + rows: 3, + title: "exp-lookit-exit-survey.feedback-label", + type: "comment", + }, + ], + name: "page1", + }, + ], + showQuestionNumbers: "off", + }; + + /** + * Adjust survey json to meet user's parameters. + * + * @param trial - Trial data including user supplied parameters. + */ + public constructor(private trial: TrialType) { + this.showDatabraryOptions(); + this.additionalVideoPrivacyText(); + this.privateLevelOnly(); + this.translation(); + } + + /** Alter survey to show Databrary options. */ + private showDatabraryOptions() { + if (!this.trial.show_databrary_options) { + const survey_elements = this.survey.pages[0].elements; + const databrary_share_element_idx = survey_elements.findIndex( + (element) => element.name === this.names.databraryShare, + ); + survey_elements.splice(databrary_share_element_idx, 1); + } + } + + /** Alter survey to contain additional video privacy text. */ + private additionalVideoPrivacyText() { + const element = this.survey.pages[0].elements.find( + (element) => element.name === this.names.useOfMedia, + ); + element && + Object.assign(element, { + description: this.trial.additional_video_privacy_text, + }); + } + + /** Alter survey to only show "private" on use of media question. */ + private privateLevelOnly() { + if (this.trial.private_level_only) { + const media_use_element = this.survey.pages[0].elements.find( + (element) => element.name === this.names.useOfMedia, + ); + media_use_element && + Object.assign(media_use_element, { + defaultValue: "private", + description: "exp-lookit-exit-survey.private-option-part-1", + choicesVisibleIf: "false", // this must be a string expression + isRequired: false, + }); + } + } + + /** Translate the survey text. */ + private translation() { + const { contact_info, name } = window.chs.study.attributes; + const view = { + ...this.trial, + include_example: this.trial.include_withdrawal_example, + contact: contact_info, + name, + }; + + this.survey.pages[0].elements.forEach((element) => { + // Descriptions + element.description && + Object.assign(element, { + description: i18next.t(element.description, view), + }); + + // Titles + Object.assign(element, { title: i18next.t(element.title, view) }); + + // Choices + element.choices && + element.choices.forEach((choice) => { + Object.assign(choice, { text: i18next.t(choice.text, view) }); + }); + }); + } +} + +/** + * Translate survey text to desired locale. + * + * @param trial - Trial data including user supplied parameters. + * @returns Survey json + */ +export const exitSurvey = (trial: TrialType) => { + setLocale(trial); + return new ExitSurveyJson(trial).survey; +}; diff --git a/packages/templates/src/index.spec.ts b/packages/templates/src/index.spec.ts index fb27953f..8612d420 100644 --- a/packages/templates/src/index.spec.ts +++ b/packages/templates/src/index.spec.ts @@ -12,7 +12,7 @@ declare const window: LookitWindow; * @param values - Object to replace default trial values * @returns Trial object */ -const getTrial = (values: Record = {}) => { +const getTrial = (values: Record = {}) => { return { locale: "en-us", template: "consent-template-5", @@ -111,3 +111,19 @@ test("uploading video template in Portuguese", () => { "
enviando vídeo, por favor, aguarde...
", ); }); + +test("exit survey template", () => { + const trial = getTrial({ private_level_only: true }); + const survey = chsTemplate.exitSurvey(trial); + expect(survey.pages[0].elements[0].description).toStrictEqual( + "We ask again just to check for typos during registration or accidental selection of a different child at the start of the study.", + ); +}); + +test("exit survey template in French", () => { + const trial = getTrial({ locale: "fr" }); + const survey = chsTemplate.exitSurvey(trial); + expect(survey.pages[0].elements[0].description).toStrictEqual( + "Nous vous demandons à nouveau en cas d'erreur lors de l'enregistrement ou de sélection par erreur d'un enfant différent au début de l'étude.", + ); +}); diff --git a/packages/templates/src/index.ts b/packages/templates/src/index.ts index 29abd1e5..6f7125be 100644 --- a/packages/templates/src/index.ts +++ b/packages/templates/src/index.ts @@ -1,5 +1,6 @@ import { consentVideo } from "./consentVideoTemplate"; +import { exitSurvey } from "./exitSurveyTemplate"; import { uploadingVideo } from "./uploadingVideoTemplate"; import { videoConfig } from "./videoConfigTemplate"; -export default { consentVideo, videoConfig, uploadingVideo }; +export default { consentVideo, videoConfig, uploadingVideo, exitSurvey };