@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.cjs
CHANGED
|
@@ -99,6 +99,7 @@ var import_core8 = require("@lessonkit/core");
|
|
|
99
99
|
|
|
100
100
|
// src/runtime/observability.ts
|
|
101
101
|
var import_xapi = require("@lessonkit/xapi");
|
|
102
|
+
var import_meta = {};
|
|
102
103
|
function createXapiQueueFromObservability(observability) {
|
|
103
104
|
const opts = {};
|
|
104
105
|
if (observability?.onXapiQueueDepth) {
|
|
@@ -109,6 +110,44 @@ function createXapiQueueFromObservability(observability) {
|
|
|
109
110
|
}
|
|
110
111
|
return (0, import_xapi.createInMemoryXAPIQueue)(opts);
|
|
111
112
|
}
|
|
113
|
+
function wrapBatchSink(batchSink, observability) {
|
|
114
|
+
if (!batchSink || !observability?.onTelemetrySinkError) return batchSink;
|
|
115
|
+
const onError = observability.onTelemetrySinkError;
|
|
116
|
+
return async (events) => {
|
|
117
|
+
try {
|
|
118
|
+
await batchSink(events);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
onError(err, { sinkId: "tracking-batch" });
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function warnMissingProductionObservability(observability, opts) {
|
|
126
|
+
let isProduction = false;
|
|
127
|
+
try {
|
|
128
|
+
isProduction = import_meta.env?.PROD === true;
|
|
129
|
+
} catch {
|
|
130
|
+
}
|
|
131
|
+
if (!isProduction) {
|
|
132
|
+
const g = globalThis;
|
|
133
|
+
isProduction = typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
|
|
134
|
+
}
|
|
135
|
+
if (!isProduction) return;
|
|
136
|
+
if (!opts.trackingEnabled && !opts.xapiEnabled) return;
|
|
137
|
+
const hooks = [
|
|
138
|
+
observability?.onTelemetrySinkError,
|
|
139
|
+
observability?.onTelemetryBufferDrop,
|
|
140
|
+
observability?.onXapiQueueDepth,
|
|
141
|
+
observability?.onXapiQueueCap,
|
|
142
|
+
observability?.onLxpackBridgeMiss
|
|
143
|
+
];
|
|
144
|
+
if (hooks.some(Boolean)) return;
|
|
145
|
+
if (typeof console !== "undefined") {
|
|
146
|
+
console.warn(
|
|
147
|
+
"[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"
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
112
151
|
function wrapTrackingSink(sink, observability) {
|
|
113
152
|
if (!sink || !observability?.onTelemetrySinkError) return sink;
|
|
114
153
|
const onError = observability.onTelemetrySinkError;
|
|
@@ -235,6 +274,7 @@ function createXapiClientFromConfig(config, queue) {
|
|
|
235
274
|
return (0, import_xapi3.createXAPIClient)({
|
|
236
275
|
courseId: config.courseId,
|
|
237
276
|
transport: config.xapi?.transport,
|
|
277
|
+
exitTransport: config.xapi?.exitTransport,
|
|
238
278
|
queue
|
|
239
279
|
});
|
|
240
280
|
}
|
|
@@ -283,6 +323,7 @@ async function emitCourseStartedNonTrackingPipeline(opts) {
|
|
|
283
323
|
const statement = (0, import_xapi4.telemetryEventToXAPIStatement)(opts.event);
|
|
284
324
|
if (statement) {
|
|
285
325
|
opts.xapi.send(statement);
|
|
326
|
+
await opts.xapi.flush();
|
|
286
327
|
xapiStatementSent = true;
|
|
287
328
|
}
|
|
288
329
|
}
|
|
@@ -318,7 +359,7 @@ function emitTelemetryWithPlugins(opts) {
|
|
|
318
359
|
}
|
|
319
360
|
|
|
320
361
|
// src/provider/courseStarted/emit.ts
|
|
321
|
-
var
|
|
362
|
+
var courseStartedTrackingFlights = /* @__PURE__ */ new Map();
|
|
322
363
|
function isTrackingActive(tracking) {
|
|
323
364
|
return tracking?.enabled !== false;
|
|
324
365
|
}
|
|
@@ -346,25 +387,40 @@ async function emitCourseStartedToTracking(tracking, storage, sessionId, courseI
|
|
|
346
387
|
if ((0, import_core5.hasCourseStartedEmittedToTracking)(storage, sessionId, courseId)) {
|
|
347
388
|
return true;
|
|
348
389
|
}
|
|
349
|
-
|
|
350
|
-
|
|
390
|
+
const existing = courseStartedTrackingFlights.get(flightKey);
|
|
391
|
+
if (existing) {
|
|
392
|
+
const settled = await existing;
|
|
393
|
+
if (settled) return true;
|
|
351
394
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
395
|
+
let resolveFlight;
|
|
396
|
+
const flight = new Promise((resolve) => {
|
|
397
|
+
resolveFlight = resolve;
|
|
398
|
+
});
|
|
399
|
+
courseStartedTrackingFlights.set(flightKey, flight);
|
|
400
|
+
void (async () => {
|
|
401
|
+
try {
|
|
402
|
+
if (shouldCommit && !shouldCommit()) {
|
|
403
|
+
resolveFlight(false);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
tracking.track(event);
|
|
407
|
+
const delivered = await tracking.flush?.();
|
|
408
|
+
if (delivered === false) {
|
|
409
|
+
resolveFlight(false);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if ((0, import_core5.markCourseStartedEmittedToTracking)(storage, sessionId, courseId) === false) {
|
|
413
|
+
resolveFlight(false);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
resolveFlight(true);
|
|
417
|
+
} catch {
|
|
418
|
+
resolveFlight(false);
|
|
419
|
+
} finally {
|
|
420
|
+
courseStartedTrackingFlights.delete(flightKey);
|
|
366
421
|
}
|
|
367
|
-
}
|
|
422
|
+
})();
|
|
423
|
+
return flight;
|
|
368
424
|
}
|
|
369
425
|
async function emitCourseStartedPipelineOnly(opts) {
|
|
370
426
|
try {
|
|
@@ -378,8 +434,10 @@ async function emitCourseStartedPipelineOnly(opts) {
|
|
|
378
434
|
skipXapi: opts.skipXapi
|
|
379
435
|
});
|
|
380
436
|
if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
|
|
381
|
-
(0, import_core5.markCourseStarted)(opts.storage, opts.sessionId, opts.courseId);
|
|
382
|
-
(0, import_core5.markCourseStartedPipelineDelivered)(opts.storage, opts.sessionId, opts.courseId)
|
|
437
|
+
if ((0, import_core5.markCourseStarted)(opts.storage, opts.sessionId, opts.courseId) === false) return "failed";
|
|
438
|
+
if ((0, import_core5.markCourseStartedPipelineDelivered)(opts.storage, opts.sessionId, opts.courseId) === false) {
|
|
439
|
+
return "failed";
|
|
440
|
+
}
|
|
383
441
|
if (xapiStatementSent) {
|
|
384
442
|
opts.onXapiStatementSent?.();
|
|
385
443
|
}
|
|
@@ -430,7 +488,9 @@ async function emitCourseStartedToTrackingOnly(opts) {
|
|
|
430
488
|
extraSinks: opts.extraSinks,
|
|
431
489
|
skipXapi: true
|
|
432
490
|
});
|
|
433
|
-
(0, import_core5.markCourseStartedPipelineDelivered)(opts.storage, opts.sessionId, opts.courseId)
|
|
491
|
+
if ((0, import_core5.markCourseStartedPipelineDelivered)(opts.storage, opts.sessionId, opts.courseId) === false) {
|
|
492
|
+
return "failed";
|
|
493
|
+
}
|
|
434
494
|
return "emitted";
|
|
435
495
|
} catch {
|
|
436
496
|
return "failed";
|
|
@@ -490,6 +550,7 @@ function createTrackingClientFromConfig(config, observability) {
|
|
|
490
550
|
sink: config.tracking?.sink,
|
|
491
551
|
batchSink: config.tracking?.batchSink,
|
|
492
552
|
batch: config.tracking?.batch,
|
|
553
|
+
exitBatchSink: config.tracking?.exitBatchSink,
|
|
493
554
|
onBufferDrop: observability?.onTelemetryBufferDrop
|
|
494
555
|
});
|
|
495
556
|
}
|
|
@@ -565,6 +626,7 @@ function useLessonkitProviderRuntime(config) {
|
|
|
565
626
|
const pendingCourseIdResetRef = (0, import_react.useRef)(false);
|
|
566
627
|
const prevUseV2RuntimeRef = (0, import_react.useRef)(useV2Runtime);
|
|
567
628
|
const xapiCourseStartedSentOnClientRef = (0, import_react.useRef)(false);
|
|
629
|
+
const xapiBootstrapSendRef = (0, import_react.useRef)(false);
|
|
568
630
|
if (prevUseV2RuntimeRef.current !== useV2Runtime) {
|
|
569
631
|
prevUseV2RuntimeRef.current = useV2Runtime;
|
|
570
632
|
if (useV2Runtime) {
|
|
@@ -637,19 +699,22 @@ function useLessonkitProviderRuntime(config) {
|
|
|
637
699
|
xapiQueueRef.current = createXapiQueueFromObservability(observabilityRef.current);
|
|
638
700
|
prevXapiCourseIdRef.current = courseId;
|
|
639
701
|
xapiCourseStartedSentOnClientRef.current = false;
|
|
702
|
+
xapiBootstrapSendRef.current = false;
|
|
640
703
|
}
|
|
641
704
|
const prev = xapiRef.current;
|
|
642
705
|
const next = createXapiClientFromConfig(normalizedConfig, xapiQueueRef.current);
|
|
643
706
|
xapiRef.current = next;
|
|
644
707
|
setXapi(next);
|
|
708
|
+
let bootstrapSent = false;
|
|
709
|
+
let bootstrapAlreadyStarted = false;
|
|
645
710
|
if (next) {
|
|
646
711
|
const sessionId = sessionIdRef.current;
|
|
647
712
|
const cid = courseIdRef.current;
|
|
648
713
|
const trackingActive = isTrackingActive(normalizedConfig.tracking);
|
|
649
|
-
|
|
714
|
+
bootstrapAlreadyStarted = (0, import_core5.hasCourseStarted)(defaultStorage, sessionId, cid);
|
|
650
715
|
const clientChanged = !prev || prev !== next;
|
|
651
|
-
const skipBootstrap = trackingActive && !
|
|
652
|
-
const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && (!
|
|
716
|
+
const skipBootstrap = trackingActive && !bootstrapAlreadyStarted;
|
|
717
|
+
const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && !xapiBootstrapSendRef.current && (!bootstrapAlreadyStarted || clientChanged);
|
|
653
718
|
if (needsBootstrap) {
|
|
654
719
|
try {
|
|
655
720
|
const event = buildCourseStartedEvent({
|
|
@@ -660,15 +725,12 @@ function useLessonkitProviderRuntime(config) {
|
|
|
660
725
|
user: userRef.current,
|
|
661
726
|
lxpackBridge: lxpackBridgeModeRef.current
|
|
662
727
|
});
|
|
663
|
-
if (event
|
|
664
|
-
} else {
|
|
728
|
+
if (event !== null) {
|
|
665
729
|
const statement = (0, import_xapi5.telemetryEventToXAPIStatement)(event);
|
|
666
730
|
if (statement) {
|
|
667
731
|
next.send(statement);
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
}
|
|
671
|
-
xapiCourseStartedSentOnClientRef.current = true;
|
|
732
|
+
xapiBootstrapSendRef.current = true;
|
|
733
|
+
bootstrapSent = true;
|
|
672
734
|
}
|
|
673
735
|
}
|
|
674
736
|
} catch {
|
|
@@ -686,6 +748,12 @@ function useLessonkitProviderRuntime(config) {
|
|
|
686
748
|
if (cancelled) return;
|
|
687
749
|
try {
|
|
688
750
|
await next?.flush();
|
|
751
|
+
if (bootstrapSent && !cancelled) {
|
|
752
|
+
if (!bootstrapAlreadyStarted) {
|
|
753
|
+
(0, import_core5.markCourseStarted)(defaultStorage, sessionIdRef.current, courseIdRef.current);
|
|
754
|
+
}
|
|
755
|
+
xapiCourseStartedSentOnClientRef.current = true;
|
|
756
|
+
}
|
|
689
757
|
} catch {
|
|
690
758
|
}
|
|
691
759
|
})();
|
|
@@ -714,7 +782,10 @@ function useLessonkitProviderRuntime(config) {
|
|
|
714
782
|
useIsoLayoutEffect(() => {
|
|
715
783
|
const prev = trackingRef.current;
|
|
716
784
|
const baseSink = wrapTrackingSink(normalizedConfig.tracking?.sink, observabilityRef.current);
|
|
717
|
-
const userBatchSink =
|
|
785
|
+
const userBatchSink = wrapBatchSink(
|
|
786
|
+
normalizedConfig.tracking?.batchSink,
|
|
787
|
+
observabilityRef.current
|
|
788
|
+
);
|
|
718
789
|
assertTrackingSinkConfig(normalizedConfig.tracking);
|
|
719
790
|
const sink = pluginHostRef.current && baseSink ? (
|
|
720
791
|
/* v8 ignore next -- composeTrackingSink may return null; fall back to base sink */
|
|
@@ -868,7 +939,11 @@ function useLessonkitProviderRuntime(config) {
|
|
|
868
939
|
user: userRef.current,
|
|
869
940
|
lxpackBridge: lxpackBridgeModeRef.current,
|
|
870
941
|
onLxpackBridgeMiss,
|
|
871
|
-
extraSinks: extraSinksRef.current
|
|
942
|
+
extraSinks: extraSinksRef.current,
|
|
943
|
+
skipXapi: xapiCourseStartedSentOnClientRef.current,
|
|
944
|
+
onXapiStatementSent: () => {
|
|
945
|
+
xapiCourseStartedSentOnClientRef.current = true;
|
|
946
|
+
}
|
|
872
947
|
});
|
|
873
948
|
courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
|
|
874
949
|
}
|
|
@@ -924,20 +999,39 @@ function useLessonkitProviderRuntime(config) {
|
|
|
924
999
|
}, []);
|
|
925
1000
|
(0, import_react.useEffect)(() => {
|
|
926
1001
|
if (typeof document === "undefined") return;
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
|
|
1002
|
+
const flushOnPageExit = () => {
|
|
1003
|
+
try {
|
|
1004
|
+
xapiRef.current?.flushOnExit?.();
|
|
1005
|
+
trackingRef.current?.flushOnExit?.();
|
|
1006
|
+
} finally {
|
|
1007
|
+
void xapiRef.current?.flush();
|
|
1008
|
+
void trackingRef.current?.flush?.();
|
|
1009
|
+
}
|
|
930
1010
|
};
|
|
931
1011
|
const onVisibilityChange = () => {
|
|
932
|
-
if (document.visibilityState === "hidden")
|
|
1012
|
+
if (document.visibilityState === "hidden") flushOnPageExit();
|
|
933
1013
|
};
|
|
934
1014
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
935
|
-
window.addEventListener("pagehide",
|
|
1015
|
+
window.addEventListener("pagehide", flushOnPageExit);
|
|
936
1016
|
return () => {
|
|
937
1017
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
938
|
-
window.removeEventListener("pagehide",
|
|
1018
|
+
window.removeEventListener("pagehide", flushOnPageExit);
|
|
939
1019
|
};
|
|
940
1020
|
}, []);
|
|
1021
|
+
(0, import_react.useEffect)(() => {
|
|
1022
|
+
warnMissingProductionObservability(observabilityRef.current, {
|
|
1023
|
+
trackingEnabled: isTrackingActive(normalizedConfig.tracking),
|
|
1024
|
+
xapiEnabled: normalizedConfig.xapi?.enabled !== false && Boolean(
|
|
1025
|
+
normalizedConfig.xapi?.client || normalizedConfig.xapi?.transport || normalizedConfig.xapi?.enabled === true
|
|
1026
|
+
)
|
|
1027
|
+
});
|
|
1028
|
+
}, [
|
|
1029
|
+
normalizedConfig.tracking,
|
|
1030
|
+
normalizedConfig.xapi?.enabled,
|
|
1031
|
+
normalizedConfig.xapi?.client,
|
|
1032
|
+
normalizedConfig.xapi?.transport,
|
|
1033
|
+
normalizedConfig.observability
|
|
1034
|
+
]);
|
|
941
1035
|
const setActiveLesson = (0, import_react.useCallback)(
|
|
942
1036
|
(lessonId) => {
|
|
943
1037
|
if (useV2Runtime && headlessRef.current) {
|
|
@@ -2210,9 +2304,13 @@ function FillInTheBlanksInner(props, ref) {
|
|
|
2210
2304
|
const [submitted, setSubmitted] = (0, import_react16.useState)(false);
|
|
2211
2305
|
const completedRef = (0, import_react16.useRef)(false);
|
|
2212
2306
|
const answeredRef = (0, import_react16.useRef)(false);
|
|
2307
|
+
const checkSnapshotRef = (0, import_react16.useRef)(null);
|
|
2308
|
+
const telemetryReplayedRef = (0, import_react16.useRef)(false);
|
|
2213
2309
|
const reset = () => {
|
|
2214
2310
|
completedRef.current = false;
|
|
2215
2311
|
answeredRef.current = false;
|
|
2312
|
+
checkSnapshotRef.current = null;
|
|
2313
|
+
telemetryReplayedRef.current = false;
|
|
2216
2314
|
setPassed(false);
|
|
2217
2315
|
setValues(Object.fromEntries(blanks.map((b) => [b.id, ""])));
|
|
2218
2316
|
setShowSolutions(false);
|
|
@@ -2229,6 +2327,31 @@ function FillInTheBlanksInner(props, ref) {
|
|
|
2229
2327
|
});
|
|
2230
2328
|
const maxScore = blanks.length;
|
|
2231
2329
|
const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
|
|
2330
|
+
const replayTelemetry = (nextValues, nextPassed, nextSubmitted, nextScore, nextMaxScore) => {
|
|
2331
|
+
if (telemetryReplayedRef.current || !nextSubmitted && !nextPassed) return;
|
|
2332
|
+
telemetryReplayedRef.current = true;
|
|
2333
|
+
const nextPassedThreshold = meetsPassingThreshold(
|
|
2334
|
+
nextScore,
|
|
2335
|
+
nextMaxScore || 1,
|
|
2336
|
+
props.passingScore
|
|
2337
|
+
);
|
|
2338
|
+
assessment.answer({
|
|
2339
|
+
checkId,
|
|
2340
|
+
interactionType: INTERACTION3,
|
|
2341
|
+
question: props.template,
|
|
2342
|
+
response: nextValues,
|
|
2343
|
+
correct: nextPassedThreshold
|
|
2344
|
+
});
|
|
2345
|
+
if (nextPassed || nextPassedThreshold) {
|
|
2346
|
+
assessment.complete({
|
|
2347
|
+
checkId,
|
|
2348
|
+
interactionType: INTERACTION3,
|
|
2349
|
+
score: nextScore,
|
|
2350
|
+
maxScore: nextMaxScore,
|
|
2351
|
+
passingScore: props.passingScore ?? nextMaxScore
|
|
2352
|
+
});
|
|
2353
|
+
}
|
|
2354
|
+
};
|
|
2232
2355
|
const handle = (0, import_react16.useMemo)(
|
|
2233
2356
|
() => buildAssessmentHandle({
|
|
2234
2357
|
checkId,
|
|
@@ -2248,20 +2371,33 @@ function FillInTheBlanksInner(props, ref) {
|
|
|
2248
2371
|
getCurrentState: () => ({ values, passed, showSolutions, submitted }),
|
|
2249
2372
|
resume: (state) => {
|
|
2250
2373
|
const raw = state.values;
|
|
2251
|
-
|
|
2374
|
+
let nextValues = values;
|
|
2375
|
+
if (raw && typeof raw === "object") {
|
|
2376
|
+
nextValues = { ...raw };
|
|
2377
|
+
setValues(nextValues);
|
|
2378
|
+
}
|
|
2379
|
+
let nextPassed = passed;
|
|
2380
|
+
let nextSubmitted = submitted;
|
|
2252
2381
|
readBooleanStateField(state, "passed", (value) => {
|
|
2382
|
+
nextPassed = value;
|
|
2253
2383
|
setPassed(value);
|
|
2254
2384
|
completedRef.current = value;
|
|
2255
2385
|
answeredRef.current = value;
|
|
2256
2386
|
});
|
|
2257
2387
|
readBooleanStateField(state, "showSolutions", setShowSolutions);
|
|
2258
2388
|
readBooleanStateField(state, "submitted", (value) => {
|
|
2389
|
+
nextSubmitted = value;
|
|
2259
2390
|
setSubmitted(value);
|
|
2260
2391
|
if (value) answeredRef.current = true;
|
|
2261
2392
|
});
|
|
2393
|
+
let nextScore = 0;
|
|
2394
|
+
blanks.forEach((b) => {
|
|
2395
|
+
if ((nextValues[b.id] ?? "").trim().toLowerCase() === b.answer.toLowerCase()) nextScore += 1;
|
|
2396
|
+
});
|
|
2397
|
+
replayTelemetry(nextValues, nextPassed, nextSubmitted, nextScore, blanks.length);
|
|
2262
2398
|
}
|
|
2263
2399
|
}),
|
|
2264
|
-
[allFilled, checkId, maxScore, passed, passedThreshold, score, showSolutions, submitted, values]
|
|
2400
|
+
[allFilled, assessment, blanks, checkId, maxScore, passed, passedThreshold, props.passingScore, props.template, score, showSolutions, submitted, values]
|
|
2265
2401
|
);
|
|
2266
2402
|
useAssessmentHandleRegistration(checkId, handle, ref);
|
|
2267
2403
|
const check = () => {
|
|
@@ -2272,7 +2408,10 @@ function FillInTheBlanksInner(props, ref) {
|
|
|
2272
2408
|
return;
|
|
2273
2409
|
}
|
|
2274
2410
|
if (!allFilled) return;
|
|
2275
|
-
if (
|
|
2411
|
+
if (passed) return;
|
|
2412
|
+
const snapshot = JSON.stringify(values);
|
|
2413
|
+
if (checkSnapshotRef.current === snapshot) return;
|
|
2414
|
+
checkSnapshotRef.current = snapshot;
|
|
2276
2415
|
answeredRef.current = true;
|
|
2277
2416
|
setSubmitted(true);
|
|
2278
2417
|
assessment.answer({
|
|
@@ -2297,12 +2436,13 @@ function FillInTheBlanksInner(props, ref) {
|
|
|
2297
2436
|
(0, import_react16.useEffect)(() => {
|
|
2298
2437
|
if (!allFilled) {
|
|
2299
2438
|
answeredRef.current = false;
|
|
2439
|
+
checkSnapshotRef.current = null;
|
|
2300
2440
|
setSubmitted(false);
|
|
2301
2441
|
}
|
|
2302
2442
|
}, [allFilled]);
|
|
2303
2443
|
(0, import_react16.useEffect)(() => {
|
|
2304
|
-
if (props.autoCheck && allFilled) check();
|
|
2305
|
-
}, [allFilled, props.autoCheck, values, passedThreshold]);
|
|
2444
|
+
if (props.autoCheck && allFilled && !passed) check();
|
|
2445
|
+
}, [allFilled, props.autoCheck, values, passedThreshold, passed]);
|
|
2306
2446
|
const reveal = showSolutions || passed && props.enableSolutionsButton;
|
|
2307
2447
|
return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("section", { "aria-label": "Fill in the Blanks", "data-lk-check-id": checkId, children: [
|
|
2308
2448
|
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { children: parsed.parts.map((part, i) => {
|
|
@@ -2361,9 +2501,13 @@ function DragTheWordsInner(props, ref) {
|
|
|
2361
2501
|
const [submitted, setSubmitted] = (0, import_react17.useState)(false);
|
|
2362
2502
|
const completedRef = (0, import_react17.useRef)(false);
|
|
2363
2503
|
const answeredRef = (0, import_react17.useRef)(false);
|
|
2504
|
+
const checkSnapshotRef = (0, import_react17.useRef)(null);
|
|
2505
|
+
const telemetryReplayedRef = (0, import_react17.useRef)(false);
|
|
2364
2506
|
const reset = () => {
|
|
2365
2507
|
completedRef.current = false;
|
|
2366
2508
|
answeredRef.current = false;
|
|
2509
|
+
checkSnapshotRef.current = null;
|
|
2510
|
+
telemetryReplayedRef.current = false;
|
|
2367
2511
|
setPassed(false);
|
|
2368
2512
|
setSubmitted(false);
|
|
2369
2513
|
setZones(Object.fromEntries(answers.map((_, i) => [`zone-${i}`, ""])));
|
|
@@ -2381,6 +2525,31 @@ function DragTheWordsInner(props, ref) {
|
|
|
2381
2525
|
});
|
|
2382
2526
|
const maxScore = answers.length;
|
|
2383
2527
|
const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
|
|
2528
|
+
const replayTelemetry = (nextZones, nextPassed, nextSubmitted, nextScore, nextMaxScore) => {
|
|
2529
|
+
if (telemetryReplayedRef.current || !nextSubmitted && !nextPassed) return;
|
|
2530
|
+
telemetryReplayedRef.current = true;
|
|
2531
|
+
const nextPassedThreshold = meetsPassingThreshold(
|
|
2532
|
+
nextScore,
|
|
2533
|
+
nextMaxScore || 1,
|
|
2534
|
+
props.passingScore
|
|
2535
|
+
);
|
|
2536
|
+
assessment.answer({
|
|
2537
|
+
checkId,
|
|
2538
|
+
interactionType: INTERACTION4,
|
|
2539
|
+
question: props.template,
|
|
2540
|
+
response: nextZones,
|
|
2541
|
+
correct: nextPassedThreshold
|
|
2542
|
+
});
|
|
2543
|
+
if (nextPassed || nextPassedThreshold) {
|
|
2544
|
+
assessment.complete({
|
|
2545
|
+
checkId,
|
|
2546
|
+
interactionType: INTERACTION4,
|
|
2547
|
+
score: nextScore,
|
|
2548
|
+
maxScore: nextMaxScore,
|
|
2549
|
+
passingScore: props.passingScore ?? nextMaxScore
|
|
2550
|
+
});
|
|
2551
|
+
}
|
|
2552
|
+
};
|
|
2384
2553
|
const handle = (0, import_react17.useMemo)(
|
|
2385
2554
|
() => buildAssessmentHandle({
|
|
2386
2555
|
checkId,
|
|
@@ -2401,22 +2570,35 @@ function DragTheWordsInner(props, ref) {
|
|
|
2401
2570
|
getCurrentState: () => ({ zones, pool, passed, keyboardWord, submitted }),
|
|
2402
2571
|
resume: (state) => {
|
|
2403
2572
|
const rawZones = state.zones;
|
|
2404
|
-
|
|
2573
|
+
let nextZones = zones;
|
|
2574
|
+
if (rawZones && typeof rawZones === "object") {
|
|
2575
|
+
nextZones = { ...rawZones };
|
|
2576
|
+
setZones(nextZones);
|
|
2577
|
+
}
|
|
2405
2578
|
if (Array.isArray(state.pool)) setPool([...state.pool]);
|
|
2579
|
+
let nextPassed = passed;
|
|
2580
|
+
let nextSubmitted = submitted;
|
|
2406
2581
|
readBooleanStateField(state, "passed", (value) => {
|
|
2582
|
+
nextPassed = value;
|
|
2407
2583
|
setPassed(value);
|
|
2408
2584
|
completedRef.current = value;
|
|
2409
2585
|
answeredRef.current = value;
|
|
2410
2586
|
});
|
|
2411
2587
|
readBooleanStateField(state, "submitted", (value) => {
|
|
2588
|
+
nextSubmitted = value;
|
|
2412
2589
|
setSubmitted(value);
|
|
2413
2590
|
if (value) answeredRef.current = true;
|
|
2414
2591
|
});
|
|
2415
2592
|
const kw = state.keyboardWord;
|
|
2416
2593
|
if (kw === null || typeof kw === "string") setKeyboardWord(kw ?? null);
|
|
2594
|
+
let nextScore = 0;
|
|
2595
|
+
answers.forEach((ans, i) => {
|
|
2596
|
+
if ((nextZones[`zone-${i}`] ?? "").trim().toLowerCase() === ans.toLowerCase()) nextScore += 1;
|
|
2597
|
+
});
|
|
2598
|
+
replayTelemetry(nextZones, nextPassed, nextSubmitted, nextScore, answers.length);
|
|
2417
2599
|
}
|
|
2418
2600
|
}),
|
|
2419
|
-
[allFilled, checkId, keyboardWord, maxScore, passed, passedThreshold, pool, score, submitted, zones]
|
|
2601
|
+
[allFilled, answers, assessment, checkId, keyboardWord, maxScore, passed, passedThreshold, pool, props.passingScore, props.template, score, submitted, zones]
|
|
2420
2602
|
);
|
|
2421
2603
|
useAssessmentHandleRegistration(checkId, handle, ref);
|
|
2422
2604
|
const placeInZone = (zoneId, word) => {
|
|
@@ -2446,7 +2628,10 @@ function DragTheWordsInner(props, ref) {
|
|
|
2446
2628
|
return;
|
|
2447
2629
|
}
|
|
2448
2630
|
if (!allFilled) return;
|
|
2449
|
-
if (
|
|
2631
|
+
if (passed) return;
|
|
2632
|
+
const snapshot = JSON.stringify(zones);
|
|
2633
|
+
if (checkSnapshotRef.current === snapshot) return;
|
|
2634
|
+
checkSnapshotRef.current = snapshot;
|
|
2450
2635
|
answeredRef.current = true;
|
|
2451
2636
|
setSubmitted(true);
|
|
2452
2637
|
assessment.answer({
|
|
@@ -2471,12 +2656,13 @@ function DragTheWordsInner(props, ref) {
|
|
|
2471
2656
|
(0, import_react17.useEffect)(() => {
|
|
2472
2657
|
if (!allFilled) {
|
|
2473
2658
|
answeredRef.current = false;
|
|
2659
|
+
checkSnapshotRef.current = null;
|
|
2474
2660
|
setSubmitted(false);
|
|
2475
2661
|
}
|
|
2476
2662
|
}, [allFilled]);
|
|
2477
2663
|
(0, import_react17.useEffect)(() => {
|
|
2478
|
-
if (props.autoCheck && allFilled) check();
|
|
2479
|
-
}, [allFilled, props.autoCheck, zones, passedThreshold]);
|
|
2664
|
+
if (props.autoCheck && allFilled && !passed) check();
|
|
2665
|
+
}, [allFilled, props.autoCheck, zones, passedThreshold, passed]);
|
|
2480
2666
|
return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("section", { "aria-label": "Drag the Words", "data-lk-check-id": checkId, children: [
|
|
2481
2667
|
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)("p", { children: "Drag words into the blanks (or select a word, then activate a blank)." }),
|
|
2482
2668
|
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { role: "list", "aria-label": "Word bank", "data-testid": "word-bank", children: pool.map((word) => /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
|
|
@@ -2876,6 +3062,8 @@ function useCompoundPersistence(opts) {
|
|
|
2876
3062
|
}, [ctx, opts.index, opts.pageCount]);
|
|
2877
3063
|
const buildStateRef = (0, import_react21.useRef)(buildState);
|
|
2878
3064
|
buildStateRef.current = buildState;
|
|
3065
|
+
const persistNowRef = (0, import_react21.useRef)(() => {
|
|
3066
|
+
});
|
|
2879
3067
|
const finalizeHydration = (0, import_react21.useCallback)(
|
|
2880
3068
|
(childStates) => {
|
|
2881
3069
|
loadedChildStatesRef.current = {
|
|
@@ -2884,6 +3072,7 @@ function useCompoundPersistence(opts) {
|
|
|
2884
3072
|
};
|
|
2885
3073
|
skipSaveUntilHydratedRef.current = false;
|
|
2886
3074
|
pendingChildResumeRef.current = null;
|
|
3075
|
+
queueMicrotask(() => persistNowRef.current());
|
|
2887
3076
|
},
|
|
2888
3077
|
[]
|
|
2889
3078
|
);
|
|
@@ -2896,6 +3085,14 @@ function useCompoundPersistence(opts) {
|
|
|
2896
3085
|
alreadyResumed: resumedChildKeysRef.current
|
|
2897
3086
|
});
|
|
2898
3087
|
if (!applied) {
|
|
3088
|
+
if (handles.size === 0) {
|
|
3089
|
+
const registeredOnly2 = stripOrphanChildStates(handles, pending.childStates);
|
|
3090
|
+
resumeChildHandles(handles, registeredOnly2, {
|
|
3091
|
+
alreadyResumed: resumedChildKeysRef.current
|
|
3092
|
+
});
|
|
3093
|
+
finalizeHydration(registeredOnly2);
|
|
3094
|
+
return;
|
|
3095
|
+
}
|
|
2899
3096
|
const handlesAtWait = handles.size;
|
|
2900
3097
|
queueMicrotask(() => {
|
|
2901
3098
|
if (pendingChildResumeRef.current !== pending) return;
|
|
@@ -2929,8 +3126,12 @@ function useCompoundPersistence(opts) {
|
|
|
2929
3126
|
});
|
|
2930
3127
|
const persistNow = (0, import_react21.useCallback)(() => {
|
|
2931
3128
|
if (!opts.enabled || !opts.courseId) return;
|
|
3129
|
+
if (skipSaveUntilHydratedRef.current) return;
|
|
2932
3130
|
saveResume(buildStateRef.current());
|
|
2933
3131
|
}, [opts.enabled, opts.courseId, saveResume]);
|
|
3132
|
+
(0, import_react21.useEffect)(() => {
|
|
3133
|
+
persistNowRef.current = persistNow;
|
|
3134
|
+
}, [persistNow]);
|
|
2934
3135
|
const notifyImperativeResume = (0, import_react21.useCallback)(
|
|
2935
3136
|
(state) => {
|
|
2936
3137
|
const clamped = (0, import_core14.clampCompoundPageIndex)(state.activePageIndex, opts.pageCount);
|
|
@@ -2952,12 +3153,12 @@ function useCompoundPersistence(opts) {
|
|
|
2952
3153
|
}
|
|
2953
3154
|
};
|
|
2954
3155
|
}, [bridgeRef, notifyImperativeResume]);
|
|
2955
|
-
(0, import_react21.useEffect)(() => {
|
|
2956
|
-
persistNow();
|
|
2957
|
-
}, [persistNow, opts.index, opts.pageCount, handlesVersion]);
|
|
2958
3156
|
(0, import_react21.useEffect)(() => {
|
|
2959
3157
|
applyPendingChildResume();
|
|
2960
3158
|
}, [opts.index, handlesVersion, applyPendingChildResume]);
|
|
3159
|
+
(0, import_react21.useEffect)(() => {
|
|
3160
|
+
persistNow();
|
|
3161
|
+
}, [persistNow, opts.index, opts.pageCount, handlesVersion]);
|
|
2961
3162
|
(0, import_react21.useEffect)(() => {
|
|
2962
3163
|
if (!opts.enabled || !opts.courseId || typeof document === "undefined") return;
|
|
2963
3164
|
const flushOnExit = () => {
|
|
@@ -3900,13 +4101,34 @@ function FindHotspotInner(props, ref) {
|
|
|
3900
4101
|
const checkId = (0, import_react36.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
|
|
3901
4102
|
const [selected, setSelected] = (0, import_react36.useState)(null);
|
|
3902
4103
|
const [checked, setChecked] = (0, import_react36.useState)(false);
|
|
4104
|
+
const telemetryReplayedRef = (0, import_react36.useRef)(false);
|
|
3903
4105
|
const assessment = useAssessmentState(props.enclosingLessonId);
|
|
3904
4106
|
const targetIdsKey = props.targets.map((t) => t.id).join("\0");
|
|
3905
4107
|
(0, import_react36.useEffect)(() => {
|
|
3906
4108
|
setSelected(null);
|
|
3907
4109
|
setChecked(false);
|
|
4110
|
+
telemetryReplayedRef.current = false;
|
|
3908
4111
|
}, [checkId, props.correctTargetId, targetIdsKey]);
|
|
3909
4112
|
const correct = selected === props.correctTargetId;
|
|
4113
|
+
const replayTelemetry = (nextSelected, nextChecked, nextCorrect) => {
|
|
4114
|
+
if (telemetryReplayedRef.current || !nextChecked || nextSelected === null) return;
|
|
4115
|
+
telemetryReplayedRef.current = true;
|
|
4116
|
+
assessment.answer({
|
|
4117
|
+
checkId,
|
|
4118
|
+
interactionType: INTERACTION6,
|
|
4119
|
+
response: nextSelected,
|
|
4120
|
+
correct: nextCorrect
|
|
4121
|
+
});
|
|
4122
|
+
if (nextCorrect) {
|
|
4123
|
+
assessment.complete({
|
|
4124
|
+
checkId,
|
|
4125
|
+
interactionType: INTERACTION6,
|
|
4126
|
+
score: 1,
|
|
4127
|
+
maxScore: 1,
|
|
4128
|
+
passingScore: props.passingScore ?? 1
|
|
4129
|
+
});
|
|
4130
|
+
}
|
|
4131
|
+
};
|
|
3910
4132
|
const handle = (0, import_react36.useMemo)(
|
|
3911
4133
|
() => buildAssessmentHandle({
|
|
3912
4134
|
checkId,
|
|
@@ -3916,6 +4138,7 @@ function FindHotspotInner(props, ref) {
|
|
|
3916
4138
|
resetTask: () => {
|
|
3917
4139
|
setSelected(null);
|
|
3918
4140
|
setChecked(false);
|
|
4141
|
+
telemetryReplayedRef.current = false;
|
|
3919
4142
|
},
|
|
3920
4143
|
showSolutions: () => setSelected(props.correctTargetId),
|
|
3921
4144
|
getXAPIData: () => ({
|
|
@@ -3928,15 +4151,23 @@ function FindHotspotInner(props, ref) {
|
|
|
3928
4151
|
}),
|
|
3929
4152
|
getCurrentState: () => ({ selected, checked }),
|
|
3930
4153
|
resume: (state) => {
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
4154
|
+
let nextSelected = selected;
|
|
4155
|
+
const rawSelected = readStringField(state, "selected");
|
|
4156
|
+
if (typeof rawSelected === "string" || rawSelected === null) {
|
|
4157
|
+
const valid = rawSelected === null || props.targets.some((t) => t.id === rawSelected);
|
|
4158
|
+
nextSelected = valid ? rawSelected : null;
|
|
4159
|
+
setSelected(nextSelected);
|
|
3935
4160
|
}
|
|
3936
|
-
|
|
4161
|
+
let nextChecked = checked;
|
|
4162
|
+
readBooleanStateField(state, "checked", (value) => {
|
|
4163
|
+
nextChecked = value;
|
|
4164
|
+
setChecked(value);
|
|
4165
|
+
});
|
|
4166
|
+
const nextCorrect = nextSelected === props.correctTargetId;
|
|
4167
|
+
replayTelemetry(nextSelected, nextChecked, nextCorrect);
|
|
3937
4168
|
}
|
|
3938
4169
|
}),
|
|
3939
|
-
[
|
|
4170
|
+
[assessment, checkId, checked, correct, props.correctTargetId, props.passingScore, props.targets, selected]
|
|
3940
4171
|
);
|
|
3941
4172
|
useAssessmentHandleRegistration(checkId, handle, ref);
|
|
3942
4173
|
const selectTarget = (id) => {
|
package/dist/index.d.cts
CHANGED
|
@@ -4,6 +4,7 @@ import * as _lessonkit_core from '@lessonkit/core';
|
|
|
4
4
|
import { TelemetryEvent, CourseId, TelemetryUser, TrackingClient, LessonkitPlugin, ProgressState, StoragePort, LessonId, TelemetryEventName, TelemetryDataFor, PluginHost, AssessmentHandle, BlockId, AssessmentBaseProps, AssessmentBehaviour, CompoundHandle, AssessmentAnsweredData, AssessmentCompletedData, CheckId } from '@lessonkit/core';
|
|
5
5
|
export { AssessmentAnsweredData, AssessmentBaseProps, AssessmentBehaviour, AssessmentCompletedData, AssessmentHandle, AssessmentInteractionType, AssessmentScoreInput, AssessmentScoreResult, AssessmentXAPIData, CompoundBaseProps, CompoundHandle, CompoundResumeState, InteractionBlockRegistration, LessonkitPlugin, LessonkitPluginContext, LessonkitPluginKind, PluginHost, PluginRegistry, TelemetryPipelineSink, buildTelemetryEvent, createLessonkitRuntime, createPluginRegistry, createTelemetryPipeline, defineAssessmentPlugin, defineLifecyclePlugin, defineTelemetryPlugin } from '@lessonkit/core';
|
|
6
6
|
import { McqAssessmentDescriptor } from '@lessonkit/lxpack';
|
|
7
|
+
import * as _lessonkit_xapi from '@lessonkit/xapi';
|
|
7
8
|
import { XAPITransport, XAPIClient } from '@lessonkit/xapi';
|
|
8
9
|
import { LxpackBridgeMode } from '@lessonkit/lxpack/bridge';
|
|
9
10
|
import { LessonkitThemeV1, ThemePresetName, PartialLessonkitThemeV1 } from '@lessonkit/themes';
|
|
@@ -37,6 +38,8 @@ type LessonkitConfig = {
|
|
|
37
38
|
enabled?: boolean;
|
|
38
39
|
sink?: (event: Parameters<TrackingClient["track"]>[0]) => void | Promise<void>;
|
|
39
40
|
batchSink?: (events: Parameters<TrackingClient["track"]>[0][]) => void | Promise<void>;
|
|
41
|
+
/** Keepalive batch delivery for pagehide (e.g. from createFetchBatchSink). */
|
|
42
|
+
exitBatchSink?: (events: Parameters<TrackingClient["track"]>[0][]) => void | Promise<void>;
|
|
40
43
|
batch?: {
|
|
41
44
|
enabled?: boolean;
|
|
42
45
|
flushIntervalMs?: number;
|
|
@@ -46,6 +49,8 @@ type LessonkitConfig = {
|
|
|
46
49
|
xapi?: {
|
|
47
50
|
enabled?: boolean;
|
|
48
51
|
transport?: XAPITransport;
|
|
52
|
+
/** Keepalive transport for pagehide (e.g. from createFetchTransport). */
|
|
53
|
+
exitTransport?: _lessonkit_xapi.XAPIExitTransport;
|
|
49
54
|
client?: XAPIClient;
|
|
50
55
|
};
|
|
51
56
|
lxpack?: {
|