Skip to content

Commit 7da0a01

Browse files
feat(qualtrics): support dynamic reference params in Qualtrics element (#1211)
Qualtrics params now accept `reference` and `position` fields (same as TrackedLink) so researchers can pass player state — urlParams, participantInfo, prompt answers, etc. — directly into the survey URL at render time. - Qualtrics.jsx: resolves reference params via referenceResolver (same logic as TrackedLink); static `value` params continue to work unchanged - validateTreatmentFile.ts: qualtrics params now validated with trackedLinkParamSchema (key + optional value or reference + position) - Added 6 Playwright component tests (QURL-001–006) covering static params, always-present deliberationId/sampleId, urlParams reference, participantInfo reference, mixed params, and QualtricsEOS submission Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4174001 commit 7da0a01

4 files changed

Lines changed: 300 additions & 17 deletions

File tree

client/src/elements/Qualtrics.jsx

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
1-
/* eslint-disable guard-for-in */
2-
/* eslint-disable no-restricted-syntax */
3-
4-
import React, { useEffect, useReducer } from "react";
5-
import { usePlayer } from "@empirica/core/player/classic/react";
1+
import React, { useEffect, useMemo, useReducer } from "react";
2+
import {
3+
useGame,
4+
usePlayer,
5+
usePlayers,
6+
} from "@empirica/core/player/classic/react";
67
import { useIdleContext } from "../components/IdleProvider";
78
import { useProgressLabel } from "../components/progressLabel";
9+
import { resolveReferenceValues as resolveReferences } from "../components/referenceResolver";
10+
11+
const serializeParamValue = (value) => {
12+
if (value === undefined || value === null) return "";
13+
if (typeof value === "boolean") return value ? "true" : "false";
14+
return value.toString();
15+
};
16+
17+
const pickFirstDefined = (values) =>
18+
values?.find((val) => val !== undefined && val !== null);
819

920
export function Qualtrics({ url, params, onSubmit }) {
21+
const game = useGame();
1022
const player = usePlayer();
23+
const players = usePlayers();
1124
const { setAllowIdle } = useIdleContext();
1225
const progressLabel = useProgressLabel();
1326
const deliberationId = player?.get("participantData")?.deliberationId;
@@ -52,7 +65,7 @@ export function Qualtrics({ url, params, onSubmit }) {
5265
return newState;
5366
};
5467

55-
const [state, dispatch] = useReducer(reducer, {
68+
const [, dispatch] = useReducer(reducer, {
5669
qualtricsSubmitted: false,
5770
});
5871

@@ -66,18 +79,40 @@ export function Qualtrics({ url, params, onSubmit }) {
6679
};
6780
}, [url, onSubmit, progressLabel]);
6881

69-
let fullURL = url;
82+
const resolvedParams = useMemo(() => {
83+
if (!params) return [];
84+
return params.map((param) => {
85+
if (!param.reference) {
86+
return {
87+
key: param.key,
88+
value: param.value === undefined ? "" : serializeParamValue(param.value),
89+
};
90+
}
91+
const referenceValues = resolveReferences({
92+
reference: param.reference,
93+
position: param.position,
94+
player,
95+
game,
96+
players,
97+
});
98+
const pickedValue = pickFirstDefined(referenceValues);
99+
const resolvedValue =
100+
pickedValue === undefined ? "" : serializeParamValue(pickedValue);
101+
if (resolvedValue === "" && referenceValues?.length) {
102+
console.warn(
103+
`Qualtrics: reference ${param.reference} resolved to undefined.`,
104+
referenceValues
105+
);
106+
}
107+
return { key: param.key, value: resolvedValue };
108+
});
109+
}, [game, params, player, players]);
70110

71111
const paramsObj = new URLSearchParams();
72-
if (params) {
73-
for (const { key, value } of params) {
74-
paramsObj.append(key, value);
75-
}
76-
}
77-
paramsObj.append("deliberationId", deliberationId); // deliberationId is always passed so that we can link qualtrics responses to participants within qualtrics data
78-
paramsObj.append("sampleId", sampleId); // sampleId is always passed so that we can link qualtrics responses to participants within qualtrics data
79-
fullURL = `${url}?${paramsObj.toString()}`;
80-
console.log("fullURL", fullURL);
112+
resolvedParams.forEach(({ key, value }) => paramsObj.append(key, value));
113+
paramsObj.append("deliberationId", deliberationId); // always passed to link qualtrics responses to participants
114+
paramsObj.append("sampleId", sampleId); // always passed to link qualtrics responses to participants
115+
const fullURL = `${url}?${paramsObj.toString()}`;
81116

82117
return (
83118
<div
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import React from 'react';
2+
import { test, expect } from '@playwright/experimental-ct-react';
3+
import { QualtricsStory } from './QualtricsStory';
4+
5+
/**
6+
* Component Tests for Qualtrics URL Parameter Resolution
7+
*
8+
* Related: Issue #1211 — Qualtrics should accept urlParams the same way
9+
* TrackedLink does, including dynamic `reference` fields that resolve
10+
* values from player/game state at render time.
11+
*
12+
* These tests verify:
13+
* QURL-001 Static params (`value`) are appended to the iframe src
14+
* QURL-002 deliberationId and sampleId are always appended
15+
* QURL-003 Reference to urlParams resolves and is appended
16+
* QURL-004 Reference to participantInfo field resolves and is appended
17+
* QURL-005 Mixed static + reference params both appear in the URL
18+
* QURL-006 QualtricsEOS postMessage calls onSubmit and records data
19+
*
20+
* Mock setup:
21+
* hooksConfig.empirica — provides MockEmpiricaProvider with player attrs
22+
* (participantData, sampleId, urlParams, name, etc.)
23+
* IdleProvider — wraps Qualtrics to avoid console errors from the
24+
* default IdleContext no-op when setAllowIdle fires
25+
*/
26+
27+
// ---------------------------------------------------------------------------
28+
// Helpers
29+
// ---------------------------------------------------------------------------
30+
31+
const SURVEY_URL = 'https://test.qualtrics.com/jfe/form/SV_testSurvey123';
32+
33+
/** Base player config with deliberationId and sampleId populated. */
34+
const basePlayer = {
35+
id: 'p0',
36+
attrs: {
37+
participantData: { deliberationId: 'delib-abc-123' },
38+
sampleId: 'sample-xyz-456',
39+
},
40+
};
41+
42+
/** Minimal hooksConfig for tests that only need basic player state. */
43+
const baseEmpirica = {
44+
currentPlayerId: 'p0',
45+
players: [basePlayer],
46+
game: { attrs: {} },
47+
stage: { attrs: {} },
48+
};
49+
50+
// ---------------------------------------------------------------------------
51+
// Tests
52+
// ---------------------------------------------------------------------------
53+
54+
/**
55+
* Test ID: QURL-001
56+
* Issue: #1211
57+
* Validates: Static `value` params are appended to the iframe src URL
58+
*/
59+
test('QURL-001: static params appended to iframe URL', async ({ mount }) => {
60+
const component = await mount(
61+
<QualtricsStory
62+
url={SURVEY_URL}
63+
params={[
64+
{ key: 'condition', value: 'treatment' },
65+
{ key: 'sessionNum', value: 2 },
66+
]}
67+
/>,
68+
{ hooksConfig: { empirica: baseEmpirica } },
69+
);
70+
71+
const src = await component.locator('[data-test="qualtricsIframe"]').getAttribute('src');
72+
const url = new URL(src);
73+
expect(url.searchParams.get('condition')).toBe('treatment');
74+
expect(url.searchParams.get('sessionNum')).toBe('2');
75+
});
76+
77+
/**
78+
* Test ID: QURL-002
79+
* Issue: #1211
80+
* Validates: deliberationId and sampleId are always appended, even with no
81+
* extra params
82+
*/
83+
test('QURL-002: deliberationId and sampleId always appended', async ({ mount }) => {
84+
const component = await mount(
85+
<QualtricsStory url={SURVEY_URL} params={[]} />,
86+
{ hooksConfig: { empirica: baseEmpirica } },
87+
);
88+
89+
const src = await component.locator('[data-test="qualtricsIframe"]').getAttribute('src');
90+
const url = new URL(src);
91+
expect(url.searchParams.get('deliberationId')).toBe('delib-abc-123');
92+
expect(url.searchParams.get('sampleId')).toBe('sample-xyz-456');
93+
});
94+
95+
/**
96+
* Test ID: QURL-003
97+
* Issue: #1211
98+
* Validates: `reference: 'urlParams.PROLIFIC_PID'` resolves from player
99+
* urlParams and is appended to the iframe src
100+
*/
101+
test('QURL-003: reference to urlParams resolves in iframe URL', async ({ mount }) => {
102+
const component = await mount(
103+
<QualtricsStory
104+
url={SURVEY_URL}
105+
params={[{ key: 'prolificId', reference: 'urlParams.PROLIFIC_PID' }]}
106+
/>,
107+
{
108+
hooksConfig: {
109+
empirica: {
110+
...baseEmpirica,
111+
players: [{
112+
id: 'p0',
113+
attrs: {
114+
...basePlayer.attrs,
115+
urlParams: { PROLIFIC_PID: 'PROLIFIC-TEST-123' },
116+
},
117+
}],
118+
},
119+
},
120+
},
121+
);
122+
123+
const src = await component.locator('[data-test="qualtricsIframe"]').getAttribute('src');
124+
const url = new URL(src);
125+
expect(url.searchParams.get('prolificId')).toBe('PROLIFIC-TEST-123');
126+
});
127+
128+
/**
129+
* Test ID: QURL-004
130+
* Issue: #1211
131+
* Validates: `reference: 'participantInfo.name'` resolves from player state
132+
* and is appended to the iframe src
133+
*/
134+
test('QURL-004: reference to participantInfo resolves in iframe URL', async ({ mount }) => {
135+
const component = await mount(
136+
<QualtricsStory
137+
url={SURVEY_URL}
138+
params={[{ key: 'pName', reference: 'participantInfo.name' }]}
139+
/>,
140+
{
141+
hooksConfig: {
142+
empirica: {
143+
...baseEmpirica,
144+
players: [{
145+
id: 'p0',
146+
attrs: { ...basePlayer.attrs, name: 'Alice' },
147+
}],
148+
},
149+
},
150+
},
151+
);
152+
153+
const src = await component.locator('[data-test="qualtricsIframe"]').getAttribute('src');
154+
const url = new URL(src);
155+
expect(url.searchParams.get('pName')).toBe('Alice');
156+
});
157+
158+
/**
159+
* Test ID: QURL-005
160+
* Issue: #1211
161+
* Validates: Static and reference params can be mixed; all appear in the URL
162+
* alongside the always-present deliberationId and sampleId
163+
*/
164+
test('QURL-005: mixed static and reference params both appear in URL', async ({ mount }) => {
165+
const component = await mount(
166+
<QualtricsStory
167+
url={SURVEY_URL}
168+
params={[
169+
{ key: 'staticFlag', value: 'on' },
170+
{ key: 'dynamicId', reference: 'urlParams.PROLIFIC_PID' },
171+
]}
172+
/>,
173+
{
174+
hooksConfig: {
175+
empirica: {
176+
...baseEmpirica,
177+
players: [{
178+
id: 'p0',
179+
attrs: {
180+
...basePlayer.attrs,
181+
urlParams: { PROLIFIC_PID: 'PROLIFIC-MIX-456' },
182+
},
183+
}],
184+
},
185+
},
186+
},
187+
);
188+
189+
const src = await component.locator('[data-test="qualtricsIframe"]').getAttribute('src');
190+
const url = new URL(src);
191+
expect(url.searchParams.get('staticFlag')).toBe('on');
192+
expect(url.searchParams.get('dynamicId')).toBe('PROLIFIC-MIX-456');
193+
// Always-present params unaffected
194+
expect(url.searchParams.get('deliberationId')).toBe('delib-abc-123');
195+
expect(url.searchParams.get('sampleId')).toBe('sample-xyz-456');
196+
});
197+
198+
/**
199+
* Test ID: QURL-006
200+
* Issue: #1211
201+
* Validates: A QualtricsEOS postMessage triggers onSubmit and records
202+
* qualtricsDataReady on the player
203+
*/
204+
test('QURL-006: QualtricsEOS postMessage calls onSubmit', async ({ mount, page }) => {
205+
await page.evaluate(() => { window.__qualtricsSubmitted = false; });
206+
207+
await mount(
208+
<QualtricsStory url={SURVEY_URL} params={[]} />,
209+
{ hooksConfig: { empirica: baseEmpirica } },
210+
);
211+
212+
await page.evaluate(() => {
213+
window.postMessage('QualtricsEOS|SV_testSurveyId|FS_testSessionId', '*');
214+
});
215+
216+
await page.waitForFunction(() => window.__qualtricsSubmitted === true, { timeout: 2000 });
217+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Test story for Qualtrics component tests.
3+
*
4+
* NOTE: This "story file" pattern is NOT the preferred approach for most
5+
* component tests in this project. Most tests (see video-call/mocked/) mount
6+
* an imported component directly — no wrapper file needed. Prefer that.
7+
*
8+
* We use a story here for one specific reason: QURL-006 needs its onSubmit
9+
* callback to run in the **browser** context so it can write to window. In
10+
* Playwright CT, function props defined inline in a test file run in the
11+
* Node.js test runner, not the browser — so window there is not the browser
12+
* window. Defining handleSubmit here (compiled by Vite, runs in browser)
13+
* lets window.__qualtricsSubmitted be visible to page.waitForFunction().
14+
*
15+
* IdleProvider is included for cleanliness (avoids a console.error from the
16+
* default IdleContext no-op), but is not the reason a story is required.
17+
*/
18+
import React from 'react';
19+
import { Qualtrics } from '../../../client/src/elements/Qualtrics';
20+
import { IdleProvider } from '../../../client/src/components/IdleProvider';
21+
22+
export function QualtricsStory({ url, params }) {
23+
const handleSubmit = () => {
24+
window.__qualtricsSubmitted = true;
25+
};
26+
return (
27+
<IdleProvider>
28+
<Qualtrics url={url} params={params} onSubmit={handleSubmit} />
29+
</IdleProvider>
30+
);
31+
}

server/src/preFlight/validateTreatmentFile.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -733,7 +733,7 @@ const qualtricsSchema = elementBaseSchema
733733
type: z.literal("qualtrics"),
734734
url: urlSchema,
735735
params: z
736-
.array(z.record(z.string().or(z.number())), {
736+
.array(trackedLinkParamSchema, {
737737
invalid_type_error:
738738
"Expected an array for `params`. Make sure each item starts with a dash (`-`) in YAML.",
739739
})

0 commit comments

Comments
 (0)