Skip to content

Commit 1774b3f

Browse files
committed
fix: send XBlock visibility status to the LMS
1 parent a22759a commit 1774b3f

File tree

4 files changed

+106
-1
lines changed

4 files changed

+106
-1
lines changed

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"history": "5.3.0",
5353
"joi": "^17.11.0",
5454
"js-cookie": "3.0.5",
55+
"lodash": "^4.17.21",
5556
"lodash.camelcase": "4.3.0",
5657
"prop-types": "15.8.1",
5758
"query-string": "^7.1.3",

src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getConfig } from '@edx/frontend-platform';
22
import React from 'react';
33
import { useDispatch } from 'react-redux';
4+
import { throttle } from 'lodash';
45

56
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
67
import { logError } from '@edx/frontend-platform/logging';
@@ -84,6 +85,49 @@ const useIFrameBehavior = ({
8485

8586
useEventListener('message', receiveMessage);
8687

88+
// Send visibility status to the iframe. It's used to mark XBlocks as viewed.
89+
React.useEffect(() => {
90+
if (!hasLoaded) {
91+
return undefined;
92+
}
93+
94+
const iframeElement = document.getElementById(elementId);
95+
if (!iframeElement || !iframeElement.contentWindow) {
96+
return undefined;
97+
}
98+
99+
const updateIframeVisibility = () => {
100+
const rect = iframeElement.getBoundingClientRect();
101+
const visibleInfo = {
102+
type: 'unit.visibilityStatus',
103+
data: {
104+
topPosition: rect.top,
105+
viewportHeight: window.innerHeight,
106+
},
107+
};
108+
iframeElement.contentWindow.postMessage(
109+
visibleInfo,
110+
`${getConfig().LMS_BASE_URL}`,
111+
);
112+
};
113+
114+
// Throttle the update function to prevent it from sending too many messages to the iframe.
115+
const throttledUpdateVisibility = throttle(updateIframeVisibility, 100);
116+
117+
// Update the visibility of the iframe in case the element is already visible.
118+
updateIframeVisibility();
119+
120+
// Add event listeners to update the visibility of the iframe when the window is scrolled or resized.
121+
window.addEventListener('scroll', throttledUpdateVisibility);
122+
window.addEventListener('resize', throttledUpdateVisibility);
123+
124+
// Clean up event listeners on unmount.
125+
return () => {
126+
window.removeEventListener('scroll', throttledUpdateVisibility);
127+
window.removeEventListener('resize', throttledUpdateVisibility);
128+
};
129+
}, [hasLoaded, elementId]);
130+
87131
/**
88132
* onLoad *should* only fire after everything in the iframe has finished its own load events.
89133
* Which means that the plugin.resize message (which calls setHasLoaded above) will have fired already

src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.test.js

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ jest.mock('react-redux', () => ({
2727
useDispatch: jest.fn(),
2828
}));
2929

30+
jest.mock('lodash', () => ({
31+
...jest.requireActual('lodash'),
32+
throttle: jest.fn((fn) => fn),
33+
}));
34+
3035
jest.mock('./useLoadBearingHook', () => jest.fn());
3136

3237
jest.mock('@edx/frontend-platform/logging', () => ({
@@ -61,7 +66,10 @@ const dispatch = jest.fn();
6166
useDispatch.mockReturnValue(dispatch);
6267

6368
const postMessage = jest.fn();
64-
const frame = { contentWindow: { postMessage } };
69+
const frame = {
70+
contentWindow: { postMessage },
71+
getBoundingClientRect: jest.fn(() => ({ top: 100 })),
72+
};
6573
const mockGetElementById = jest.fn(() => frame);
6674
const testHash = '#test-hash';
6775

@@ -84,6 +92,10 @@ describe('useIFrameBehavior hook', () => {
8492
beforeEach(() => {
8593
jest.clearAllMocks();
8694
state.mock();
95+
global.document.getElementById = mockGetElementById;
96+
global.window.addEventListener = jest.fn();
97+
global.window.removeEventListener = jest.fn();
98+
global.window.innerHeight = 800;
8799
});
88100
afterEach(() => {
89101
state.resetVals();
@@ -262,6 +274,53 @@ describe('useIFrameBehavior hook', () => {
262274
});
263275
});
264276
});
277+
describe('visibility tracking', () => {
278+
it('sets up visibility tracking after iframe has loaded', () => {
279+
state.mockVals({ ...defaultStateVals, hasLoaded: true });
280+
useIFrameBehavior(props);
281+
282+
const effects = getEffects([true, props.elementId], React);
283+
expect(effects.length).toEqual(2);
284+
effects[0](); // Execute the visibility tracking effect.
285+
286+
expect(global.window.addEventListener).toHaveBeenCalledTimes(2);
287+
expect(global.window.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
288+
expect(global.window.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
289+
// Initial visibility update.
290+
expect(postMessage).toHaveBeenCalledWith(
291+
{
292+
type: 'unit.visibilityStatus',
293+
data: {
294+
topPosition: 100,
295+
viewportHeight: 800,
296+
},
297+
},
298+
config.LMS_BASE_URL,
299+
);
300+
});
301+
it('does not set up visibility tracking before iframe has loaded', () => {
302+
state.mockVals({ ...defaultStateVals, hasLoaded: false });
303+
useIFrameBehavior(props);
304+
305+
const effects = getEffects([false, props.elementId], React);
306+
expect(effects).toBeNull();
307+
308+
expect(global.window.addEventListener).not.toHaveBeenCalled();
309+
expect(postMessage).not.toHaveBeenCalled();
310+
});
311+
it('cleans up event listeners on unmount', () => {
312+
state.mockVals({ ...defaultStateVals, hasLoaded: true });
313+
useIFrameBehavior(props);
314+
315+
const effects = getEffects([true, props.elementId], React);
316+
const cleanup = effects[0](); // Execute the effect and get the cleanup function.
317+
cleanup(); // Call the cleanup function.
318+
319+
expect(global.window.removeEventListener).toHaveBeenCalledTimes(2);
320+
expect(global.window.removeEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
321+
expect(global.window.removeEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
322+
});
323+
});
265324
});
266325
describe('output', () => {
267326
describe('handleIFrameLoad', () => {

0 commit comments

Comments
 (0)