Track viewing of individual blocks.
* Implement a ViewedEvent handling system which calls handlers when a block has been viewed for 5 seconds (configurable). * Hook up Verticals to register their children blocks with this event, and submit completions once seen. OSPR-2093 OC-3358
This commit is contained in:
committed by
Alex Dusenbery
parent
17ddd07838
commit
4946b6b296
@@ -1095,6 +1095,8 @@ COMPLETION_VIDEO_COMPLETE_PERCENTAGE = ENV_TOKENS.get(
|
||||
'COMPLETION_VIDEO_COMPLETE_PERCENTAGE',
|
||||
COMPLETION_VIDEO_COMPLETE_PERCENTAGE,
|
||||
)
|
||||
# The time a block needs to be viewed to be considered complete, in milliseconds.
|
||||
COMPLETION_BY_VIEWING_DELAY_MS = ENV_TOKENS.get('COMPLETION_BY_VIEWING_DELAY_MS', COMPLETION_BY_VIEWING_DELAY_MS)
|
||||
|
||||
############### Settings for django-fernet-fields ##################
|
||||
FERNET_KEYS = AUTH_TOKENS.get('FERNET_KEYS', FERNET_KEYS)
|
||||
|
||||
@@ -3447,6 +3447,7 @@ EDX_PLATFORM_REVISION = 'unknown'
|
||||
# Once a user has watched this percentage of a video, mark it as complete:
|
||||
# (0.0 = 0%, 1.0 = 100%)
|
||||
COMPLETION_VIDEO_COMPLETE_PERCENTAGE = 0.95
|
||||
COMPLETION_BY_VIEWING_DELAY_MS = 5000
|
||||
|
||||
############### Settings for Django Rate limit #####################
|
||||
RATELIMIT_ENABLE = True
|
||||
|
||||
7
lms/static/completion/js/.eslintrc.js
Normal file
7
lms/static/completion/js/.eslintrc.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
extends: 'eslint-config-edx',
|
||||
root: true,
|
||||
settings: {
|
||||
'import/resolver': 'webpack',
|
||||
},
|
||||
};
|
||||
182
lms/static/completion/js/ViewedEvent.js
Normal file
182
lms/static/completion/js/ViewedEvent.js
Normal file
@@ -0,0 +1,182 @@
|
||||
/** Ensure that a function is only run once every `wait` milliseconds */
|
||||
function throttle(fn, wait) {
|
||||
let time = 0;
|
||||
function delay() {
|
||||
// Do not call the function until at least `wait` seconds after the
|
||||
// last time the function was called.
|
||||
const now = Date.now();
|
||||
if (time + wait < now) {
|
||||
time = now;
|
||||
fn();
|
||||
}
|
||||
}
|
||||
return delay;
|
||||
}
|
||||
|
||||
|
||||
export class ElementViewing {
|
||||
/**
|
||||
* A wrapper for an HTMLElement that tracks whether the element has been
|
||||
* viewed or not.
|
||||
*/
|
||||
constructor(el, viewedAfterMs, callback) {
|
||||
this.el = el;
|
||||
this.viewedAfterMs = viewedAfterMs;
|
||||
this.callback = callback;
|
||||
|
||||
this.topSeen = false;
|
||||
this.bottomSeen = false;
|
||||
this.seenForMs = 0;
|
||||
this.becameVisibleAt = undefined;
|
||||
this.hasBeenViewed = false;
|
||||
}
|
||||
|
||||
getBoundingRect() {
|
||||
return this.el.getBoundingClientRect();
|
||||
}
|
||||
|
||||
/** This element has become visible on screen.
|
||||
*
|
||||
* (may be called even when already on screen though)
|
||||
*/
|
||||
handleVisible() {
|
||||
if (!this.becameVisibleAt) {
|
||||
this.becameVisibleAt = Date.now();
|
||||
// We're now visible; after viewedAfterMs, if the top and bottom have been
|
||||
// seen, this block will count as viewed.
|
||||
setTimeout(
|
||||
() => {
|
||||
this.checkIfViewed();
|
||||
},
|
||||
this.viewedAfterMs - this.seenForMs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleNotVisible() {
|
||||
if (this.becameVisibleAt) {
|
||||
this.seenForMs = Date.now() - this.becameVisibleAt;
|
||||
}
|
||||
this.becameVisibleAt = undefined;
|
||||
}
|
||||
|
||||
markTopSeen() {
|
||||
// If this element has been seen for enough time, but the top wasn't visible, it may now be
|
||||
// considered viewed.
|
||||
this.topSeen = true;
|
||||
this.checkIfViewed();
|
||||
}
|
||||
|
||||
markBottomSeen() {
|
||||
this.bottomSeen = true;
|
||||
this.checkIfViewed();
|
||||
}
|
||||
|
||||
getTotalTimeSeen() {
|
||||
if (this.becameVisibleAt) {
|
||||
return this.seenForMs + (Date.now() - this.becameVisibleAt);
|
||||
}
|
||||
return this.seenForMs;
|
||||
}
|
||||
|
||||
areViewedCriteriaMet() {
|
||||
return this.topSeen && this.bottomSeen && (this.getTotalTimeSeen() >= this.viewedAfterMs);
|
||||
}
|
||||
|
||||
checkIfViewed() {
|
||||
// User can provide a "now" value for testing purposes.
|
||||
if (this.hasBeenViewed) {
|
||||
return;
|
||||
}
|
||||
if (this.areViewedCriteriaMet()) {
|
||||
this.hasBeenViewed = true;
|
||||
// Report to the tracker that we have been viewed
|
||||
this.callback(this.el, { elementHasBeenViewed: this.hasBeenViewed });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class ViewedEventTracker {
|
||||
/**
|
||||
* When the top or bottom of an element is first viewed, and the entire
|
||||
* element is viewed for a specified amount of time, the callback is called,
|
||||
* passing the element that was viewed, and an event object having the
|
||||
* following field:
|
||||
*
|
||||
* * hasBeenViewed (bool): true if all the conditions for being
|
||||
* considered "viewed" have been met.
|
||||
*/
|
||||
constructor(elements, viewedAfterMs) {
|
||||
this.viewedAfterMs = viewedAfterMs;
|
||||
this.elementViewings = new Set();
|
||||
this.handlers = [];
|
||||
|
||||
this.interval = undefined;
|
||||
elements.forEach((el) => {
|
||||
this.elementViewings.add(
|
||||
new ElementViewing(
|
||||
el,
|
||||
viewedAfterMs,
|
||||
(element, event) => this.callHandlers(element, event),
|
||||
),
|
||||
);
|
||||
});
|
||||
this.registerDomHandlers();
|
||||
}
|
||||
|
||||
/** Register a new handler to be called when an element has been viewed. */
|
||||
addHandler(handler) {
|
||||
this.handlers.push(handler);
|
||||
}
|
||||
|
||||
/** Mark which elements are currently visible.
|
||||
*
|
||||
* Also marks when an elements top or bottom has been seen.
|
||||
* */
|
||||
updateVisible() {
|
||||
this.elementViewings.forEach((elv) => {
|
||||
if (elv.hasBeenViewed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now(); // Use the same "now" for all calculations
|
||||
const rect = elv.getBoundingRect();
|
||||
let visible = false;
|
||||
|
||||
if (rect.top > 0 && rect.top < window.innerHeight) {
|
||||
elv.markTopSeen(now);
|
||||
visible = true;
|
||||
}
|
||||
if (rect.bottom > 0 && rect.bottom < window.innerHeight) {
|
||||
elv.markBottomSeen(now);
|
||||
visible = true;
|
||||
}
|
||||
if (rect.top < 0 && rect.bottom > window.innerHeight) {
|
||||
visible = true;
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
elv.handleVisible(now);
|
||||
} else {
|
||||
elv.handleNotVisible(now);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
registerDomHandlers() {
|
||||
window.onscroll = throttle(() => this.updateVisible(), 100);
|
||||
window.onresize = throttle(() => this.updateVisible(), 100);
|
||||
this.updateVisible();
|
||||
}
|
||||
|
||||
/** Call the handlers for all newly-viewed elements and pause tracking
|
||||
* for recently disappeared elements.
|
||||
*/
|
||||
callHandlers(el, event) {
|
||||
this.handlers.forEach((handler) => {
|
||||
handler(el, event);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
95
lms/static/completion/js/spec/ViewedEvent_spec.js
Normal file
95
lms/static/completion/js/spec/ViewedEvent_spec.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { ElementViewing, ViewedEventTracker } from '../ViewedEvent';
|
||||
|
||||
|
||||
describe('ViewedTracker', () => {
|
||||
let existingHTML;
|
||||
beforeEach(() => {
|
||||
existingHTML = document.body.innerHTML;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = existingHTML;
|
||||
});
|
||||
|
||||
it('calls the handlers when an element is viewed', () => {
|
||||
document.body.innerHTML = '<div id="d1"></div><div id="d2"></div><div id="d3"></div>';
|
||||
const tracker = new ViewedEventTracker(Array.from(document.getElementsByTagName('div')), 1000);
|
||||
const handlerSpy = jasmine.createSpy('handlerSpy');
|
||||
tracker.addHandler(handlerSpy);
|
||||
const elvIter = tracker.elementViewings.values();
|
||||
// Pick two elements, and mock them so that one has met the criteria to be viewed,
|
||||
// and the other hasn't.
|
||||
const viewed = elvIter.next().value;
|
||||
spyOn(viewed, 'areViewedCriteriaMet').and.returnValue(true);
|
||||
viewed.checkIfViewed();
|
||||
expect(handlerSpy).toHaveBeenCalledWith(viewed.el, {
|
||||
elementHasBeenViewed: true,
|
||||
});
|
||||
const unviewed = elvIter.next().value;
|
||||
spyOn(unviewed, 'areViewedCriteriaMet').and.returnValue(false);
|
||||
unviewed.checkIfViewed();
|
||||
expect(handlerSpy).not.toHaveBeenCalledWith(unviewed.el, jasmine.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe('ElementViewing', () => {
|
||||
beforeEach(() => {
|
||||
jasmine.clock().install();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jasmine.clock().uninstall();
|
||||
});
|
||||
|
||||
it('calls checkIfViewed when enough time has elapsed', () => {
|
||||
const viewing = new ElementViewing({}, 500, () => {});
|
||||
spyOn(viewing, 'checkIfViewed').and.callThrough();
|
||||
viewing.seenForMs = 250;
|
||||
viewing.handleVisible();
|
||||
jasmine.clock().tick(249);
|
||||
expect(viewing.checkIfViewed).not.toHaveBeenCalled();
|
||||
jasmine.clock().tick(1);
|
||||
expect(viewing.checkIfViewed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('has been viewed after the specified number of milliseconds', () => {
|
||||
const viewing = new ElementViewing({}, 500, () => {});
|
||||
viewing.seenForMs = 250;
|
||||
spyOn(Date, 'now').and.returnValue(750);
|
||||
viewing.handleVisible();
|
||||
viewing.markTopSeen();
|
||||
viewing.markBottomSeen();
|
||||
Date.now.and.returnValue(999);
|
||||
viewing.checkIfViewed();
|
||||
expect(viewing.hasBeenViewed).toBeFalsy();
|
||||
Date.now.and.returnValue(1000);
|
||||
jasmine.clock().tick(250);
|
||||
expect(viewing.hasBeenViewed).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has not been viewed if the bottom has not been seen', () => {
|
||||
const viewing = new ElementViewing(undefined, 500, () => {});
|
||||
viewing.markTopSeen();
|
||||
viewing.seenForMs = 500;
|
||||
expect(viewing.areViewedCriteriaMet()).toBeFalsy();
|
||||
viewing.checkIfViewed();
|
||||
expect(viewing.hasBeenViewed).toBeFalsy();
|
||||
});
|
||||
|
||||
it('has not been viewed if the top has not been seen', () => {
|
||||
const viewing = new ElementViewing(undefined, 500, () => {});
|
||||
viewing.markBottomSeen();
|
||||
viewing.seenForMs = 500;
|
||||
expect(viewing.areViewedCriteriaMet()).toBeFalsy();
|
||||
viewing.checkIfViewed();
|
||||
expect(viewing.hasBeenViewed).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does not update time seen if lastSeen is undefined', () => {
|
||||
const viewing = new ElementViewing(undefined, 500, () => {});
|
||||
viewing.becameVisibleAt = undefined;
|
||||
expect(viewing.becameVisibleAt).toBeUndefined();
|
||||
viewing.handleVisible();
|
||||
expect(viewing.becameVisibleAt).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -34,12 +34,14 @@ var options = {
|
||||
{pattern: 'learner_profile/**/!(*spec).js'},
|
||||
{pattern: 'lms/js/**/!(*spec).js'},
|
||||
{pattern: 'support/js/**/!(*spec).js'},
|
||||
{pattern: 'teams/js/**/!(*spec).js'}
|
||||
{pattern: 'teams/js/**/!(*spec).js'},
|
||||
{pattern: 'completion/js/**/!(*spec).js'}
|
||||
],
|
||||
|
||||
specFiles: [
|
||||
// Define the Webpack-built spec files first
|
||||
{pattern: 'course_experience/js/**/*_spec.js', webpack: true},
|
||||
{pattern: 'completion/js/**/*_spec.js', webpack: true},
|
||||
|
||||
// Add all remaining spec files to be used without Webpack
|
||||
{pattern: '../**/*spec.js'}
|
||||
|
||||
@@ -690,6 +690,7 @@
|
||||
});
|
||||
|
||||
testFiles = [
|
||||
'completion/js/spec/ViewedEvent_spec.js',
|
||||
'course_bookmarks/js/spec/bookmark_button_view_spec.js',
|
||||
'course_bookmarks/js/spec/bookmarks_list_view_spec.js',
|
||||
'course_bookmarks/js/spec/course_bookmarks_factory_spec.js',
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
<%include file='bookmark_button.html' args="bookmark_id=bookmark_id, is_bookmarked=bookmarked"/>
|
||||
% endif
|
||||
|
||||
<div class="vert-mod">
|
||||
<div class="vert-mod" \
|
||||
% if completion_delay_ms is not None:
|
||||
data-completion-delay-ms="${completion_delay_ms}" \
|
||||
% endif
|
||||
>
|
||||
% for idx, item in enumerate(items):
|
||||
<div class="vert vert-${idx}" data-id="${item['id']}" \
|
||||
% if item['id'] in watched_completable_blocks:
|
||||
|
||||
Reference in New Issue
Block a user