Skip to content

Commit

Permalink
fix: send XBlock visibility status to the LMS
Browse files Browse the repository at this point in the history
  • Loading branch information
Agrendalath committed Sep 30, 2024
1 parent a22759a commit 1774b3f
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 1 deletion.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"history": "5.3.0",
"joi": "^17.11.0",
"js-cookie": "3.0.5",
"lodash": "^4.17.21",
"lodash.camelcase": "4.3.0",
"prop-types": "15.8.1",
"query-string": "^7.1.3",
Expand Down
44 changes: 44 additions & 0 deletions src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getConfig } from '@edx/frontend-platform';
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';
Expand Down Expand Up @@ -84,6 +85,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,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', () => ({
Expand Down Expand Up @@ -61,7 +66,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';

Expand All @@ -84,6 +92,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();
Expand Down Expand Up @@ -262,6 +274,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', () => {
Expand Down

0 comments on commit 1774b3f

Please sign in to comment.