diff --git a/public/demo-html-trrack/assets/dots-count.html b/public/demo-html-trrack/assets/dots-count.html index 3c860c1437..52e0f064ac 100644 --- a/public/demo-html-trrack/assets/dots-count.html +++ b/public/demo-html-trrack/assets/dots-count.html @@ -142,7 +142,8 @@ // Initialize Trrack - const trrack = initializeTrrack({ + const trrack = Revisit.createTrrack({ + initializeTrrack, initialState, registry }); @@ -182,7 +183,6 @@ Revisit.postAnswers({ [taskId]: dots.length }); - Revisit.postProvenance(trrack.graph.backend); } // Register a listener to update the dot chart based on trrack updates. diff --git a/public/demo-svelte-trrack/assets/dots-count.html b/public/demo-svelte-trrack/assets/dots-count.html index 10d69adfb2..0272803ad4 100644 --- a/public/demo-svelte-trrack/assets/dots-count.html +++ b/public/demo-svelte-trrack/assets/dots-count.html @@ -77,7 +77,8 @@ } ); - const trrack = initializeTrrack({ + const trrack = Revisit.createTrrack({ + initializeTrrack, initialState, registry }); @@ -128,7 +129,6 @@ Revisit.postAnswers({ [taskId]: dots.length }); - Revisit.postProvenance(trrack.graph.backend); } trrack.currentChange(updateRevisit); diff --git a/public/revisitUtilities/revisit-communicate.js b/public/revisitUtilities/revisit-communicate.js index 3fcd766687..f604ff2f5d 100644 --- a/public/revisitUtilities/revisit-communicate.js +++ b/public/revisitUtilities/revisit-communicate.js @@ -21,6 +21,48 @@ let onDataReceiveCallback = null; let onProvenanceReceiveCallback = null; let onAnswerReceiveCallback = null; + const managedTrrackSubscriptions = new Set(); + + const appendTraversalEvent = (previousProvenance, incomingProvenance, observedAt) => { + const isSameGraph = previousProvenance?.root === incomingProvenance.root; + const previousEvents = isSameGraph && Array.isArray(previousProvenance?.traversalEvents) + ? previousProvenance.traversalEvents + : []; + const incomingEvents = Array.isArray(incomingProvenance.traversalEvents) + ? incomingProvenance.traversalEvents + : []; + const traversalEvents = incomingEvents.length >= previousEvents.length + ? incomingEvents + : previousEvents; + const currentNodeId = incomingProvenance.current; + const currentNode = incomingProvenance.nodes?.[currentNodeId]; + + if (!currentNode || traversalEvents.at(-1)?.nodeId === currentNodeId) { + return traversalEvents.length > 0 + ? { ...incomingProvenance, traversalEvents } + : incomingProvenance; + } + + const isNewNode = !isSameGraph || !previousProvenance?.nodes?.[currentNodeId]; + const eventTime = isNewNode ? currentNode.createdOn : observedAt; + const previousEventTime = traversalEvents.at(-1)?.createdOn ?? Number.NEGATIVE_INFINITY; + + return { + ...incomingProvenance, + traversalEvents: [ + ...traversalEvents, + { + nodeId: currentNodeId, + createdOn: Math.max(eventTime, previousEventTime + 1), + }, + ], + }; + }; + + const disposeManagedTrracks = () => { + managedTrrackSubscriptions.forEach((unsubscribe) => unsubscribe()); + managedTrrackSubscriptions.clear(); + }; window.addEventListener('message', function (e) { const data = e.data; @@ -41,9 +83,35 @@ postAnswers: (answers) => { sendMessage("ANSWERS", answers); }, + /** + * @deprecated Use Revisit.createTrrack so every traversal is reported automatically. + */ postProvenance: (provenance) => { sendMessage("PROVENANCE", provenance); }, + createTrrack: ({ initializeTrrack, ...options }) => { + if (typeof initializeTrrack !== 'function') { + throw new TypeError('Revisit.createTrrack requires the Trrack initializeTrrack function.'); + } + + const trrack = initializeTrrack(options); + let previousProvenance; + const publishProvenance = () => { + const provenance = appendTraversalEvent( + previousProvenance, + trrack.graph.backend, + Date.now(), + ); + previousProvenance = provenance; + sendMessage("PROVENANCE", provenance); + }; + const unsubscribe = trrack.currentChange(publishProvenance); + + managedTrrackSubscriptions.add(unsubscribe); + publishProvenance(); + + return trrack; + }, postEvent: (eventName, objectId) => { sendMessage("EVENT", { eventName, objectId }); }, @@ -72,4 +140,9 @@ }, false ); + window.addEventListener('pagehide', (event) => { + if (!event.persisted) { + disposeManagedTrracks(); + } + }); })(); diff --git a/src/components/audioAnalysis/provenanceReplay.ts b/src/components/audioAnalysis/provenanceReplay.ts index 818a8ffed7..ffad23baa6 100644 --- a/src/components/audioAnalysis/provenanceReplay.ts +++ b/src/components/audioAnalysis/provenanceReplay.ts @@ -5,29 +5,15 @@ export type ProvenanceReplaySelection = ProvenanceTraversalEvent & { fromTraversal: boolean; }; -export function getReplaySelection( +/** + * @deprecated Graph creation order cannot reproduce undo, redo, or arbitrary + * traversal. Retained only for provenance recorded before traversal events. + */ +function getLegacyReplaySelection( provenance: TrrackedProvenance, playTime: number, currentNode?: string | null, ): ProvenanceReplaySelection { - const traversalEvents = getProvenanceTraversalEvents(provenance); - - if (traversalEvents.length > 0) { - let replaySelection = { - nodeId: provenance.root as string, - createdOn: provenance.nodes[provenance.root].createdOn, - fromTraversal: true, - }; - - traversalEvents.forEach((event) => { - if (event.createdOn <= playTime) { - replaySelection = { ...event, fromTraversal: true }; - } - }); - - return replaySelection; - } - if (!currentNode || !provenance.nodes[currentNode]) { return { nodeId: provenance.root as string, @@ -65,6 +51,32 @@ export function getReplaySelection( }; } +export function getReplaySelection( + provenance: TrrackedProvenance, + playTime: number, + currentNode?: string | null, +): ProvenanceReplaySelection { + const traversalEvents = getProvenanceTraversalEvents(provenance); + + if (traversalEvents.length > 0) { + let replaySelection = { + nodeId: provenance.root as string, + createdOn: provenance.nodes[provenance.root].createdOn, + fromTraversal: true, + }; + + traversalEvents.forEach((event) => { + if (event.createdOn <= playTime) { + replaySelection = { ...event, fromTraversal: true }; + } + }); + + return replaySelection; + } + + return getLegacyReplaySelection(provenance, playTime, currentNode); +} + export function getReplayNodeId( provenance: TrrackedProvenance, playTime: number, diff --git a/src/components/response/ResponseBlock.tsx b/src/components/response/ResponseBlock.tsx index f278a9570e..4d0ad3656d 100644 --- a/src/components/response/ResponseBlock.tsx +++ b/src/components/response/ResponseBlock.tsx @@ -8,7 +8,7 @@ import React, { import isEqual from 'lodash.isequal'; import { IconAlertTriangle } from '@tabler/icons-react'; import { useNavigate } from 'react-router'; -import { Registry, initializeTrrack } from '@trrack/core'; +import { Registry } from '@trrack/core'; import { IndividualComponent, ResponseBlockLocation, @@ -33,7 +33,7 @@ import { shouldUseStimulusValidation } from './stimulusErrors'; import { ResponseSwitcher } from './ResponseSwitcher'; import { FeedbackAlert } from './FeedbackAlert'; import { - CustomResponseField, FormElementProvenance, StoredAnswer, + CustomResponseField, FormElementProvenance, StoredAnswer, TrrackedProvenance, } from '../../store/types'; import { useStudyConfig } from '../../store/hooks/useStudyConfig'; import { useStoredAnswer } from '../../store/hooks/useStoredAnswer'; @@ -42,6 +42,7 @@ import { useIsAnalysis } from '../../store/hooks/useIsAnalysis'; import { responseAnswerIsCorrect } from '../../utils/correctAnswer'; import { getCustomResponseModule, getCustomResponseModuleLoadError } from './customResponseModules'; import { appendStimulusShowErrorsToGraph } from './stimulusProvenance'; +import { useManagedTrrack } from '../../store/hooks/useRevisitTrrack'; import { useStorageEngine } from '../../storage/storageEngineHooks'; import { showNotification } from '../../utils/notifications'; import { getAnswersFromAllLocations } from '../../utils/getAnswersFromAllLocations'; @@ -71,10 +72,12 @@ export function ResponseBlock({ }: Props) { const storeDispatch = useStoreDispatch(); const { - updateResponseBlockValidation, saveIncorrectAnswer, saveTrialAnswer, setResponseSubmitAttempt, setStimulusSubmitAttempt, setCheckAnswerResult, + updateProvenance, updateResponseBlockValidation, saveIncorrectAnswer, saveTrialAnswer, setResponseSubmitAttempt, setStimulusSubmitAttempt, setCheckAnswerResult, } = useStoreActions(); const currentStep = useCurrentStep(); + const identifier = useCurrentIdentifier(); + const isAnalysis = useIsAnalysis(); const currentProvenance = useStoreSelector((state) => state.analysisProvState[location]) as FormElementProvenance | undefined; const storedAnswer = useMemo(() => currentProvenance?.form || status?.answer, [currentProvenance, status]); @@ -113,7 +116,7 @@ export function ResponseBlock({ }), [allResponses]); // Set up trrack to store provenance graph of the answerValidator status - const { actions, trrack } = useMemo(() => { + const { actions, registry } = useMemo(() => { const reg = Registry.create(); const updateFormAction = reg.register('update', (state, payload: StoredAnswer['answer']) => { @@ -125,22 +128,29 @@ export function ResponseBlock({ return state; }); - const trrackInst = initializeTrrack({ - registry: reg, - initialState: { - form: null, - showResponseErrors: false, - }, - }); - return { actions: { updateFormAction, updateShowResponseErrorsAction, }, - trrack: trrackInst, + registry: reg, }; }, []); + const reportProvenance = useCallback((provenanceGraph: TrrackedProvenance) => { + if (isAnalysis) return; + storeDispatch(updateProvenance({ + location, + identifier, + provenanceGraph, + })); + }, [identifier, isAnalysis, location, storeDispatch, updateProvenance]); + const trrack = useManagedTrrack({ + registry, + initialState: { + form: null, + showResponseErrors: false, + }, + }, reportProvenance, identifier); const reactiveAnswers = useStoreSelector((state) => state.reactiveAnswers); @@ -150,7 +160,6 @@ export function ResponseBlock({ const trialValidation = useStoreSelector((state) => state.trialValidation); const analysisProvState = useStoreSelector((state) => state.analysisProvState); const { goToNextStep } = useNextStep(); - const isAnalysis = useIsAnalysis(); const studyConfig = useStudyConfig(); @@ -158,7 +167,6 @@ export function ResponseBlock({ const hasCorrectAnswerFeedback = !!provideFeedback && ((config?.correctAnswer?.length || 0) > 0); const allowFailedTraining = useMemo(() => config?.allowFailedTraining ?? studyConfig.uiConfig.allowFailedTraining ?? true, [config, studyConfig]); const trainingAttempts = useMemo(() => config?.trainingAttempts ?? studyConfig.uiConfig.trainingAttempts ?? 2, [config, studyConfig]); - const identifier = useCurrentIdentifier(); const savedSubmitAttempt = storedAnswerData?.responseSubmitAttempted ?? status?.responseSubmitAttempted ?? false; const currentSubmitAttempt = useStoreSelector((state) => state.responseSubmitAttempted[identifier]); const liveErrors = currentSubmitAttempt ?? savedSubmitAttempt; @@ -395,7 +403,6 @@ export function ResponseBlock({ identifier, status: answerValidator.isValid(), values: structuredClone(answerValidator.values), - provenanceGraph: trrack.graph.backend, }); } // Disable exhaustive-deps because we only want this to run when there is a new storedAnswer @@ -449,7 +456,6 @@ export function ResponseBlock({ identifier, status: answerValidator.isValid() || bypassValidationForFailedTraining, values: structuredClone(answerValidator.values), - provenanceGraph: trrack.graph.backend, }), ); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -467,7 +473,6 @@ export function ResponseBlock({ identifier, status: answerValidator.isValid() || bypassValidationForFailedTraining, values: structuredClone(answerValidator.values), - provenanceGraph: trrack.graph.backend, }), ); }, [actions, answerValidator, bypassValidationForFailedTraining, identifier, isAnalysis, liveErrors, location, storeDispatch, trrack, updateResponseBlockValidation]); diff --git a/src/components/response/tests/ResponseBlock.spec.tsx b/src/components/response/tests/ResponseBlock.spec.tsx index 9f201944b1..2ad5477e88 100644 --- a/src/components/response/tests/ResponseBlock.spec.tsx +++ b/src/components/response/tests/ResponseBlock.spec.tsx @@ -73,6 +73,7 @@ vi.mock('@trrack/core', () => ({ }, initializeTrrack: vi.fn(() => ({ apply: vi.fn(), + currentChange: vi.fn(() => vi.fn()), graph: { backend: { root: 'root', diff --git a/src/controllers/IframeController.tsx b/src/controllers/IframeController.tsx index 926de75e23..1c1e27e2f5 100644 --- a/src/controllers/IframeController.tsx +++ b/src/controllers/IframeController.tsx @@ -12,7 +12,7 @@ const PREFIX = '@REVISIT_COMMS'; export function IframeController({ currentConfig, provState, answers }: { currentConfig: WebsiteComponent; provState?: unknown, answers: ParticipantData['answers'] }) { const { - setReactiveAnswers, updateResponseBlockValidation, + setReactiveAnswers, updateProvenance, updateResponseBlockValidation, } = useStoreActions(); const storeDispatch = useStoreDispatch(); const dispatch = useDispatch(); @@ -96,15 +96,10 @@ export function IframeController({ currentConfig, provState, answers }: { curren break; case `${PREFIX}/PROVENANCE`: { if (isAnalysis) return; - const currentStimulusValidation = stimulusValidationRef.current; - storeDispatch(updateResponseBlockValidation({ + storeDispatch(updateProvenance({ location: 'stimulus', identifier, - values: {}, - status: currentStimulusValidation?.valid ?? true, provenanceGraph: data.message, - reason: currentStimulusValidation?.reason, - message: currentStimulusValidation?.message, })); break; } @@ -117,7 +112,7 @@ export function IframeController({ currentConfig, provState, answers }: { curren window.addEventListener('message', handler); return () => window.removeEventListener('message', handler); - }, [storeDispatch, dispatch, iframeId, currentConfig, sendMessage, setReactiveAnswers, updateResponseBlockValidation, identifier, isAnalysis, provState, answers]); + }, [storeDispatch, dispatch, iframeId, currentConfig, sendMessage, setReactiveAnswers, updateProvenance, updateResponseBlockValidation, identifier, isAnalysis, provState, answers]); return (