From 2ed12469f31ba465020b6a0286539da48fd98e46 Mon Sep 17 00:00:00 2001 From: Agrendalath Date: Wed, 25 Sep 2024 19:45:09 +0200 Subject: [PATCH] fix: send XBlock visibility status to the LMS --- package-lock.json | 1 + package.json | 1 + .../sequence/Unit/hooks/useIFrameBehavior.js | 44 +++++++++++++ .../Unit/hooks/useIFrameBehavior.test.js | 61 ++++++++++++++++++- 4 files changed, 106 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index c7d6c4c1c4..0553cfeb52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "husky": "7.0.4", "joi": "^17.11.0", "js-cookie": "3.0.5", + "lodash": "^4.17.21", "lodash.camelcase": "4.3.0", "patch-package": "^8.0.0", "postcss-loader": "^8.1.1", diff --git a/package.json b/package.json index 2ee3eb1e75..799d0e2ba5 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "husky": "7.0.4", "joi": "^17.11.0", "js-cookie": "3.0.5", + "lodash": "^4.17.21", "lodash.camelcase": "4.3.0", "patch-package": "^8.0.0", "postcss-loader": "^8.1.1", diff --git a/src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.js b/src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.js index 71282a7b31..ab40436a70 100644 --- a/src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.js +++ b/src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.js @@ -2,6 +2,7 @@ import { getConfig } from '@edx/frontend-platform'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import React from 'react'; import { useDispatch } from 'react-redux'; +import { throttle } from 'lodash'; import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils'; import { logError } from '@edx/frontend-platform/logging'; @@ -85,6 +86,49 @@ const useIFrameBehavior = ({ useEventListener('message', receiveMessage); + // Send visibility status to the iframe. It's used to mark XBlocks as viewed. + React.useEffect(() => { + if (!hasLoaded) { + return undefined; + } + + const iframeElement = document.getElementById(elementId); + if (!iframeElement || !iframeElement.contentWindow) { + return undefined; + } + + const updateIframeVisibility = () => { + const rect = iframeElement.getBoundingClientRect(); + const visibleInfo = { + type: 'unit.visibilityStatus', + data: { + topPosition: rect.top, + viewportHeight: window.innerHeight, + }, + }; + iframeElement.contentWindow.postMessage( + visibleInfo, + `${getConfig().LMS_BASE_URL}`, + ); + }; + + // Throttle the update function to prevent it from sending too many messages to the iframe. + const throttledUpdateVisibility = throttle(updateIframeVisibility, 100); + + // Update the visibility of the iframe in case the element is already visible. + updateIframeVisibility(); + + // Add event listeners to update the visibility of the iframe when the window is scrolled or resized. + window.addEventListener('scroll', throttledUpdateVisibility); + window.addEventListener('resize', throttledUpdateVisibility); + + // Clean up event listeners on unmount. + return () => { + window.removeEventListener('scroll', throttledUpdateVisibility); + window.removeEventListener('resize', throttledUpdateVisibility); + }; + }, [hasLoaded, elementId]); + /** * onLoad *should* only fire after everything in the iframe has finished its own load events. * Which means that the plugin.resize message (which calls setHasLoaded above) will have fired already diff --git a/src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.test.js b/src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.test.js index 28b23faadf..eb70e17e53 100644 --- a/src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.test.js +++ b/src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.test.js @@ -30,6 +30,11 @@ jest.mock('react-redux', () => ({ useDispatch: jest.fn(), })); +jest.mock('lodash', () => ({ + ...jest.requireActual('lodash'), + throttle: jest.fn((fn) => fn), +})); + jest.mock('./useLoadBearingHook', () => jest.fn()); jest.mock('@edx/frontend-platform/logging', () => ({ @@ -64,7 +69,10 @@ const dispatch = jest.fn(); useDispatch.mockReturnValue(dispatch); const postMessage = jest.fn(); -const frame = { contentWindow: { postMessage } }; +const frame = { + contentWindow: { postMessage }, + getBoundingClientRect: jest.fn(() => ({ top: 100 })), +}; const mockGetElementById = jest.fn(() => frame); const testHash = '#test-hash'; @@ -87,6 +95,10 @@ describe('useIFrameBehavior hook', () => { beforeEach(() => { jest.clearAllMocks(); state.mock(); + global.document.getElementById = mockGetElementById; + global.window.addEventListener = jest.fn(); + global.window.removeEventListener = jest.fn(); + global.window.innerHeight = 800; }); afterEach(() => { state.resetVals(); @@ -265,6 +277,53 @@ describe('useIFrameBehavior hook', () => { }); }); }); + describe('visibility tracking', () => { + it('sets up visibility tracking after iframe has loaded', () => { + state.mockVals({ ...defaultStateVals, hasLoaded: true }); + useIFrameBehavior(props); + + const effects = getEffects([true, props.elementId], React); + expect(effects.length).toEqual(2); + effects[0](); // Execute the visibility tracking effect. + + expect(global.window.addEventListener).toHaveBeenCalledTimes(2); + expect(global.window.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(global.window.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function)); + // Initial visibility update. + expect(postMessage).toHaveBeenCalledWith( + { + type: 'unit.visibilityStatus', + data: { + topPosition: 100, + viewportHeight: 800, + }, + }, + config.LMS_BASE_URL, + ); + }); + it('does not set up visibility tracking before iframe has loaded', () => { + state.mockVals({ ...defaultStateVals, hasLoaded: false }); + useIFrameBehavior(props); + + const effects = getEffects([false, props.elementId], React); + expect(effects).toBeNull(); + + expect(global.window.addEventListener).not.toHaveBeenCalled(); + expect(postMessage).not.toHaveBeenCalled(); + }); + it('cleans up event listeners on unmount', () => { + state.mockVals({ ...defaultStateVals, hasLoaded: true }); + useIFrameBehavior(props); + + const effects = getEffects([true, props.elementId], React); + const cleanup = effects[0](); // Execute the effect and get the cleanup function. + cleanup(); // Call the cleanup function. + + expect(global.window.removeEventListener).toHaveBeenCalledTimes(2); + expect(global.window.removeEventListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(global.window.removeEventListener).toHaveBeenCalledWith('resize', expect.any(Function)); + }); + }); }); describe('output', () => { describe('handleIFrameLoad', () => {