diff --git a/package.json b/package.json index bc2b957736..60d1c5cddd 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "generate-schemas": "yarn generate-schema:StudyConfig && yarn generate-schema:GlobalConfig && yarn generate-schema:LibraryConfig", "test": "playwright test", "unittest": "vitest", - "preinstall": "node -e 'if(!/yarn\\.js$/.test(process.env.npm_execpath))throw new Error(\"Use yarn\")'", + "preinstall": "node -e \"if(!/yarn\\.js$/.test(process.env.npm_execpath))throw new Error('Use yarn')\"", "postinstall": "husky" }, "lint-staged": { diff --git a/public/demo-html/config.json b/public/demo-html/config.json index 1b95f7cd2d..ea6d2248d4 100644 --- a/public/demo-html/config.json +++ b/public/demo-html/config.json @@ -3,16 +3,10 @@ "studyMetadata": { "title": "HTML as a Stimulus", "version": "pilot", - "authors": [ - "The reVISit Team" - ], + "authors": ["The reVISit Team"], "date": "2023-04-14", "description": "A simple demo of using stimuli in an HTML file that renders a D3 visualization. Data is collected via a numeric response field.", - "organizations": [ - "University of Utah", - "WPI", - "University of Toronto" - ] + "organizations": ["University of Utah", "WPI", "University of Toronto"] }, "uiConfig": { "contactEmail": "contact@revisit.dev", @@ -23,7 +17,29 @@ "withSidebar": true, "windowEventDebounceTime": 200, "minHeightSize": 800, - "minWidthSize": 400 + "minWidthSize": 400, + "browserRules": { + "allowed": [ + { + "name": "chrome", + "minVersion": 100 + }, + { + "name": "firefox", + "minVersion": 100 + }, + { + "name": "safari", + "minVersion": 10 + } + ] + }, + "deviceRules": { + "allowed": ["tablet", "desktop"] + }, + "inputRules": { + "allowed": ["touch", "mouse"] + } }, "components": { "introduction": { @@ -61,10 +77,6 @@ }, "sequence": { "order": "fixed", - "components": [ - "introduction", - "barChart", - "external_website" - ] + "components": ["introduction", "barChart", "external_website"] } } diff --git a/public/demo-survey/config.json b/public/demo-survey/config.json index 3f2c8129f3..c4933fd536 100644 --- a/public/demo-survey/config.json +++ b/public/demo-survey/config.json @@ -210,8 +210,11 @@ "Option 2", "Option 3", "Option 4" - ], - "withDivider": true + ] + }, + { + "id": "divider1", + "type": "divider" }, { "id": "matrixHeaderTitle", @@ -227,8 +230,11 @@ "type": "likert", "numItems": 9, "rightLabel": "Like", - "leftLabel": "Dislike", - "withDivider": true + "leftLabel": "Dislike" + }, + { + "id": "divider2", + "type": "divider" }, { "id": "q-multi-satisfaction", @@ -382,7 +388,8 @@ { "id": "textField", "type": "textOnly", - "prompt": "# Randomizing Questions in a Form\n\n This shows how to randomize the order of questions in the form. Notice how the number before each question is different from the order number specified in the text. Note that currently the title is also randomized; you can avoid that by putting the title in a markdown file and adding the questions as a response." + "prompt": "# Randomizing Questions in a Form\n\n This shows how to randomize the order of questions in the form. Notice how the number before each question is different from the order number specified in the text. Note that currently the title is not randomized; you can exclude a form element from being randomized if you need to.", + "excludeFromRandomization": true }, { "id": "q-dropdown", @@ -645,7 +652,11 @@ "Option 2", "Option 3" ], - "withDivider": true, + "location": "sidebar" + }, + { + "id": "divider", + "type": "divider", "location": "sidebar" }, { diff --git a/public/tutorial/_answers/config.json b/public/tutorial/_answers/config.json index 0e41e43163..ada3f46f71 100644 --- a/public/tutorial/_answers/config.json +++ b/public/tutorial/_answers/config.json @@ -58,8 +58,12 @@ "secondaryText": "1 being the worst health and 5 being the best health", "numItems": 5, "rightLabel": "Best health", - "leftLabel": "Worst health", - "withDivider": true + "leftLabel": "Worst health" + }, + { + "id": "dividerResponse", + "type": "divider", + "location": "belowStimulus" }, { "id": "fruits", diff --git a/src/analysis/individualStudy/summary/ResponseStats.tsx b/src/analysis/individualStudy/summary/ResponseStats.tsx index fb7b448eae..8ba31c394c 100644 --- a/src/analysis/individualStudy/summary/ResponseStats.tsx +++ b/src/analysis/individualStudy/summary/ResponseStats.tsx @@ -30,7 +30,7 @@ export function ResponseStats({ visibleParticipants, studyConfig }: { visiblePar data.push({ component: stat.name, type: response.type, - question: response.prompt, + question: response.prompt || '', options: getResponseOptions(response), correctness: correctnessStr, }); diff --git a/src/analysis/individualStudy/summary/SummaryView.tsx b/src/analysis/individualStudy/summary/SummaryView.tsx index 140469f5a1..98bb0b47e4 100644 --- a/src/analysis/individualStudy/summary/SummaryView.tsx +++ b/src/analysis/individualStudy/summary/SummaryView.tsx @@ -51,7 +51,7 @@ export function SummaryView({ visibleParticipants, studyConfig }: { data.push({ component: stat.name, type: response.type, - question: response.prompt, + question: response.prompt || '', options: getResponseOptions(response), correctness: correctnessStr, }); diff --git a/src/components/StepRenderer.tsx b/src/components/StepRenderer.tsx index 8316516f54..eee3d2ed0c 100644 --- a/src/components/StepRenderer.tsx +++ b/src/components/StepRenderer.tsx @@ -23,6 +23,7 @@ import { ResolutionWarning } from './interface/ResolutionWarning'; import { useFetchStylesheet } from '../utils/fetchStylesheet'; import { ScreenRecordingContext, useScreenRecording } from '../store/hooks/useScreenRecording'; import { ScreenRecordingRejection } from './interface/ScreenRecordingRejection'; +import { DeviceWarning } from './interface/DeviceWarning'; export function StepRenderer() { const windowEvents = useRef([]); @@ -148,6 +149,7 @@ export function StepRenderer() { {showTitleBar && ( )} + {isScreenRecordingUserRejected && } diff --git a/src/components/interface/AlertModal.tsx b/src/components/interface/AlertModal.tsx index fb593747a9..da26329a3f 100644 --- a/src/components/interface/AlertModal.tsx +++ b/src/components/interface/AlertModal.tsx @@ -11,7 +11,7 @@ export function AlertModal() { const storeDispatch = useStoreDispatch(); const [opened, setOpened] = useState(alertModal.show); - const close = useCallback(() => storeDispatch(setAlertModal({ ...alertModal, show: false })), [alertModal, setAlertModal, storeDispatch]); + const close = useCallback(() => storeDispatch(setAlertModal({ ...alertModal, show: false, title: '' })), [alertModal, setAlertModal, storeDispatch]); useEffect(() => setOpened(alertModal.show), [alertModal.show]); @@ -20,7 +20,7 @@ export function AlertModal() { } onClose={close} styles={{ root: { backgroundColor: 'unset' } }} diff --git a/src/components/interface/AppHeader.tsx b/src/components/interface/AppHeader.tsx index 9e4ddaec6a..9d5de62220 100644 --- a/src/components/interface/AppHeader.tsx +++ b/src/components/interface/AppHeader.tsx @@ -121,6 +121,18 @@ export function AppHeader({ studyNavigatorEnabled, dataCollectionEnabled }: { st } }, [answers, flatSequence, studyConfig, currentStep, storageEngine, dataCollectionEnabled, funcIndex]); + // Check if we have issues connecting to the database, if so show alert modal + const { setAlertModal } = useStoreActions(); + const [firstMount, setFirstMount] = useState(true); + if (storageEngineFailedToConnect && firstMount) { + storeDispatch(setAlertModal({ + show: true, + message: `You may be behind a firewall blocking access, or the server collecting data may be down. Study data will not be saved. If you're taking the study you will not be compensated for your efforts. You are welcome to look around. If you are attempting to participate in the study, please email ${studyConfig.uiConfig.contactEmail} for assistance.`, + title: 'Failed to connect to the storage engine', + })); + setFirstMount(false); + } + return ( diff --git a/src/components/interface/DeviceWarning.tsx b/src/components/interface/DeviceWarning.tsx new file mode 100644 index 0000000000..a68af2be52 --- /dev/null +++ b/src/components/interface/DeviceWarning.tsx @@ -0,0 +1,120 @@ +import { + Modal, Text, Title, Stack, List, + Card, + Flex, +} from '@mantine/core'; +import { + IconAlertTriangle, IconBrowser, IconDevices, IconHandClick, +} from '@tabler/icons-react'; +import { useStudyConfig } from '../../store/hooks/useStudyConfig'; +import { useDeviceRules } from '../../store/hooks/useDeviceRules'; + +export function DeviceWarning() { + const studyConfig = useStudyConfig(); + + const { browserRules, deviceRules, inputRules } = studyConfig.uiConfig; + + const { isBrowserAllowed, isDeviceAllowed, isInputAllowed } = useDeviceRules(studyConfig.uiConfig); + + if (isBrowserAllowed && isDeviceAllowed && isInputAllowed) { + return null; + } + + return ( + {}} fullScreen withCloseButton={false}> + + + Browser or Device Not Supported + + + {!isBrowserAllowed && ( + + + + + {browserRules?.blockedMessage + ? ( + + {browserRules.blockedMessage} + + ) : ( + <> + + This study only works in the following browser(s): + + + {browserRules?.allowed.map((browser, idx) => ( + + {browser.name} + {browser.minVersion && ` v${browser.minVersion} or later`} + + ))} + + + )} + + )} + + {!isDeviceAllowed && ( + + + + + {deviceRules?.blockedMessage + ? ( + + {deviceRules.blockedMessage} + + ) : ( + <> + + This study only works in the following device(s): + + + {deviceRules?.allowed.map((device, idx) => ( + + {device} + + ))} + + + )} + + )} + + {!isInputAllowed && ( + + + + + {inputRules?.blockedMessage + ? ( + + {inputRules.blockedMessage} + + ) : ( + <> + + This study only works on devices that support following input type(s): + + + {inputRules?.allowed.map((input, idx) => ( + + {input} + + ))} + + + )} + + )} + + + Please reopen the study link in one of the browsers/device listed above. +
+ Thank you for your understanding! +
+
+
+ ); +} diff --git a/src/components/response/ResponseBlock.tsx b/src/components/response/ResponseBlock.tsx index a7986dcd96..bac709bf92 100644 --- a/src/components/response/ResponseBlock.tsx +++ b/src/components/response/ResponseBlock.tsx @@ -75,7 +75,7 @@ export function ResponseBlock({ const responses = useMemo(() => allResponses.filter((r) => (r.location ? r.location === location : location === 'belowStimulus')), [allResponses, location]); const responsesWithDefaults = useMemo(() => responses.map((response) => { - if (response.type !== 'textOnly') { + if (response.type !== 'textOnly' && response.type !== 'divider') { return { ...response, required: response.required === undefined ? true : response.required, @@ -85,7 +85,7 @@ export function ResponseBlock({ }), [responses]); const allResponsesWithDefaults = useMemo(() => allResponses.map((response) => { - if (response.type !== 'textOnly') { + if (response.type !== 'textOnly' && response.type !== 'divider') { return { ...response, required: response.required === undefined ? true : response.required, diff --git a/src/components/response/ResponseSwitcher.tsx b/src/components/response/ResponseSwitcher.tsx index e545995c78..6c4adecb1f 100644 --- a/src/components/response/ResponseSwitcher.tsx +++ b/src/components/response/ResponseSwitcher.tsx @@ -254,7 +254,7 @@ export function ResponseSwitcher({ onChange={(event) => { dontKnowCheckbox?.onChange(event.currentTarget.checked); form.onChange(fieldInitialValue); }} /> )} - {responseDividers && } + {(response.type === 'divider' || responseDividers) && } ); } diff --git a/src/controllers/ComponentController.tsx b/src/controllers/ComponentController.tsx index 76dfc3a99e..994439f200 100644 --- a/src/controllers/ComponentController.tsx +++ b/src/controllers/ComponentController.tsx @@ -94,6 +94,7 @@ export function ComponentController() { storeDispatch(setAlertModal({ show: true, message: `There was an issue connecting to the ${import.meta.env.VITE_STORAGE_ENGINE} database. This could be caused by a network issue or your adblocker. If you are using an adblocker, please disable it for this website and refresh.`, + title: 'Failed to connect to the storage engine', })); } }, [setAlertModal, storageEngine, storeDispatch]); diff --git a/src/parser/LibraryConfigSchema.json b/src/parser/LibraryConfigSchema.json index 573c9406ea..9a09cca381 100644 --- a/src/parser/LibraryConfigSchema.json +++ b/src/parser/LibraryConfigSchema.json @@ -245,6 +245,10 @@ "additionalProperties": false, "description": "The ButtonsResponse interface is used to define the properties of a buttons response. ButtonsResponses render as a list of buttons that the participant can click. When a button is clicked, the value of the button is stored in the data file. Participants can cycle through the options using the arrow keys.\n\nExample: ```js { \"id\": \"buttonsResponse\", \"type\": \"buttons\", \"prompt\": \"Click a button\", \"location\": \"belowStimulus\", \"options\": [ \"Option 1\", \"Option 2\", \"Option 3\" ] } ``` In this example, the participant can click one of the buttons labeled \"Option 1\", \"Option 2\", or \"Option 3\".", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -338,6 +342,10 @@ "additionalProperties": false, "description": "The CheckboxResponse interface is used to define the properties of a checkbox response. CheckboxResponses render as a checkbox input with user specified options.\n\n```js { \"id\": \"q7\", \"prompt\": \"Checkbox example (not required)\", \"location\": \"aboveStimulus\", \"type\": \"checkbox\", \"options\": [\"Option 1\", \"Option 2\", \"Option 3\"] } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -569,10 +577,75 @@ ], "type": "object" }, + "DividerResponse": { + "additionalProperties": false, + "properties": { + "hidden": { + "not": {} + }, + "id": { + "description": "The id of the response. This is used to identify the response in the data file.", + "type": "string" + }, + "infoText": { + "not": {} + }, + "location": { + "$ref": "#/definitions/ConfigResponseBlockLocation", + "description": "Controls the response location. These might be the same for all responses, or differ across responses. Defaults to `belowStimulus`" + }, + "paramCapture": { + "not": {} + }, + "prompt": { + "not": {} + }, + "required": { + "not": {} + }, + "requiredLabel": { + "not": {} + }, + "requiredValue": { + "not": {} + }, + "secondaryText": { + "not": {} + }, + "style": { + "$ref": "#/definitions/Styles", + "description": "You can set styles here, using React CSSProperties, for example: `{\"width\": 100}` or `{\"width\": \"50%\"}`" + }, + "stylesheetPath": { + "description": "The path to the external stylesheet file.", + "type": "string" + }, + "type": { + "const": "divider", + "type": "string" + }, + "withDivider": { + "description": "Renders the response with a trailing divider. If present, will override the divider setting in the components or uiConfig.", + "type": "boolean" + }, + "withDontKnow": { + "not": {} + } + }, + "required": [ + "id", + "type" + ], + "type": "object" + }, "DropdownResponse": { "additionalProperties": false, "description": "The DropdownResponse interface is used to define the properties of a dropdown response. DropdownResponses render as a select input with user specified options.\n\nExample: ```js { \"id\": \"q-color\", \"prompt\": \"What is your favorite color?\", \"location\": \"aboveStimulus\", \"type\": \"dropdown\", \"placeholder\": \"Please choose your favorite color\", \"options\": [\"Red\", \"Blue\"] } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -590,11 +663,11 @@ "description": "Controls the response location. These might be the same for all responses, or differ across responses. Defaults to `belowStimulus`" }, "maxSelections": { - "description": "The maximum number of selections that are required.", + "description": "The maximum number of selections that are required. This will make the dropdown a multiselect dropdown.", "type": "number" }, "minSelections": { - "description": "The minimum number of selections that are required.", + "description": "The minimum number of selections that are required. This will make the dropdown a multiselect dropdown.", "type": "number" }, "options": { @@ -1246,6 +1319,10 @@ "additionalProperties": false, "description": "The LikertResponse interface is used to define the properties of a likert response. LikertResponses render as radio buttons with a user specified number of options, which can be controlled through the numItems. For example, numItems: 5 will render 5 radio buttons, and numItems: 7 will render 7 radio buttons. LikertResponses can also have a description, and left and right labels. The left and right labels are used to label the left and right ends of the likert scale with values such as 'Strongly Disagree' and 'Strongly Agree'.\n\nExample for a five-point Likert Scale:\n\n```js { \"id\": \"q-satisfaction\", \"prompt\": \"Rate your satisfaction from 1 (not enjoyable) to 5 (very enjoyable).\", \"location\": \"aboveStimulus\", \"type\": \"likert\", \"leftLabel\": \"Not Enjoyable\", \"rightLabel\": \"Very Enjoyable\", \"numItems\": 5, \"start\": 1, \"spacing\": 1 } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -1338,6 +1415,10 @@ "additionalProperties": false, "description": "The LongTextResponse interface is used to define the properties of a long text response. LongTextResponses render as a text area that accepts any text and can optionally have a placeholder. ```js { \"id\": \"q-name\", \"prompt\": \"What is your first name?\", \"location\": \"aboveStimulus\", \"type\": \"longText\", \"placeholder\": \"Please enter your first name\" } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -1591,6 +1672,10 @@ ], "description": "The answer options (columns). We provide some shortcuts for a likelihood scale (ranging from highly unlikely to highly likely) and a satisfaction scale (ranging from highly unsatisfied to highly satisfied) with either 5 or 7 options to choose from." }, + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -1701,6 +1786,10 @@ "additionalProperties": false, "description": "The NumericalResponse interface is used to define the properties of a numerical response. NumericalResponses render as a text input that only accepts numbers, and can optionally have a min and max value, or a placeholder.\n\nExample: ```js { \"id\": \"q-numerical\", \"prompt\": \"Numerical example\", \"location\": \"aboveStimulus\", \"type\": \"numerical\", \"placeholder\": \"Enter your age, range from 0 - 120\", \"max\": 120, \"min\": 0 } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -1930,6 +2019,10 @@ "additionalProperties": false, "description": "The RadioResponse interface is used to define the properties of a radio response. Radios have only one allowable selection. RadioResponses render as a radio input with user specified options, and optionally left and right labels.\n\nExample: ```js { \"id\": \"q-radio\", \"prompt\": \"Radio button example\", \"location\": \"aboveStimulus\", \"type\": \"radio\", \"options\": [\"Option 1\", \"Option 2\"] } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -2068,6 +2161,10 @@ "additionalProperties": false, "description": "The RankingResponse interface is used to define the properties of a ranking widget response. RankingResponses render as a ranking widget with user specified options.\n\nThere are three types of ranking widgets: Ranking Sublist: The participant is asked to rank a subset of items from a larger list. Ranking Categorical: The participant is asked to rank items within categories: HIGH, MEDIUM, and LOW. Ranking Pairwise: The participant is asked to rank items by comparing them in pairs.\n\n```js { \"id\": \"ranking-sublist\", \"type\": \"ranking-sublist\", \"prompt\": \"Rank your top 2 favorite fruits from the list below\", \"location\": \"belowStimulus\", \"options\": [\"Apple\", \"Banana\", \"Orange\", \"Strawberry\", \"Grapes\"], \"numItems\": 2 }, { \"id\": \"ranking-categorical\", \"type\": \"ranking-categorical\", \"prompt\": \"Sort these hobbies into the categories of HIGH, MEDIUM, and LOW based on your level of interest.\", \"location\": \"belowStimulus\", \"options\": [\"Drawing\", \"Singing\", \"Hiking\", \"Dancing\", \"Photography\"] }, { \"id\": \"ranking-pairwise\", \"type\": \"ranking-pairwise\", \"prompt\": \"Which meal would you prefer\", \"location\": \"belowStimulus\", \"options\": [\"Pizza\", \"Sushi\", \"Burger\", \"Pasta\", \"Salad\", \"Tacos\"] } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -2318,6 +2415,10 @@ "additionalProperties": false, "description": "The ReactiveResponse interface is used to define the properties of a reactive response. ReactiveResponses render as a list, that is connected to a WebsiteComponent, VegaComponent, or ReactComponent. When data is sent from the components, it is displayed in the list.\n\n```js { \"id\": \"reactiveResponse\", \"prompt\": \"Answer clicked in the stimulus\", \"location\": \"aboveStimulus\", \"type\": \"reactive\" } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -2464,6 +2565,9 @@ }, { "$ref": "#/definitions/TextOnlyResponse" + }, + { + "$ref": "#/definitions/DividerResponse" } ] }, @@ -2471,6 +2575,10 @@ "additionalProperties": false, "description": "The ShortTextResponse interface is used to define the properties of a short text response. ShortTextResponses render as a text input that accepts any text and can optionally have a placeholder.\n\n```js { \"id\": \"q-short-text\", \"prompt\": \"Short text example\", \"location\": \"aboveStimulus\", \"type\": \"shortText\", \"placeholder\": \"Enter your answer here\" } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -2566,6 +2674,10 @@ "additionalProperties": false, "description": "The SliderResponse interface is used to define the properties of a slider response. SliderResponses render as a slider input with user specified steps. For example, you could have steps of 0, 50, and 100.\n\nExample: ```js { \"id\": \"q-slider\", \"prompt\": \"How are you feeling?\", \"location\": \"aboveStimulus\", \"type\": \"slider\", \"options\": [ { \"label\": \"Bad\", \"value\": 0 }, { \"label\": \"OK\", \"value\": 50 }, { \"label\": \"Good\", \"value\": 100 } ] } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -2848,6 +2960,10 @@ "additionalProperties": false, "description": "The TextOnlyResponse interface is used to define the properties of a text only response. TextOnlyResponses render as a block of text that is displayed to the user. This can be used to display instructions or other information. It does not accept any input from the user.\n\nExample: ```js { \"id\": \"textOnlyResponse\", \"type\": \"textOnly\", \"prompt\": \"This is a text only response, it accepts markdown so you can **bold** or _italicize_ text.\", \"location\": \"belowStimulus\", \"restartEnumeration\": true } ```\n\nIn this example, the text only response is displayed below the stimulus and the enumeration of the questions is restarted.", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "not": {} }, diff --git a/src/parser/StudyConfigSchema.json b/src/parser/StudyConfigSchema.json index 0100975c71..4d50096a3f 100644 --- a/src/parser/StudyConfigSchema.json +++ b/src/parser/StudyConfigSchema.json @@ -241,10 +241,32 @@ "description": "The baseComponents is an optional set of components which can help template other components. For example, suppose you have a single HTML file that you want to display to the user several times. Instead of having the same component twice in the `components` list, you can have a single baseComponent with all the information that the two HTML components will share. A great example is showing the same HTML component but with two different questions;\n\n Using baseComponents:\n\n```js \"baseComponents\": { \"my-image-component\": { \"instructionLocation\": \"sidebar\", \"nextButtonLocation\": \"sidebar\", \"path\": \"/assets/my-image.jpg\", \"response\": [ { \"id\": \"my-image-id\", \"options\": [\"Europe\", \"Japan\", \"USA\"], \"prompt\": \"Your Selected Answer:\", \"type\": \"dropdown\" } ], \"type\": \"image\" } } ``` In the above code snippet, we have a single base component which holds the information about the type of component, the path to the image, and the response (which is a dropdown containing three choices). Any component which contains the `\"baseComponent\":\"my-image-component\"` key-value pair will inherit each of these properties. Thus, if we have three different questions which have the same choices and are concerning the same image, we can define our components like below: ```js \"components\": { \"q1\": { \"baseComponent\": \"my-image-component\", \"description\": \"Choosing section with largest GDP\", \"instruction\": \"Which region has the largest GDP?\" }, \"q2\": { \"baseComponent\": \"my-image-component\", \"description\": \"Choosing section with lowest GDP\", \"instruction\": \"Which region has the lowest GDP?\" }, \"q3\": { \"baseComponent\": \"my-image-component\", \"description\": \"Choosing section with highest exports of Wheat\", \"instruction\": \"Which region had the most Wheat exported in 2022?\" } } ```", "type": "object" }, + "BrowserRules": { + "additionalProperties": false, + "properties": { + "allowed": { + "items": { + "$ref": "#/definitions/UserBrowser" + }, + "type": "array" + }, + "blockedMessage": { + "type": "string" + } + }, + "required": [ + "allowed" + ], + "type": "object" + }, "ButtonsResponse": { "additionalProperties": false, "description": "The ButtonsResponse interface is used to define the properties of a buttons response. ButtonsResponses render as a list of buttons that the participant can click. When a button is clicked, the value of the button is stored in the data file. Participants can cycle through the options using the arrow keys.\n\nExample: ```js { \"id\": \"buttonsResponse\", \"type\": \"buttons\", \"prompt\": \"Click a button\", \"location\": \"belowStimulus\", \"options\": [ \"Option 1\", \"Option 2\", \"Option 3\" ] } ``` In this example, the participant can click one of the buttons labeled \"Option 1\", \"Option 2\", or \"Option 3\".", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -338,6 +360,10 @@ "additionalProperties": false, "description": "The CheckboxResponse interface is used to define the properties of a checkbox response. CheckboxResponses render as a checkbox input with user specified options.\n\n```js { \"id\": \"q7\", \"prompt\": \"Checkbox example (not required)\", \"location\": \"aboveStimulus\", \"type\": \"checkbox\", \"options\": [\"Option 1\", \"Option 2\", \"Option 3\"] } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -569,10 +595,97 @@ ], "type": "object" }, + "DeviceRules": { + "additionalProperties": false, + "properties": { + "allowed": { + "items": { + "$ref": "#/definitions/UserDevice" + }, + "type": "array" + }, + "blockedMessage": { + "type": "string" + } + }, + "required": [ + "allowed" + ], + "type": "object" + }, + "DividerResponse": { + "additionalProperties": false, + "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, + "hidden": { + "not": {} + }, + "id": { + "description": "The id of the response. This is used to identify the response in the data file.", + "type": "string" + }, + "infoText": { + "not": {} + }, + "location": { + "$ref": "#/definitions/ConfigResponseBlockLocation", + "description": "Controls the response location. These might be the same for all responses, or differ across responses. Defaults to `belowStimulus`" + }, + "paramCapture": { + "not": {} + }, + "prompt": { + "not": {} + }, + "required": { + "not": {} + }, + "requiredLabel": { + "not": {} + }, + "requiredValue": { + "not": {} + }, + "secondaryText": { + "not": {} + }, + "style": { + "$ref": "#/definitions/Styles", + "description": "You can set styles here, using React CSSProperties, for example: `{\"width\": 100}` or `{\"width\": \"50%\"}`" + }, + "stylesheetPath": { + "description": "The path to the external stylesheet file.", + "type": "string" + }, + "type": { + "const": "divider", + "type": "string" + }, + "withDivider": { + "description": "Renders the response with a trailing divider. If present, will override the divider setting in the components or uiConfig.", + "type": "boolean" + }, + "withDontKnow": { + "not": {} + } + }, + "required": [ + "id", + "type" + ], + "type": "object" + }, "DropdownResponse": { "additionalProperties": false, "description": "The DropdownResponse interface is used to define the properties of a dropdown response. DropdownResponses render as a select input with user specified options.\n\nExample: ```js { \"id\": \"q-color\", \"prompt\": \"What is your favorite color?\", \"location\": \"aboveStimulus\", \"type\": \"dropdown\", \"placeholder\": \"Please choose your favorite color\", \"options\": [\"Red\", \"Blue\"] } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -590,11 +703,11 @@ "description": "Controls the response location. These might be the same for all responses, or differ across responses. Defaults to `belowStimulus`" }, "maxSelections": { - "description": "The maximum number of selections that are required.", + "description": "The maximum number of selections that are required. This will make the dropdown a multiselect dropdown.", "type": "number" }, "minSelections": { - "description": "The minimum number of selections that are required.", + "description": "The minimum number of selections that are required. This will make the dropdown a multiselect dropdown.", "type": "number" }, "options": { @@ -1161,6 +1274,24 @@ ], "type": "object" }, + "InputRules": { + "additionalProperties": false, + "properties": { + "allowed": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "blockedMessage": { + "type": "string" + } + }, + "required": [ + "allowed" + ], + "type": "object" + }, "InterruptionBlock": { "anyOf": [ { @@ -1176,6 +1307,10 @@ "additionalProperties": false, "description": "The LikertResponse interface is used to define the properties of a likert response. LikertResponses render as radio buttons with a user specified number of options, which can be controlled through the numItems. For example, numItems: 5 will render 5 radio buttons, and numItems: 7 will render 7 radio buttons. LikertResponses can also have a description, and left and right labels. The left and right labels are used to label the left and right ends of the likert scale with values such as 'Strongly Disagree' and 'Strongly Agree'.\n\nExample for a five-point Likert Scale:\n\n```js { \"id\": \"q-satisfaction\", \"prompt\": \"Rate your satisfaction from 1 (not enjoyable) to 5 (very enjoyable).\", \"location\": \"aboveStimulus\", \"type\": \"likert\", \"leftLabel\": \"Not Enjoyable\", \"rightLabel\": \"Very Enjoyable\", \"numItems\": 5, \"start\": 1, \"spacing\": 1 } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -1268,6 +1403,10 @@ "additionalProperties": false, "description": "The LongTextResponse interface is used to define the properties of a long text response. LongTextResponses render as a text area that accepts any text and can optionally have a placeholder. ```js { \"id\": \"q-name\", \"prompt\": \"What is your first name?\", \"location\": \"aboveStimulus\", \"type\": \"longText\", \"placeholder\": \"Please enter your first name\" } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -1521,6 +1660,10 @@ ], "description": "The answer options (columns). We provide some shortcuts for a likelihood scale (ranging from highly unlikely to highly likely) and a satisfaction scale (ranging from highly unsatisfied to highly satisfied) with either 5 or 7 options to choose from." }, + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -1631,6 +1774,10 @@ "additionalProperties": false, "description": "The NumericalResponse interface is used to define the properties of a numerical response. NumericalResponses render as a text input that only accepts numbers, and can optionally have a min and max value, or a placeholder.\n\nExample: ```js { \"id\": \"q-numerical\", \"prompt\": \"Numerical example\", \"location\": \"aboveStimulus\", \"type\": \"numerical\", \"placeholder\": \"Enter your age, range from 0 - 120\", \"max\": 120, \"min\": 0 } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -1860,6 +2007,10 @@ "additionalProperties": false, "description": "The RadioResponse interface is used to define the properties of a radio response. Radios have only one allowable selection. RadioResponses render as a radio input with user specified options, and optionally left and right labels.\n\nExample: ```js { \"id\": \"q-radio\", \"prompt\": \"Radio button example\", \"location\": \"aboveStimulus\", \"type\": \"radio\", \"options\": [\"Option 1\", \"Option 2\"] } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -1998,6 +2149,10 @@ "additionalProperties": false, "description": "The RankingResponse interface is used to define the properties of a ranking widget response. RankingResponses render as a ranking widget with user specified options.\n\nThere are three types of ranking widgets: Ranking Sublist: The participant is asked to rank a subset of items from a larger list. Ranking Categorical: The participant is asked to rank items within categories: HIGH, MEDIUM, and LOW. Ranking Pairwise: The participant is asked to rank items by comparing them in pairs.\n\n```js { \"id\": \"ranking-sublist\", \"type\": \"ranking-sublist\", \"prompt\": \"Rank your top 2 favorite fruits from the list below\", \"location\": \"belowStimulus\", \"options\": [\"Apple\", \"Banana\", \"Orange\", \"Strawberry\", \"Grapes\"], \"numItems\": 2 }, { \"id\": \"ranking-categorical\", \"type\": \"ranking-categorical\", \"prompt\": \"Sort these hobbies into the categories of HIGH, MEDIUM, and LOW based on your level of interest.\", \"location\": \"belowStimulus\", \"options\": [\"Drawing\", \"Singing\", \"Hiking\", \"Dancing\", \"Photography\"] }, { \"id\": \"ranking-pairwise\", \"type\": \"ranking-pairwise\", \"prompt\": \"Which meal would you prefer\", \"location\": \"belowStimulus\", \"options\": [\"Pizza\", \"Sushi\", \"Burger\", \"Pasta\", \"Salad\", \"Tacos\"] } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -2248,6 +2403,10 @@ "additionalProperties": false, "description": "The ReactiveResponse interface is used to define the properties of a reactive response. ReactiveResponses render as a list, that is connected to a WebsiteComponent, VegaComponent, or ReactComponent. When data is sent from the components, it is displayed in the list.\n\n```js { \"id\": \"reactiveResponse\", \"prompt\": \"Answer clicked in the stimulus\", \"location\": \"aboveStimulus\", \"type\": \"reactive\" } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -2394,6 +2553,9 @@ }, { "$ref": "#/definitions/TextOnlyResponse" + }, + { + "$ref": "#/definitions/DividerResponse" } ] }, @@ -2401,6 +2563,10 @@ "additionalProperties": false, "description": "The ShortTextResponse interface is used to define the properties of a short text response. ShortTextResponses render as a text input that accepts any text and can optionally have a placeholder.\n\n```js { \"id\": \"q-short-text\", \"prompt\": \"Short text example\", \"location\": \"aboveStimulus\", \"type\": \"shortText\", \"placeholder\": \"Enter your answer here\" } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -2496,6 +2662,10 @@ "additionalProperties": false, "description": "The SliderResponse interface is used to define the properties of a slider response. SliderResponses render as a slider input with user specified steps. For example, you could have steps of 0, 50, and 100.\n\nExample: ```js { \"id\": \"q-slider\", \"prompt\": \"How are you feeling?\", \"location\": \"aboveStimulus\", \"type\": \"slider\", \"options\": [ { \"label\": \"Bad\", \"value\": 0 }, { \"label\": \"OK\", \"value\": 50 }, { \"label\": \"Good\", \"value\": 100 } ] } ```", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "description": "Controls whether the response is hidden.", "type": "boolean" @@ -2885,6 +3055,10 @@ "additionalProperties": false, "description": "The TextOnlyResponse interface is used to define the properties of a text only response. TextOnlyResponses render as a block of text that is displayed to the user. This can be used to display instructions or other information. It does not accept any input from the user.\n\nExample: ```js { \"id\": \"textOnlyResponse\", \"type\": \"textOnly\", \"prompt\": \"This is a text only response, it accepts markdown so you can **bold** or _italicize_ text.\", \"location\": \"belowStimulus\", \"restartEnumeration\": true } ```\n\nIn this example, the text only response is displayed below the stimulus and the enumeration of the questions is restarted.", "properties": { + "excludeFromRandomization": { + "description": "Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false.", + "type": "boolean" + }, "hidden": { "not": {} }, @@ -2966,10 +3140,18 @@ "description": "The time in milliseconds to wait before automatically downloading the study data.", "type": "number" }, + "browserRules": { + "$ref": "#/definitions/BrowserRules", + "description": "Browsers supported by the study" + }, "contactEmail": { "description": "The email address that used during the study if a participant clicks contact.", "type": "string" }, + "deviceRules": { + "$ref": "#/definitions/DeviceRules", + "description": "Devices suported by the study" + }, "enumerateQuestions": { "description": "Whether to prepend questions with their index (+ 1). This should only be used when all questions are in the same location, e.g. all are in the side bar.", "type": "boolean" @@ -2978,6 +3160,10 @@ "description": "The path to the help text file. This is displayed when a participant clicks help. Markdown is supported.", "type": "string" }, + "inputRules": { + "$ref": "#/definitions/InputRules", + "description": "Input types suported by the study" + }, "instructionLocation": { "$ref": "#/definitions/ConfigResponseBlockLocation", "description": "The location of the instructions." @@ -3099,6 +3285,36 @@ ], "type": "object" }, + "UserBrowser": { + "additionalProperties": false, + "properties": { + "minVersion": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "UserDevice": { + "enum": [ + "desktop", + "tablet", + "mobile" + ], + "type": "string" + }, + "UserInput": { + "enum": [ + "mouse", + "touch" + ], + "type": "string" + }, "VegaComponent": { "anyOf": [ { diff --git a/src/parser/types.ts b/src/parser/types.ts index d14af73845..6a09932ea2 100644 --- a/src/parser/types.ts +++ b/src/parser/types.ts @@ -64,6 +64,24 @@ export interface StudyMetadata { */ export type ResponseBlockLocation = 'sidebar' | 'aboveStimulus' | 'belowStimulus' | 'stimulus'; export type ConfigResponseBlockLocation = Exclude; +export type UserBrowser = { + name: string; + minVersion?: number; +}; +export type BrowserRules = { + allowed: UserBrowser[]; + blockedMessage?: string; +}; +export type UserDevice = 'desktop' | 'tablet' | 'mobile' +export type DeviceRules = { + allowed: UserDevice[], + blockedMessage?: string; +} +export type UserInput = 'mouse' | 'touch' +export type InputRules = { + allowed: UserInput[], + blockedMessage?: string; +} export type Styles = { /** Sizing */ @@ -210,6 +228,12 @@ export interface UIConfig { minHeightSize?: number; /** The path to the external stylesheet file. */ stylesheetPath?: string; + /** Browsers supported by the study */ + browserRules?: BrowserRules + /** Devices suported by the study */ + deviceRules?: DeviceRules + /** Input types suported by the study */ + inputRules?: InputRules } /** @@ -266,8 +290,10 @@ export interface BaseResponse { withDontKnow?: boolean; /** The path to the external stylesheet file. */ stylesheetPath?: string; - /** You can set styles here, using React CSSProperties, for example: `{"width": 100}` or `{"width": "50%"}` */ + /** You can set styles here, using React CSSProperties, for example: `{"width": 100}` or `{"width": "50%"}` */ style?: Styles; + /** Exclude response from randomization. If present, will override the responseOrder randomization setting in the components. Defaults to false. */ + excludeFromRandomization?: boolean; } /** @@ -692,7 +718,36 @@ export interface TextOnlyResponse extends Omit { + type: 'divider'; + + prompt?: undefined; + infoText?: undefined; + secondaryText?: undefined; + required?: undefined; + requiredValue?: undefined; + requiredLabel?: undefined; + paramCapture?: undefined; + hidden?: undefined; + withDontKnow?: undefined; +} + +export type Response = NumericalResponse | ShortTextResponse | LongTextResponse | LikertResponse | DropdownResponse | SliderResponse | RadioResponse | CheckboxResponse | RankingResponse | ReactiveResponse | MatrixResponse | ButtonsResponse | TextOnlyResponse | DividerResponse; /** * The Answer interface is used to define the properties of an answer. Answers are used to define the correct answer for a task. These are generally used in training tasks or if skip logic is required based on the answer. diff --git a/src/public/libraries/virtual-chinrest/assets/ViewingDistanceCalibration.tsx b/src/public/libraries/virtual-chinrest/assets/ViewingDistanceCalibration.tsx index a5dc622062..0c07a55a5e 100644 --- a/src/public/libraries/virtual-chinrest/assets/ViewingDistanceCalibration.tsx +++ b/src/public/libraries/virtual-chinrest/assets/ViewingDistanceCalibration.tsx @@ -171,14 +171,14 @@ export default function ViewingDistanceCalibration({ parameters, setAnswer }: St - Put your left hand on the + Put your left hand on the  space bar . Cover your right eye with your right hand. Using your left eye, focus on the black square. Keep your focus on the black square. - The + The  red ball {' '} will disappear as it moves from right to left. diff --git a/src/store/hooks/useDeviceRules.ts b/src/store/hooks/useDeviceRules.ts new file mode 100644 index 0000000000..267c54410f --- /dev/null +++ b/src/store/hooks/useDeviceRules.ts @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react'; +import { + BrowserRules, UIConfig, +} from '../../parser/types'; + +function detectBrowser() { + const ua = navigator.userAgent.toLowerCase(); + let name = 'unknown'; + let version = 0; + + if (ua.includes('chrome') && !ua.includes('edg') && !ua.includes('opr')) { + name = 'chrome'; + version = parseInt(ua.match(/chrome\/(\d+)/)?.[1] || '0', 10); + } else if (ua.includes('firefox')) { + name = 'firefox'; + version = parseInt(ua.match(/firefox\/(\d+)/)?.[1] || '0', 10); + } else if (ua.includes('safari') && !ua.includes('chrome')) { + name = 'safari'; + version = parseInt(ua.match(/version\/(\d+)/)?.[1] || '0', 10); + } else if (ua.includes('edg')) { + name = 'edge'; + version = parseInt(ua.match(/edg\/(\d+)/)?.[1] || '0', 10); + } + + return { name, version }; +} + +function detectDeviceType() { + const ua = navigator.userAgent.toLowerCase(); + + const isMobile = /iphone|ipod|android.*mobile|windows phone|blackberry|opera mini/.test(ua); + const isTablet = /ipad|android(?!.*mobile)|tablet/.test(ua); + + if (isMobile) return 'mobile'; + if (isTablet) return 'tablet'; + return 'desktop'; +} + +function detectInputTypes() { + const types: ('mouse' | 'touch')[] = []; + if (window.matchMedia('(pointer:fine)').matches) types.push('mouse'); + if ('ontouchstart' in window || navigator.maxTouchPoints > 0) types.push('touch'); + return types; +} + +export function useAllowedBrowsers(browserRules: BrowserRules | undefined) { + const [isAllowed, setIsAllowed] = useState(true); + + useEffect(() => { + const browser = detectBrowser(); + + setIsAllowed(browserRules?.allowed.some( + (b) => b.name === browser.name + && browser.version >= (b.minVersion ?? 0), + ) || false); + }, [browserRules]); + + return isAllowed; +} + +export function useDeviceRules(uiConfig?: UIConfig) { + const [isBrowserAllowed, setIsBrowserAllowed] = useState(true); + const [isDeviceAllowed, setIsDeviceAllowed] = useState(true); + const [isInputAllowed, setIsInputAllowed] = useState(true); + + useEffect(() => { + const browser = detectBrowser(); + const device = detectDeviceType(); + const inputs = detectInputTypes(); + if (!uiConfig) { + return; + } + + // Browser check + if (uiConfig.browserRules?.allowed?.length) { + const ok = uiConfig.browserRules.allowed.some( + (b) => b.name === browser.name + && browser.version >= (b.minVersion ?? 0), + ); + if (!ok) { + setIsBrowserAllowed(false); + } + } + + // Device check + if (uiConfig.deviceRules?.allowed?.length) { + if (!uiConfig.deviceRules.allowed.includes(device)) { + setIsDeviceAllowed(false); + } + } + + // Input type check + if (uiConfig.inputRules?.allowed?.length) { + const hasAllowedInput = inputs.some((i) => uiConfig.inputRules!.allowed.includes(i)); + if (!hasAllowedInput) { + setIsInputAllowed(false); + } + } + }, [uiConfig]); + + return { + isBrowserAllowed, + isDeviceAllowed, + isInputAllowed, + }; +} diff --git a/src/store/store.tsx b/src/store/store.tsx index 9ebe9faff7..b2f6b3d89f 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -111,7 +111,7 @@ export async function studyStoreCreator( config, showStudyBrowser: true, showHelpText: false, - alertModal: { show: false, message: '' }, + alertModal: { show: false, message: '', title: '' }, trialValidation: Object.keys(answers).length > 0 ? allValid : emptyValidation, reactiveAnswers: {}, metadata, @@ -205,7 +205,7 @@ export async function studyStoreCreator( toggleShowHelpText: (state) => { state.showHelpText = !state.showHelpText; }, - setAlertModal: (state, action: PayloadAction<{ show: boolean; message: string }>) => { + setAlertModal: (state, action: PayloadAction<{ show: boolean; message: string; title: string }>) => { state.alertModal = action.payload; }, setReactiveAnswers: (state, action: PayloadAction>>) => { diff --git a/src/store/types.ts b/src/store/types.ts index 4cc57495f7..cc03b2150c 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -166,7 +166,7 @@ export interface StoreState { config: StudyConfig; showStudyBrowser: boolean; showHelpText: boolean; - alertModal: { show: boolean, message: string }; + alertModal: { show: boolean, message: string, title: string }; trialValidation: TrialValidation; reactiveAnswers: Record>; metadata: ParticipantMetadata; diff --git a/src/utils/handleResponseRandomization.ts b/src/utils/handleResponseRandomization.ts index c364f1e55c..f07ab4d379 100644 --- a/src/utils/handleResponseRandomization.ts +++ b/src/utils/handleResponseRandomization.ts @@ -4,12 +4,14 @@ export function randomizeForm(componentConfig: IndividualComponent) { const response = componentConfig.response.map((r) => r.id); if (componentConfig.responseOrder === 'random') { - return { - response: [...response] - .map((value) => ({ value, sort: Math.random() })) - .sort((a, b) => a.sort - b.sort) - .map(({ value }) => value), - }; + const fixedIndices = componentConfig.response.flatMap((r, i) => (r.excludeFromRandomization ? [i] : [])); + const shuffled = componentConfig.response + .filter((r) => !r.excludeFromRandomization) + .map((r) => r.id) + .map((value) => ({ value, sort: Math.random() })) + .sort((a, b) => a.sort - b.sort) + .map(({ value }) => value); + return { response: componentConfig.response.map((r, i) => (fixedIndices.includes(i) ? r.id : shuffled.shift()!)) }; } return { response }; diff --git a/src/utils/useDisableBrowserBack.tsx b/src/utils/useDisableBrowserBack.tsx index 847273d245..735425caef 100644 --- a/src/utils/useDisableBrowserBack.tsx +++ b/src/utils/useDisableBrowserBack.tsx @@ -14,7 +14,7 @@ export function useDisableBrowserBack() { window.history.pushState(null, '', window.location.href); window.onpopstate = () => { window.history.pushState(null, '', window.location.href); - storeDispatch(setAlertModal({ show: true, message: 'Using the browser\'s back button is prohibited during the study.' })); + storeDispatch(setAlertModal({ show: true, message: 'Using the browser\'s back button is prohibited during the study.', title: 'Prohibited' })); }; } }, [currentStep, setAlertModal, storeDispatch]);