@lessonkit/react 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +288 -57
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +291 -61
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -28,6 +28,44 @@ function createXapiQueueFromObservability(observability) {
|
|
|
28
28
|
}
|
|
29
29
|
return createInMemoryXAPIQueue(opts);
|
|
30
30
|
}
|
|
31
|
+
function wrapBatchSink(batchSink, observability) {
|
|
32
|
+
if (!batchSink || !observability?.onTelemetrySinkError) return batchSink;
|
|
33
|
+
const onError = observability.onTelemetrySinkError;
|
|
34
|
+
return async (events) => {
|
|
35
|
+
try {
|
|
36
|
+
await batchSink(events);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
onError(err, { sinkId: "tracking-batch" });
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function warnMissingProductionObservability(observability, opts) {
|
|
44
|
+
let isProduction = false;
|
|
45
|
+
try {
|
|
46
|
+
isProduction = import.meta.env?.PROD === true;
|
|
47
|
+
} catch {
|
|
48
|
+
}
|
|
49
|
+
if (!isProduction) {
|
|
50
|
+
const g = globalThis;
|
|
51
|
+
isProduction = typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
|
|
52
|
+
}
|
|
53
|
+
if (!isProduction) return;
|
|
54
|
+
if (!opts.trackingEnabled && !opts.xapiEnabled) return;
|
|
55
|
+
const hooks = [
|
|
56
|
+
observability?.onTelemetrySinkError,
|
|
57
|
+
observability?.onTelemetryBufferDrop,
|
|
58
|
+
observability?.onXapiQueueDepth,
|
|
59
|
+
observability?.onXapiQueueCap,
|
|
60
|
+
observability?.onLxpackBridgeMiss
|
|
61
|
+
];
|
|
62
|
+
if (hooks.some(Boolean)) return;
|
|
63
|
+
if (typeof console !== "undefined") {
|
|
64
|
+
console.warn(
|
|
65
|
+
"[lessonkit] Production deployment without observability hooks \u2014 telemetry/xAPI failures and buffer drops will be silent. See https://lessonkit.readthedocs.io/en/latest/guides/react-developers/production-checklist.html"
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
31
69
|
function wrapTrackingSink(sink, observability) {
|
|
32
70
|
if (!sink || !observability?.onTelemetrySinkError) return sink;
|
|
33
71
|
const onError = observability.onTelemetrySinkError;
|
|
@@ -169,6 +207,7 @@ function createXapiClientFromConfig(config, queue) {
|
|
|
169
207
|
return createXAPIClient({
|
|
170
208
|
courseId: config.courseId,
|
|
171
209
|
transport: config.xapi?.transport,
|
|
210
|
+
exitTransport: config.xapi?.exitTransport,
|
|
172
211
|
queue
|
|
173
212
|
});
|
|
174
213
|
}
|
|
@@ -228,6 +267,7 @@ async function emitCourseStartedNonTrackingPipeline(opts) {
|
|
|
228
267
|
const statement = telemetryEventToXAPIStatement2(opts.event);
|
|
229
268
|
if (statement) {
|
|
230
269
|
opts.xapi.send(statement);
|
|
270
|
+
await opts.xapi.flush();
|
|
231
271
|
xapiStatementSent = true;
|
|
232
272
|
}
|
|
233
273
|
}
|
|
@@ -263,7 +303,7 @@ function emitTelemetryWithPlugins(opts) {
|
|
|
263
303
|
}
|
|
264
304
|
|
|
265
305
|
// src/provider/courseStarted/emit.ts
|
|
266
|
-
var
|
|
306
|
+
var courseStartedTrackingFlights = /* @__PURE__ */ new Map();
|
|
267
307
|
function isTrackingActive(tracking) {
|
|
268
308
|
return tracking?.enabled !== false;
|
|
269
309
|
}
|
|
@@ -291,25 +331,40 @@ async function emitCourseStartedToTracking(tracking, storage, sessionId, courseI
|
|
|
291
331
|
if (hasCourseStartedEmittedToTracking(storage, sessionId, courseId)) {
|
|
292
332
|
return true;
|
|
293
333
|
}
|
|
294
|
-
|
|
295
|
-
|
|
334
|
+
const existing = courseStartedTrackingFlights.get(flightKey);
|
|
335
|
+
if (existing) {
|
|
336
|
+
const settled = await existing;
|
|
337
|
+
if (settled) return true;
|
|
296
338
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
339
|
+
let resolveFlight;
|
|
340
|
+
const flight = new Promise((resolve) => {
|
|
341
|
+
resolveFlight = resolve;
|
|
342
|
+
});
|
|
343
|
+
courseStartedTrackingFlights.set(flightKey, flight);
|
|
344
|
+
void (async () => {
|
|
345
|
+
try {
|
|
346
|
+
if (shouldCommit && !shouldCommit()) {
|
|
347
|
+
resolveFlight(false);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
tracking.track(event);
|
|
351
|
+
const delivered = await tracking.flush?.();
|
|
352
|
+
if (delivered === false) {
|
|
353
|
+
resolveFlight(false);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (markCourseStartedEmittedToTracking(storage, sessionId, courseId) === false) {
|
|
357
|
+
resolveFlight(false);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
resolveFlight(true);
|
|
361
|
+
} catch {
|
|
362
|
+
resolveFlight(false);
|
|
363
|
+
} finally {
|
|
364
|
+
courseStartedTrackingFlights.delete(flightKey);
|
|
311
365
|
}
|
|
312
|
-
}
|
|
366
|
+
})();
|
|
367
|
+
return flight;
|
|
313
368
|
}
|
|
314
369
|
async function emitCourseStartedPipelineOnly(opts) {
|
|
315
370
|
try {
|
|
@@ -323,8 +378,10 @@ async function emitCourseStartedPipelineOnly(opts) {
|
|
|
323
378
|
skipXapi: opts.skipXapi
|
|
324
379
|
});
|
|
325
380
|
if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
|
|
326
|
-
markCourseStarted(opts.storage, opts.sessionId, opts.courseId);
|
|
327
|
-
markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId)
|
|
381
|
+
if (markCourseStarted(opts.storage, opts.sessionId, opts.courseId) === false) return "failed";
|
|
382
|
+
if (markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId) === false) {
|
|
383
|
+
return "failed";
|
|
384
|
+
}
|
|
328
385
|
if (xapiStatementSent) {
|
|
329
386
|
opts.onXapiStatementSent?.();
|
|
330
387
|
}
|
|
@@ -375,7 +432,9 @@ async function emitCourseStartedToTrackingOnly(opts) {
|
|
|
375
432
|
extraSinks: opts.extraSinks,
|
|
376
433
|
skipXapi: true
|
|
377
434
|
});
|
|
378
|
-
markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId)
|
|
435
|
+
if (markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId) === false) {
|
|
436
|
+
return "failed";
|
|
437
|
+
}
|
|
379
438
|
return "emitted";
|
|
380
439
|
} catch {
|
|
381
440
|
return "failed";
|
|
@@ -435,6 +494,7 @@ function createTrackingClientFromConfig(config, observability) {
|
|
|
435
494
|
sink: config.tracking?.sink,
|
|
436
495
|
batchSink: config.tracking?.batchSink,
|
|
437
496
|
batch: config.tracking?.batch,
|
|
497
|
+
exitBatchSink: config.tracking?.exitBatchSink,
|
|
438
498
|
onBufferDrop: observability?.onTelemetryBufferDrop
|
|
439
499
|
});
|
|
440
500
|
}
|
|
@@ -510,6 +570,7 @@ function useLessonkitProviderRuntime(config) {
|
|
|
510
570
|
const pendingCourseIdResetRef = useRef(false);
|
|
511
571
|
const prevUseV2RuntimeRef = useRef(useV2Runtime);
|
|
512
572
|
const xapiCourseStartedSentOnClientRef = useRef(false);
|
|
573
|
+
const xapiBootstrapSendRef = useRef(false);
|
|
513
574
|
if (prevUseV2RuntimeRef.current !== useV2Runtime) {
|
|
514
575
|
prevUseV2RuntimeRef.current = useV2Runtime;
|
|
515
576
|
if (useV2Runtime) {
|
|
@@ -582,19 +643,22 @@ function useLessonkitProviderRuntime(config) {
|
|
|
582
643
|
xapiQueueRef.current = createXapiQueueFromObservability(observabilityRef.current);
|
|
583
644
|
prevXapiCourseIdRef.current = courseId;
|
|
584
645
|
xapiCourseStartedSentOnClientRef.current = false;
|
|
646
|
+
xapiBootstrapSendRef.current = false;
|
|
585
647
|
}
|
|
586
648
|
const prev = xapiRef.current;
|
|
587
649
|
const next = createXapiClientFromConfig(normalizedConfig, xapiQueueRef.current);
|
|
588
650
|
xapiRef.current = next;
|
|
589
651
|
setXapi(next);
|
|
652
|
+
let bootstrapSent = false;
|
|
653
|
+
let bootstrapAlreadyStarted = false;
|
|
590
654
|
if (next) {
|
|
591
655
|
const sessionId = sessionIdRef.current;
|
|
592
656
|
const cid = courseIdRef.current;
|
|
593
657
|
const trackingActive = isTrackingActive(normalizedConfig.tracking);
|
|
594
|
-
|
|
658
|
+
bootstrapAlreadyStarted = hasCourseStarted(defaultStorage, sessionId, cid);
|
|
595
659
|
const clientChanged = !prev || prev !== next;
|
|
596
|
-
const skipBootstrap = trackingActive && !
|
|
597
|
-
const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && (!
|
|
660
|
+
const skipBootstrap = trackingActive && !bootstrapAlreadyStarted;
|
|
661
|
+
const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && !xapiBootstrapSendRef.current && (!bootstrapAlreadyStarted || clientChanged);
|
|
598
662
|
if (needsBootstrap) {
|
|
599
663
|
try {
|
|
600
664
|
const event = buildCourseStartedEvent({
|
|
@@ -605,15 +669,12 @@ function useLessonkitProviderRuntime(config) {
|
|
|
605
669
|
user: userRef.current,
|
|
606
670
|
lxpackBridge: lxpackBridgeModeRef.current
|
|
607
671
|
});
|
|
608
|
-
if (event
|
|
609
|
-
} else {
|
|
672
|
+
if (event !== null) {
|
|
610
673
|
const statement = telemetryEventToXAPIStatement3(event);
|
|
611
674
|
if (statement) {
|
|
612
675
|
next.send(statement);
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
}
|
|
616
|
-
xapiCourseStartedSentOnClientRef.current = true;
|
|
676
|
+
xapiBootstrapSendRef.current = true;
|
|
677
|
+
bootstrapSent = true;
|
|
617
678
|
}
|
|
618
679
|
}
|
|
619
680
|
} catch {
|
|
@@ -631,6 +692,12 @@ function useLessonkitProviderRuntime(config) {
|
|
|
631
692
|
if (cancelled) return;
|
|
632
693
|
try {
|
|
633
694
|
await next?.flush();
|
|
695
|
+
if (bootstrapSent && !cancelled) {
|
|
696
|
+
if (!bootstrapAlreadyStarted) {
|
|
697
|
+
markCourseStarted(defaultStorage, sessionIdRef.current, courseIdRef.current);
|
|
698
|
+
}
|
|
699
|
+
xapiCourseStartedSentOnClientRef.current = true;
|
|
700
|
+
}
|
|
634
701
|
} catch {
|
|
635
702
|
}
|
|
636
703
|
})();
|
|
@@ -659,7 +726,10 @@ function useLessonkitProviderRuntime(config) {
|
|
|
659
726
|
useIsoLayoutEffect(() => {
|
|
660
727
|
const prev = trackingRef.current;
|
|
661
728
|
const baseSink = wrapTrackingSink(normalizedConfig.tracking?.sink, observabilityRef.current);
|
|
662
|
-
const userBatchSink =
|
|
729
|
+
const userBatchSink = wrapBatchSink(
|
|
730
|
+
normalizedConfig.tracking?.batchSink,
|
|
731
|
+
observabilityRef.current
|
|
732
|
+
);
|
|
663
733
|
assertTrackingSinkConfig(normalizedConfig.tracking);
|
|
664
734
|
const sink = pluginHostRef.current && baseSink ? (
|
|
665
735
|
/* v8 ignore next -- composeTrackingSink may return null; fall back to base sink */
|
|
@@ -813,7 +883,11 @@ function useLessonkitProviderRuntime(config) {
|
|
|
813
883
|
user: userRef.current,
|
|
814
884
|
lxpackBridge: lxpackBridgeModeRef.current,
|
|
815
885
|
onLxpackBridgeMiss,
|
|
816
|
-
extraSinks: extraSinksRef.current
|
|
886
|
+
extraSinks: extraSinksRef.current,
|
|
887
|
+
skipXapi: xapiCourseStartedSentOnClientRef.current,
|
|
888
|
+
onXapiStatementSent: () => {
|
|
889
|
+
xapiCourseStartedSentOnClientRef.current = true;
|
|
890
|
+
}
|
|
817
891
|
});
|
|
818
892
|
courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
|
|
819
893
|
}
|
|
@@ -869,20 +943,39 @@ function useLessonkitProviderRuntime(config) {
|
|
|
869
943
|
}, []);
|
|
870
944
|
useEffect(() => {
|
|
871
945
|
if (typeof document === "undefined") return;
|
|
872
|
-
const
|
|
873
|
-
|
|
874
|
-
|
|
946
|
+
const flushOnPageExit = () => {
|
|
947
|
+
try {
|
|
948
|
+
xapiRef.current?.flushOnExit?.();
|
|
949
|
+
trackingRef.current?.flushOnExit?.();
|
|
950
|
+
} finally {
|
|
951
|
+
void xapiRef.current?.flush();
|
|
952
|
+
void trackingRef.current?.flush?.();
|
|
953
|
+
}
|
|
875
954
|
};
|
|
876
955
|
const onVisibilityChange = () => {
|
|
877
|
-
if (document.visibilityState === "hidden")
|
|
956
|
+
if (document.visibilityState === "hidden") flushOnPageExit();
|
|
878
957
|
};
|
|
879
958
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
880
|
-
window.addEventListener("pagehide",
|
|
959
|
+
window.addEventListener("pagehide", flushOnPageExit);
|
|
881
960
|
return () => {
|
|
882
961
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
883
|
-
window.removeEventListener("pagehide",
|
|
962
|
+
window.removeEventListener("pagehide", flushOnPageExit);
|
|
884
963
|
};
|
|
885
964
|
}, []);
|
|
965
|
+
useEffect(() => {
|
|
966
|
+
warnMissingProductionObservability(observabilityRef.current, {
|
|
967
|
+
trackingEnabled: isTrackingActive(normalizedConfig.tracking),
|
|
968
|
+
xapiEnabled: normalizedConfig.xapi?.enabled !== false && Boolean(
|
|
969
|
+
normalizedConfig.xapi?.client || normalizedConfig.xapi?.transport || normalizedConfig.xapi?.enabled === true
|
|
970
|
+
)
|
|
971
|
+
});
|
|
972
|
+
}, [
|
|
973
|
+
normalizedConfig.tracking,
|
|
974
|
+
normalizedConfig.xapi?.enabled,
|
|
975
|
+
normalizedConfig.xapi?.client,
|
|
976
|
+
normalizedConfig.xapi?.transport,
|
|
977
|
+
normalizedConfig.observability
|
|
978
|
+
]);
|
|
886
979
|
const setActiveLesson = useCallback(
|
|
887
980
|
(lessonId) => {
|
|
888
981
|
if (useV2Runtime && headlessRef.current) {
|
|
@@ -2155,9 +2248,13 @@ function FillInTheBlanksInner(props, ref) {
|
|
|
2155
2248
|
const [submitted, setSubmitted] = useState7(false);
|
|
2156
2249
|
const completedRef = useRef8(false);
|
|
2157
2250
|
const answeredRef = useRef8(false);
|
|
2251
|
+
const checkSnapshotRef = useRef8(null);
|
|
2252
|
+
const telemetryReplayedRef = useRef8(false);
|
|
2158
2253
|
const reset = () => {
|
|
2159
2254
|
completedRef.current = false;
|
|
2160
2255
|
answeredRef.current = false;
|
|
2256
|
+
checkSnapshotRef.current = null;
|
|
2257
|
+
telemetryReplayedRef.current = false;
|
|
2161
2258
|
setPassed(false);
|
|
2162
2259
|
setValues(Object.fromEntries(blanks.map((b) => [b.id, ""])));
|
|
2163
2260
|
setShowSolutions(false);
|
|
@@ -2174,6 +2271,31 @@ function FillInTheBlanksInner(props, ref) {
|
|
|
2174
2271
|
});
|
|
2175
2272
|
const maxScore = blanks.length;
|
|
2176
2273
|
const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
|
|
2274
|
+
const replayTelemetry = (nextValues, nextPassed, nextSubmitted, nextScore, nextMaxScore) => {
|
|
2275
|
+
if (telemetryReplayedRef.current || !nextSubmitted && !nextPassed) return;
|
|
2276
|
+
telemetryReplayedRef.current = true;
|
|
2277
|
+
const nextPassedThreshold = meetsPassingThreshold(
|
|
2278
|
+
nextScore,
|
|
2279
|
+
nextMaxScore || 1,
|
|
2280
|
+
props.passingScore
|
|
2281
|
+
);
|
|
2282
|
+
assessment.answer({
|
|
2283
|
+
checkId,
|
|
2284
|
+
interactionType: INTERACTION3,
|
|
2285
|
+
question: props.template,
|
|
2286
|
+
response: nextValues,
|
|
2287
|
+
correct: nextPassedThreshold
|
|
2288
|
+
});
|
|
2289
|
+
if (nextPassed || nextPassedThreshold) {
|
|
2290
|
+
assessment.complete({
|
|
2291
|
+
checkId,
|
|
2292
|
+
interactionType: INTERACTION3,
|
|
2293
|
+
score: nextScore,
|
|
2294
|
+
maxScore: nextMaxScore,
|
|
2295
|
+
passingScore: props.passingScore ?? nextMaxScore
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
};
|
|
2177
2299
|
const handle = useMemo9(
|
|
2178
2300
|
() => buildAssessmentHandle({
|
|
2179
2301
|
checkId,
|
|
@@ -2193,20 +2315,33 @@ function FillInTheBlanksInner(props, ref) {
|
|
|
2193
2315
|
getCurrentState: () => ({ values, passed, showSolutions, submitted }),
|
|
2194
2316
|
resume: (state) => {
|
|
2195
2317
|
const raw = state.values;
|
|
2196
|
-
|
|
2318
|
+
let nextValues = values;
|
|
2319
|
+
if (raw && typeof raw === "object") {
|
|
2320
|
+
nextValues = { ...raw };
|
|
2321
|
+
setValues(nextValues);
|
|
2322
|
+
}
|
|
2323
|
+
let nextPassed = passed;
|
|
2324
|
+
let nextSubmitted = submitted;
|
|
2197
2325
|
readBooleanStateField(state, "passed", (value) => {
|
|
2326
|
+
nextPassed = value;
|
|
2198
2327
|
setPassed(value);
|
|
2199
2328
|
completedRef.current = value;
|
|
2200
2329
|
answeredRef.current = value;
|
|
2201
2330
|
});
|
|
2202
2331
|
readBooleanStateField(state, "showSolutions", setShowSolutions);
|
|
2203
2332
|
readBooleanStateField(state, "submitted", (value) => {
|
|
2333
|
+
nextSubmitted = value;
|
|
2204
2334
|
setSubmitted(value);
|
|
2205
2335
|
if (value) answeredRef.current = true;
|
|
2206
2336
|
});
|
|
2337
|
+
let nextScore = 0;
|
|
2338
|
+
blanks.forEach((b) => {
|
|
2339
|
+
if ((nextValues[b.id] ?? "").trim().toLowerCase() === b.answer.toLowerCase()) nextScore += 1;
|
|
2340
|
+
});
|
|
2341
|
+
replayTelemetry(nextValues, nextPassed, nextSubmitted, nextScore, blanks.length);
|
|
2207
2342
|
}
|
|
2208
2343
|
}),
|
|
2209
|
-
[allFilled, checkId, maxScore, passed, passedThreshold, score, showSolutions, submitted, values]
|
|
2344
|
+
[allFilled, assessment, blanks, checkId, maxScore, passed, passedThreshold, props.passingScore, props.template, score, showSolutions, submitted, values]
|
|
2210
2345
|
);
|
|
2211
2346
|
useAssessmentHandleRegistration(checkId, handle, ref);
|
|
2212
2347
|
const check = () => {
|
|
@@ -2217,7 +2352,10 @@ function FillInTheBlanksInner(props, ref) {
|
|
|
2217
2352
|
return;
|
|
2218
2353
|
}
|
|
2219
2354
|
if (!allFilled) return;
|
|
2220
|
-
if (
|
|
2355
|
+
if (passed) return;
|
|
2356
|
+
const snapshot = JSON.stringify(values);
|
|
2357
|
+
if (checkSnapshotRef.current === snapshot) return;
|
|
2358
|
+
checkSnapshotRef.current = snapshot;
|
|
2221
2359
|
answeredRef.current = true;
|
|
2222
2360
|
setSubmitted(true);
|
|
2223
2361
|
assessment.answer({
|
|
@@ -2242,12 +2380,13 @@ function FillInTheBlanksInner(props, ref) {
|
|
|
2242
2380
|
useEffect7(() => {
|
|
2243
2381
|
if (!allFilled) {
|
|
2244
2382
|
answeredRef.current = false;
|
|
2383
|
+
checkSnapshotRef.current = null;
|
|
2245
2384
|
setSubmitted(false);
|
|
2246
2385
|
}
|
|
2247
2386
|
}, [allFilled]);
|
|
2248
2387
|
useEffect7(() => {
|
|
2249
|
-
if (props.autoCheck && allFilled) check();
|
|
2250
|
-
}, [allFilled, props.autoCheck, values, passedThreshold]);
|
|
2388
|
+
if (props.autoCheck && allFilled && !passed) check();
|
|
2389
|
+
}, [allFilled, props.autoCheck, values, passedThreshold, passed]);
|
|
2251
2390
|
const reveal = showSolutions || passed && props.enableSolutionsButton;
|
|
2252
2391
|
return /* @__PURE__ */ jsxs6("section", { "aria-label": "Fill in the Blanks", "data-lk-check-id": checkId, children: [
|
|
2253
2392
|
/* @__PURE__ */ jsx10("p", { children: parsed.parts.map((part, i) => {
|
|
@@ -2306,9 +2445,13 @@ function DragTheWordsInner(props, ref) {
|
|
|
2306
2445
|
const [submitted, setSubmitted] = useState8(false);
|
|
2307
2446
|
const completedRef = useRef9(false);
|
|
2308
2447
|
const answeredRef = useRef9(false);
|
|
2448
|
+
const checkSnapshotRef = useRef9(null);
|
|
2449
|
+
const telemetryReplayedRef = useRef9(false);
|
|
2309
2450
|
const reset = () => {
|
|
2310
2451
|
completedRef.current = false;
|
|
2311
2452
|
answeredRef.current = false;
|
|
2453
|
+
checkSnapshotRef.current = null;
|
|
2454
|
+
telemetryReplayedRef.current = false;
|
|
2312
2455
|
setPassed(false);
|
|
2313
2456
|
setSubmitted(false);
|
|
2314
2457
|
setZones(Object.fromEntries(answers.map((_, i) => [`zone-${i}`, ""])));
|
|
@@ -2326,6 +2469,31 @@ function DragTheWordsInner(props, ref) {
|
|
|
2326
2469
|
});
|
|
2327
2470
|
const maxScore = answers.length;
|
|
2328
2471
|
const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
|
|
2472
|
+
const replayTelemetry = (nextZones, nextPassed, nextSubmitted, nextScore, nextMaxScore) => {
|
|
2473
|
+
if (telemetryReplayedRef.current || !nextSubmitted && !nextPassed) return;
|
|
2474
|
+
telemetryReplayedRef.current = true;
|
|
2475
|
+
const nextPassedThreshold = meetsPassingThreshold(
|
|
2476
|
+
nextScore,
|
|
2477
|
+
nextMaxScore || 1,
|
|
2478
|
+
props.passingScore
|
|
2479
|
+
);
|
|
2480
|
+
assessment.answer({
|
|
2481
|
+
checkId,
|
|
2482
|
+
interactionType: INTERACTION4,
|
|
2483
|
+
question: props.template,
|
|
2484
|
+
response: nextZones,
|
|
2485
|
+
correct: nextPassedThreshold
|
|
2486
|
+
});
|
|
2487
|
+
if (nextPassed || nextPassedThreshold) {
|
|
2488
|
+
assessment.complete({
|
|
2489
|
+
checkId,
|
|
2490
|
+
interactionType: INTERACTION4,
|
|
2491
|
+
score: nextScore,
|
|
2492
|
+
maxScore: nextMaxScore,
|
|
2493
|
+
passingScore: props.passingScore ?? nextMaxScore
|
|
2494
|
+
});
|
|
2495
|
+
}
|
|
2496
|
+
};
|
|
2329
2497
|
const handle = useMemo10(
|
|
2330
2498
|
() => buildAssessmentHandle({
|
|
2331
2499
|
checkId,
|
|
@@ -2346,22 +2514,35 @@ function DragTheWordsInner(props, ref) {
|
|
|
2346
2514
|
getCurrentState: () => ({ zones, pool, passed, keyboardWord, submitted }),
|
|
2347
2515
|
resume: (state) => {
|
|
2348
2516
|
const rawZones = state.zones;
|
|
2349
|
-
|
|
2517
|
+
let nextZones = zones;
|
|
2518
|
+
if (rawZones && typeof rawZones === "object") {
|
|
2519
|
+
nextZones = { ...rawZones };
|
|
2520
|
+
setZones(nextZones);
|
|
2521
|
+
}
|
|
2350
2522
|
if (Array.isArray(state.pool)) setPool([...state.pool]);
|
|
2523
|
+
let nextPassed = passed;
|
|
2524
|
+
let nextSubmitted = submitted;
|
|
2351
2525
|
readBooleanStateField(state, "passed", (value) => {
|
|
2526
|
+
nextPassed = value;
|
|
2352
2527
|
setPassed(value);
|
|
2353
2528
|
completedRef.current = value;
|
|
2354
2529
|
answeredRef.current = value;
|
|
2355
2530
|
});
|
|
2356
2531
|
readBooleanStateField(state, "submitted", (value) => {
|
|
2532
|
+
nextSubmitted = value;
|
|
2357
2533
|
setSubmitted(value);
|
|
2358
2534
|
if (value) answeredRef.current = true;
|
|
2359
2535
|
});
|
|
2360
2536
|
const kw = state.keyboardWord;
|
|
2361
2537
|
if (kw === null || typeof kw === "string") setKeyboardWord(kw ?? null);
|
|
2538
|
+
let nextScore = 0;
|
|
2539
|
+
answers.forEach((ans, i) => {
|
|
2540
|
+
if ((nextZones[`zone-${i}`] ?? "").trim().toLowerCase() === ans.toLowerCase()) nextScore += 1;
|
|
2541
|
+
});
|
|
2542
|
+
replayTelemetry(nextZones, nextPassed, nextSubmitted, nextScore, answers.length);
|
|
2362
2543
|
}
|
|
2363
2544
|
}),
|
|
2364
|
-
[allFilled, checkId, keyboardWord, maxScore, passed, passedThreshold, pool, score, submitted, zones]
|
|
2545
|
+
[allFilled, answers, assessment, checkId, keyboardWord, maxScore, passed, passedThreshold, pool, props.passingScore, props.template, score, submitted, zones]
|
|
2365
2546
|
);
|
|
2366
2547
|
useAssessmentHandleRegistration(checkId, handle, ref);
|
|
2367
2548
|
const placeInZone = (zoneId, word) => {
|
|
@@ -2391,7 +2572,10 @@ function DragTheWordsInner(props, ref) {
|
|
|
2391
2572
|
return;
|
|
2392
2573
|
}
|
|
2393
2574
|
if (!allFilled) return;
|
|
2394
|
-
if (
|
|
2575
|
+
if (passed) return;
|
|
2576
|
+
const snapshot = JSON.stringify(zones);
|
|
2577
|
+
if (checkSnapshotRef.current === snapshot) return;
|
|
2578
|
+
checkSnapshotRef.current = snapshot;
|
|
2395
2579
|
answeredRef.current = true;
|
|
2396
2580
|
setSubmitted(true);
|
|
2397
2581
|
assessment.answer({
|
|
@@ -2416,12 +2600,13 @@ function DragTheWordsInner(props, ref) {
|
|
|
2416
2600
|
useEffect8(() => {
|
|
2417
2601
|
if (!allFilled) {
|
|
2418
2602
|
answeredRef.current = false;
|
|
2603
|
+
checkSnapshotRef.current = null;
|
|
2419
2604
|
setSubmitted(false);
|
|
2420
2605
|
}
|
|
2421
2606
|
}, [allFilled]);
|
|
2422
2607
|
useEffect8(() => {
|
|
2423
|
-
if (props.autoCheck && allFilled) check();
|
|
2424
|
-
}, [allFilled, props.autoCheck, zones, passedThreshold]);
|
|
2608
|
+
if (props.autoCheck && allFilled && !passed) check();
|
|
2609
|
+
}, [allFilled, props.autoCheck, zones, passedThreshold, passed]);
|
|
2425
2610
|
return /* @__PURE__ */ jsxs7("section", { "aria-label": "Drag the Words", "data-lk-check-id": checkId, children: [
|
|
2426
2611
|
/* @__PURE__ */ jsx11("p", { children: "Drag words into the blanks (or select a word, then activate a blank)." }),
|
|
2427
2612
|
/* @__PURE__ */ jsx11("div", { role: "list", "aria-label": "Word bank", "data-testid": "word-bank", children: pool.map((word) => /* @__PURE__ */ jsx11(
|
|
@@ -2826,6 +3011,8 @@ function useCompoundPersistence(opts) {
|
|
|
2826
3011
|
}, [ctx, opts.index, opts.pageCount]);
|
|
2827
3012
|
const buildStateRef = useRef12(buildState);
|
|
2828
3013
|
buildStateRef.current = buildState;
|
|
3014
|
+
const persistNowRef = useRef12(() => {
|
|
3015
|
+
});
|
|
2829
3016
|
const finalizeHydration = useCallback6(
|
|
2830
3017
|
(childStates) => {
|
|
2831
3018
|
loadedChildStatesRef.current = {
|
|
@@ -2834,6 +3021,7 @@ function useCompoundPersistence(opts) {
|
|
|
2834
3021
|
};
|
|
2835
3022
|
skipSaveUntilHydratedRef.current = false;
|
|
2836
3023
|
pendingChildResumeRef.current = null;
|
|
3024
|
+
queueMicrotask(() => persistNowRef.current());
|
|
2837
3025
|
},
|
|
2838
3026
|
[]
|
|
2839
3027
|
);
|
|
@@ -2846,6 +3034,14 @@ function useCompoundPersistence(opts) {
|
|
|
2846
3034
|
alreadyResumed: resumedChildKeysRef.current
|
|
2847
3035
|
});
|
|
2848
3036
|
if (!applied) {
|
|
3037
|
+
if (handles.size === 0) {
|
|
3038
|
+
const registeredOnly2 = stripOrphanChildStates(handles, pending.childStates);
|
|
3039
|
+
resumeChildHandles(handles, registeredOnly2, {
|
|
3040
|
+
alreadyResumed: resumedChildKeysRef.current
|
|
3041
|
+
});
|
|
3042
|
+
finalizeHydration(registeredOnly2);
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
2849
3045
|
const handlesAtWait = handles.size;
|
|
2850
3046
|
queueMicrotask(() => {
|
|
2851
3047
|
if (pendingChildResumeRef.current !== pending) return;
|
|
@@ -2879,8 +3075,12 @@ function useCompoundPersistence(opts) {
|
|
|
2879
3075
|
});
|
|
2880
3076
|
const persistNow = useCallback6(() => {
|
|
2881
3077
|
if (!opts.enabled || !opts.courseId) return;
|
|
3078
|
+
if (skipSaveUntilHydratedRef.current) return;
|
|
2882
3079
|
saveResume(buildStateRef.current());
|
|
2883
3080
|
}, [opts.enabled, opts.courseId, saveResume]);
|
|
3081
|
+
useEffect11(() => {
|
|
3082
|
+
persistNowRef.current = persistNow;
|
|
3083
|
+
}, [persistNow]);
|
|
2884
3084
|
const notifyImperativeResume = useCallback6(
|
|
2885
3085
|
(state) => {
|
|
2886
3086
|
const clamped = clampCompoundPageIndex2(state.activePageIndex, opts.pageCount);
|
|
@@ -2902,12 +3102,12 @@ function useCompoundPersistence(opts) {
|
|
|
2902
3102
|
}
|
|
2903
3103
|
};
|
|
2904
3104
|
}, [bridgeRef, notifyImperativeResume]);
|
|
2905
|
-
useEffect11(() => {
|
|
2906
|
-
persistNow();
|
|
2907
|
-
}, [persistNow, opts.index, opts.pageCount, handlesVersion]);
|
|
2908
3105
|
useEffect11(() => {
|
|
2909
3106
|
applyPendingChildResume();
|
|
2910
3107
|
}, [opts.index, handlesVersion, applyPendingChildResume]);
|
|
3108
|
+
useEffect11(() => {
|
|
3109
|
+
persistNow();
|
|
3110
|
+
}, [persistNow, opts.index, opts.pageCount, handlesVersion]);
|
|
2911
3111
|
useEffect11(() => {
|
|
2912
3112
|
if (!opts.enabled || !opts.courseId || typeof document === "undefined") return;
|
|
2913
3113
|
const flushOnExit = () => {
|
|
@@ -3847,20 +4047,41 @@ function ImageSlider(props) {
|
|
|
3847
4047
|
setLessonkitBlockType(ImageSlider, "ImageSlider");
|
|
3848
4048
|
|
|
3849
4049
|
// src/blocks/FindHotspot.tsx
|
|
3850
|
-
import { forwardRef as forwardRef10, useEffect as useEffect18, useMemo as useMemo16, useState as useState18 } from "react";
|
|
4050
|
+
import { forwardRef as forwardRef10, useEffect as useEffect18, useMemo as useMemo16, useRef as useRef15, useState as useState18 } from "react";
|
|
3851
4051
|
import { jsx as jsx26, jsxs as jsxs19 } from "react/jsx-runtime";
|
|
3852
4052
|
var INTERACTION6 = "findHotspot";
|
|
3853
4053
|
function FindHotspotInner(props, ref) {
|
|
3854
4054
|
const checkId = useMemo16(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
|
|
3855
4055
|
const [selected, setSelected] = useState18(null);
|
|
3856
4056
|
const [checked, setChecked] = useState18(false);
|
|
4057
|
+
const telemetryReplayedRef = useRef15(false);
|
|
3857
4058
|
const assessment = useAssessmentState(props.enclosingLessonId);
|
|
3858
4059
|
const targetIdsKey = props.targets.map((t) => t.id).join("\0");
|
|
3859
4060
|
useEffect18(() => {
|
|
3860
4061
|
setSelected(null);
|
|
3861
4062
|
setChecked(false);
|
|
4063
|
+
telemetryReplayedRef.current = false;
|
|
3862
4064
|
}, [checkId, props.correctTargetId, targetIdsKey]);
|
|
3863
4065
|
const correct = selected === props.correctTargetId;
|
|
4066
|
+
const replayTelemetry = (nextSelected, nextChecked, nextCorrect) => {
|
|
4067
|
+
if (telemetryReplayedRef.current || !nextChecked || nextSelected === null) return;
|
|
4068
|
+
telemetryReplayedRef.current = true;
|
|
4069
|
+
assessment.answer({
|
|
4070
|
+
checkId,
|
|
4071
|
+
interactionType: INTERACTION6,
|
|
4072
|
+
response: nextSelected,
|
|
4073
|
+
correct: nextCorrect
|
|
4074
|
+
});
|
|
4075
|
+
if (nextCorrect) {
|
|
4076
|
+
assessment.complete({
|
|
4077
|
+
checkId,
|
|
4078
|
+
interactionType: INTERACTION6,
|
|
4079
|
+
score: 1,
|
|
4080
|
+
maxScore: 1,
|
|
4081
|
+
passingScore: props.passingScore ?? 1
|
|
4082
|
+
});
|
|
4083
|
+
}
|
|
4084
|
+
};
|
|
3864
4085
|
const handle = useMemo16(
|
|
3865
4086
|
() => buildAssessmentHandle({
|
|
3866
4087
|
checkId,
|
|
@@ -3870,6 +4091,7 @@ function FindHotspotInner(props, ref) {
|
|
|
3870
4091
|
resetTask: () => {
|
|
3871
4092
|
setSelected(null);
|
|
3872
4093
|
setChecked(false);
|
|
4094
|
+
telemetryReplayedRef.current = false;
|
|
3873
4095
|
},
|
|
3874
4096
|
showSolutions: () => setSelected(props.correctTargetId),
|
|
3875
4097
|
getXAPIData: () => ({
|
|
@@ -3882,15 +4104,23 @@ function FindHotspotInner(props, ref) {
|
|
|
3882
4104
|
}),
|
|
3883
4105
|
getCurrentState: () => ({ selected, checked }),
|
|
3884
4106
|
resume: (state) => {
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
4107
|
+
let nextSelected = selected;
|
|
4108
|
+
const rawSelected = readStringField(state, "selected");
|
|
4109
|
+
if (typeof rawSelected === "string" || rawSelected === null) {
|
|
4110
|
+
const valid = rawSelected === null || props.targets.some((t) => t.id === rawSelected);
|
|
4111
|
+
nextSelected = valid ? rawSelected : null;
|
|
4112
|
+
setSelected(nextSelected);
|
|
3889
4113
|
}
|
|
3890
|
-
|
|
4114
|
+
let nextChecked = checked;
|
|
4115
|
+
readBooleanStateField(state, "checked", (value) => {
|
|
4116
|
+
nextChecked = value;
|
|
4117
|
+
setChecked(value);
|
|
4118
|
+
});
|
|
4119
|
+
const nextCorrect = nextSelected === props.correctTargetId;
|
|
4120
|
+
replayTelemetry(nextSelected, nextChecked, nextCorrect);
|
|
3891
4121
|
}
|
|
3892
4122
|
}),
|
|
3893
|
-
[
|
|
4123
|
+
[assessment, checkId, checked, correct, props.correctTargetId, props.passingScore, props.targets, selected]
|
|
3894
4124
|
);
|
|
3895
4125
|
useAssessmentHandleRegistration(checkId, handle, ref);
|
|
3896
4126
|
const selectTarget = (id) => {
|
|
@@ -4067,7 +4297,7 @@ import React29, {
|
|
|
4067
4297
|
useContext as useContext8,
|
|
4068
4298
|
useLayoutEffect as useLayoutEffect2,
|
|
4069
4299
|
useMemo as useMemo18,
|
|
4070
|
-
useRef as
|
|
4300
|
+
useRef as useRef16,
|
|
4071
4301
|
useState as useState20
|
|
4072
4302
|
} from "react";
|
|
4073
4303
|
import {
|
|
@@ -4139,8 +4369,8 @@ function ThemeProvider(props) {
|
|
|
4139
4369
|
const base = preset === "default" ? modeBase : preset === "brand" ? mergeThemes(modeBase, brandThemeOverrides) : mergeThemes(modeBase, getPresetTheme(preset));
|
|
4140
4370
|
return mergeThemes(base, props.theme ?? {});
|
|
4141
4371
|
}, [preset, mode, dataTheme, props.theme]);
|
|
4142
|
-
const hostRef =
|
|
4143
|
-
const appliedKeysRef =
|
|
4372
|
+
const hostRef = useRef16(null);
|
|
4373
|
+
const appliedKeysRef = useRef16(/* @__PURE__ */ new Set());
|
|
4144
4374
|
useIsoLayoutEffect2(() => {
|
|
4145
4375
|
if (targetKind === "document" && typeof document !== "undefined") {
|
|
4146
4376
|
document.documentElement.setAttribute("data-lk-theme", dataTheme);
|