diff --git a/.gitignore b/.gitignore
index f50ad217f395..593dc3cf19e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,6 +20,7 @@ cms/envs/private.py
*.orig
/nbproject
.idea/
+.vscode/
.redcar/
codekit-config.json
.pycharm_helpers/
diff --git a/common/lib/xmodule/xmodule/assets/vertical/public/js/vertical_student_view.js b/common/lib/xmodule/xmodule/assets/vertical/public/js/vertical_student_view.js
index 2f4865356cd3..fdc7f9dede29 100644
--- a/common/lib/xmodule/xmodule/assets/vertical/public/js/vertical_student_view.js
+++ b/common/lib/xmodule/xmodule/assets/vertical/public/js/vertical_student_view.js
@@ -1,9 +1,15 @@
/* JavaScript for Vertical Student View. */
+
+/* global markBlocksCompletedOnViewIfNeeded:false */
+
window.VerticalStudentView = function(runtime, element) {
'use strict';
+
+ markBlocksCompletedOnViewIfNeeded(runtime, element);
+
if (typeof RequireJS === 'undefined') {
// eslint-disable-next-line no-console
- console.log('Cannot initialize VerticalStudentView. RequireJS is not defined.');
+ console.warn('Cannot initialize bookmarks for VerticalStudentView. RequireJS is not defined.');
return;
}
RequireJS.require(['js/bookmarks/views/bookmark_button'], function(BookmarkButton) {
diff --git a/common/lib/xmodule/xmodule/vertical_block.py b/common/lib/xmodule/xmodule/vertical_block.py
index d253a1f67e7c..70fd2c04d5cb 100644
--- a/common/lib/xmodule/xmodule/vertical_block.py
+++ b/common/lib/xmodule/xmodule/vertical_block.py
@@ -2,8 +2,10 @@
VerticalBlock - an XBlock which renders its children in a column.
"""
import logging
+import os
from copy import copy
+from django.conf import settings
from lxml import etree
from xblock.core import XBlock
from xblock.fragment import Fragment
@@ -23,6 +25,7 @@
@XBlock.needs('user', 'bookmarks')
+@XBlock.wants('completion')
class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParserMixin, MakoTemplateBlockBase, XBlock):
"""
Layout XBlock for rendering subblocks vertically.
@@ -55,14 +58,24 @@ def student_view(self, context):
if 'username' not in child_context:
user_service = self.runtime.service(self, 'user')
child_context['username'] = user_service.get_current_user().opt_attrs['edx-platform.username']
+ child_blocks = self.get_display_items()
+ child_blocks_to_complete_on_view = set()
+ completion_service = self.runtime.service(self, 'completion')
+ if completion_service and completion_service.completion_tracking_enabled():
+ child_blocks_to_complete_on_view = completion_service.blocks_to_mark_complete_on_view(child_blocks)
child_context['child_of_vertical'] = True
is_child_of_vertical = context.get('child_of_vertical', False)
# pylint: disable=no-member
- for child in self.get_display_items():
- rendered_child = child.render(STUDENT_VIEW, child_context)
+ for child in child_blocks:
+ child_block_context = copy(child_context)
+ if child in child_blocks_to_complete_on_view:
+ child_block_context['wrap_xblock_data'] = {
+ 'mark-completed-on-view-after-delay': completion_service.get_complete_on_view_delay_ms()
+ }
+ rendered_child = child.render(STUDENT_VIEW, child_block_context)
fragment.add_frag_resources(rendered_child)
contents.append({
@@ -79,6 +92,7 @@ def student_view(self, context):
'bookmark_id': u"{},{}".format(child_context['username'], unicode(self.location))
}))
+ fragment.add_javascript_url(self.runtime.STATIC_URL + 'bundles/CompletionOnViewService.js')
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vertical_student_view.js'))
fragment.initialize_js('VerticalStudentView')
diff --git a/common/test/acceptance/pages/lms/completion.py b/common/test/acceptance/pages/lms/completion.py
new file mode 100644
index 000000000000..b535af7f51ff
--- /dev/null
+++ b/common/test/acceptance/pages/lms/completion.py
@@ -0,0 +1,25 @@
+"""
+Mixins for completion.
+"""
+
+
+class CompletionOnViewMixin(object):
+ """
+ Methods for testing completion on view.
+ """
+
+ def xblock_components_mark_completed_on_view_value(self):
+ """
+ Return the xblock components data-mark-completed-on-view-after-delay value.
+ """
+ return self.q(css=self.xblock_component_selector).attrs(u'data-mark-completed-on-view-after-delay')
+
+ def wait_for_xblock_component_to_be_marked_completed_on_view(self, index=0):
+ """
+ Wait for xblock component to be marked completed on view.
+
+ Arguments
+ index (int): index of block to wait on. (default is 0)
+ """
+ self.wait_for(lambda: (self.xblock_components_mark_completed_on_view_value()[index] == u'0'),
+ u'Waiting for xblock to be marked completed on view.')
diff --git a/common/test/acceptance/pages/lms/courseware.py b/common/test/acceptance/pages/lms/courseware.py
index 2240808c6089..6dabe9f2e555 100644
--- a/common/test/acceptance/pages/lms/courseware.py
+++ b/common/test/acceptance/pages/lms/courseware.py
@@ -8,11 +8,13 @@
from bok_choy.promise import EmptyPromise
from selenium.webdriver.common.action_chains import ActionChains
+from common.test.acceptance.pages.lms import BASE_URL
from common.test.acceptance.pages.lms.bookmarks import BookmarksPage
+from common.test.acceptance.pages.lms.completion import CompletionOnViewMixin
from common.test.acceptance.pages.lms.course_page import CoursePage
-class CoursewarePage(CoursePage):
+class CoursewarePage(CoursePage, CompletionOnViewMixin):
"""
Course info.
"""
@@ -587,3 +589,25 @@ def visit_course_outline_page(self):
# reload the same page with the course_outline_page flag
self.browser.get(self.browser.current_url + "&course_experience.course_outline_page=1")
self.wait_for_page()
+
+
+class RenderXBlockPage(PageObject, CompletionOnViewMixin):
+ """
+ render_xblock page.
+ """
+
+ xblock_component_selector = '.xblock'
+
+ def __init__(self, browser, block_id):
+ super(RenderXBlockPage, self).__init__(browser)
+ self.block_id = block_id
+
+ @property
+ def url(self):
+ """
+ Construct a URL to the page within the course.
+ """
+ return BASE_URL + "/xblock/" + self.block_id
+
+ def is_browser_on_page(self):
+ return self.q(css='.course-content').present
diff --git a/common/test/acceptance/tests/lms/test_lms_courseware.py b/common/test/acceptance/tests/lms/test_lms_courseware.py
index e86fd7e78dcf..aa500a6d9b02 100644
--- a/common/test/acceptance/tests/lms/test_lms_courseware.py
+++ b/common/test/acceptance/tests/lms/test_lms_courseware.py
@@ -14,7 +14,7 @@
from ...pages.common.auto_auth import AutoAuthPage
from ...pages.common.logout import LogoutPage
from ...pages.lms.course_home import CourseHomePage
-from ...pages.lms.courseware import CoursewarePage, CoursewareSequentialTabPage
+from ...pages.lms.courseware import CoursewarePage, CoursewareSequentialTabPage, RenderXBlockPage
from ...pages.lms.create_mode import ModeCreationPage
from ...pages.lms.dashboard import DashboardPage
from ...pages.lms.pay_and_verify import FakePaymentPage, FakeSoftwareSecureVerificationPage, PaymentAndVerificationFlow
@@ -947,3 +947,129 @@ def test_subsecton_hidden_after_due_date(self):
self.progress_page.visit()
self.assertEqual(self.progress_page.scores('Test Section 1', 'Test Subsection 1'), [(0, 1)])
+
+
+@attr(shard=9)
+class CompletionTestCase(UniqueCourseTest, EventsTestMixin):
+ """
+ Test the completion on view functionality.
+ """
+ USERNAME = "STUDENT_TESTER"
+ EMAIL = "student101@example.com"
+ COMPLETION_BY_VIEWING_DELAY_MS = '1000'
+
+ def setUp(self):
+ super(CompletionTestCase, self).setUp()
+
+ self.studio_course_outline = StudioCourseOutlinePage(
+ self.browser,
+ self.course_info['org'],
+ self.course_info['number'],
+ self.course_info['run']
+ )
+
+ # Install a course with sections/problems, tabs, updates, and handouts
+ course_fix = CourseFixture(
+ self.course_info['org'], self.course_info['number'],
+ self.course_info['run'], self.course_info['display_name']
+ )
+
+ self.html_1_block = XBlockFixtureDesc('html', 'html 1', data="html 1 dummy body")
+ self.problem_1_block = XBlockFixtureDesc(
+ 'problem', 'Test Problem 1', data='problem 1 dummy body'
+ )
+
+ course_fix.add_children(
+ XBlockFixtureDesc('chapter', 'Test Section 1').add_children(
+ XBlockFixtureDesc('sequential', 'Test Subsection 1,1').add_children(
+ XBlockFixtureDesc('vertical', 'Test Unit 1,1,1').add_children(
+ XBlockFixtureDesc('html', 'html 1', data="html 1 dummy body"),
+ XBlockFixtureDesc(
+ 'html', 'html 2',
+ data=("html 2 dummy body" * 100) + "End",
+ ),
+ XBlockFixtureDesc('problem', 'Test Problem 1', data='problem 1 dummy body'),
+ ),
+ XBlockFixtureDesc('vertical', 'Test Unit 1,1,2').add_children(
+ XBlockFixtureDesc('html', 'html 1', data="html 1 dummy body"),
+ XBlockFixtureDesc('problem', 'Test Problem 1', data='problem 1 dummy body'),
+ ),
+ XBlockFixtureDesc('vertical', 'Test Unit 1,1,2').add_children(
+ self.html_1_block,
+ self.problem_1_block,
+ ),
+ ),
+ ),
+ ).install()
+
+ # Auto-auth register for the course.
+ AutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL,
+ course_id=self.course_id, staff=False).visit()
+
+ def test_courseware_publish_completion_is_sent_on_view(self):
+ """
+ Test that when viewing courseware XBlocks are correctly marked as completed on view.
+ """
+ courseware_page = CoursewarePage(self.browser, self.course_id)
+ courseware_page.visit()
+ courseware_page.wait_for_page()
+
+ # Initially, the first two blocks in the first vertical should be marked as needing to be completed on view.
+ self.assertEqual(
+ courseware_page.xblock_components_mark_completed_on_view_value(),
+ [self.COMPLETION_BY_VIEWING_DELAY_MS, self.COMPLETION_BY_VIEWING_DELAY_MS, None],
+ )
+ # Wait and verify that the first block which is completely visible is marked as completed.
+ courseware_page.wait_for_xblock_component_to_be_marked_completed_on_view(0)
+ self.assertEqual(
+ courseware_page.xblock_components_mark_completed_on_view_value(),
+ ['0', self.COMPLETION_BY_VIEWING_DELAY_MS, None],
+ )
+
+ # Scroll to the bottom of the second block.
+ courseware_page.scroll_to_element('#html2-end', 'Scroll to end of html 2 block')
+ # Wait and verify that the second block is also now marked as completed.
+ courseware_page.wait_for_xblock_component_to_be_marked_completed_on_view(1)
+ self.assertEqual(courseware_page.xblock_components_mark_completed_on_view_value(), ['0', '0', None])
+
+ # After page refresh, no blocks in the vertical should be marked as needing to be completed on view.
+ self.browser.refresh()
+ courseware_page.wait_for_page()
+ self.assertEqual(courseware_page.xblock_components_mark_completed_on_view_value(), [None, None, None])
+
+ courseware_page.go_to_sequential_position(2)
+
+ # Initially, the first block in the second vertical should be marked as needing to be completed on view.
+ self.assertEqual(
+ courseware_page.xblock_components_mark_completed_on_view_value(),
+ [self.COMPLETION_BY_VIEWING_DELAY_MS, None],
+ )
+ # Wait and verify that the first block which is completely visible is marked as completed.
+ courseware_page.wait_for_xblock_component_to_be_marked_completed_on_view(0)
+ self.assertEqual(courseware_page.xblock_components_mark_completed_on_view_value(), ['0', None])
+
+ # After page refresh, no blocks in the vertical should be marked as needing to be completed on view.
+ self.browser.refresh()
+ courseware_page.wait_for_page()
+ self.assertEqual(courseware_page.xblock_components_mark_completed_on_view_value(), [None, None])
+
+ def test_render_xblock_publish_completion_is_sent_on_view(self):
+ """
+ Test that when viewing a XBlock in render_xblock, it is correctly marked as completed on view.
+ """
+ block_page = RenderXBlockPage(self.browser, self.html_1_block.locator)
+ block_page.visit()
+ block_page.wait_for_page()
+
+ # Initially the block should be marked as needing to be completed on view.
+ self.assertEqual(
+ block_page.xblock_components_mark_completed_on_view_value(), [self.COMPLETION_BY_VIEWING_DELAY_MS]
+ )
+ # Wait and verify that the block is marked as completed on view.
+ block_page.wait_for_xblock_component_to_be_marked_completed_on_view(0)
+ self.assertEqual(block_page.xblock_components_mark_completed_on_view_value(), ['0'])
+
+ # After page refresh, it should not be marked as needing to be completed on view.
+ self.browser.refresh()
+ block_page.wait_for_page()
+ self.assertEqual(block_page.xblock_components_mark_completed_on_view_value(), [None])
diff --git a/common/test/db_fixtures/waffle_flags.json b/common/test/db_fixtures/waffle_flags.json
new file mode 100644
index 000000000000..43de53b0a7ce
--- /dev/null
+++ b/common/test/db_fixtures/waffle_flags.json
@@ -0,0 +1,9 @@
+[
+ {
+ "pk": 2,
+ "fields": {
+ "name": "completion.enable_completion_tracking",
+ "active": true
+ }
+ }
+]
diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py
index e29be8df6b19..7748c4d2c636 100644
--- a/lms/djangoapps/courseware/views/views.py
+++ b/lms/djangoapps/courseware/views/views.py
@@ -1458,6 +1458,15 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True):
student_view_context = request.GET.dict()
student_view_context['show_bookmark_button'] = False
+ enable_completion_on_view_service = False
+ completion_service = block.runtime.service(block, 'completion')
+ if completion_service and completion_service.completion_tracking_enabled():
+ if completion_service.blocks_to_mark_complete_on_view({block}):
+ enable_completion_on_view_service = True
+ student_view_context['wrap_xblock_data'] = {
+ 'mark-completed-on-view-after-delay': completion_service.get_complete_on_view_delay_ms()
+ }
+
context = {
'fragment': block.render('student_view', context=student_view_context),
'course': course,
@@ -1466,6 +1475,7 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True):
'disable_header': True,
'disable_footer': True,
'disable_window_wrap': True,
+ 'enable_completion_on_view_service': enable_completion_on_view_service,
'staff_access': bool(has_access(request.user, 'staff', course)),
'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'),
}
diff --git a/lms/djangoapps/lms_xblock/mixin.py b/lms/djangoapps/lms_xblock/mixin.py
index 2e850088a60e..9b60ad14f45f 100644
--- a/lms/djangoapps/lms_xblock/mixin.py
+++ b/lms/djangoapps/lms_xblock/mixin.py
@@ -5,12 +5,14 @@
#from django.utils.translation import ugettext_noop as _
from lazy import lazy
from xblock.core import XBlock
+from xblock.exceptions import JsonHandlerError
from xblock.fields import Boolean, Dict, Scope, String, XBlockMixin
from xblock.validation import ValidationMessage
from xmodule.modulestore.inheritance import UserPartitionList
from xmodule.partitions.partitions import NoSuchUserPartitionError, NoSuchUserPartitionGroupError
+
# Please do not remove, this is a workaround for Django 1.8.
# more information can be found here: https://openedx.atlassian.net/browse/PLAT-902
_ = lambda text: text
@@ -31,6 +33,7 @@ def to_json(self, access_dict):
@XBlock.needs('partitions')
+@XBlock.wants('completion')
class LmsBlockMixin(XBlockMixin):
"""
Mixin that defines fields common to all blocks used in the LMS
@@ -179,3 +182,18 @@ def validate(self):
)
)
return validation
+
+ @XBlock.json_handler
+ def publish_completion(self, data, suffix=''): # pylint: disable=unused-argument
+ """
+ Publish completion data from the front end.
+ """
+ completion_service = self.runtime.service(self, 'completion')
+ if completion_service is None:
+ raise JsonHandlerError(500, u"No completion service found")
+ elif not completion_service.completion_tracking_enabled():
+ raise JsonHandlerError(404, u"Completion tracking is not enabled and API calls are unexpected")
+ if not completion_service.should_mark_block_completed_on_view(self):
+ raise JsonHandlerError(400, u"Block not configured for completion on view.")
+ self.runtime.publish(self, u"completion", data)
+ return {u'result': u'ok'}
diff --git a/lms/djangoapps/lms_xblock/runtime.py b/lms/djangoapps/lms_xblock/runtime.py
index 105afd46e157..9ce5fef9e49b 100644
--- a/lms/djangoapps/lms_xblock/runtime.py
+++ b/lms/djangoapps/lms_xblock/runtime.py
@@ -1,7 +1,9 @@
"""
Module implementing `xblock.runtime.Runtime` functionality for the LMS
"""
+
import xblock.reference.plugins
+from completion.services import CompletionService
from django.conf import settings
from django.core.urlresolvers import reverse
@@ -178,6 +180,7 @@ class LmsModuleSystem(LmsCourse, LmsUser, ModuleSystem): # pylint: disable=abst
def __init__(self, **kwargs):
request_cache_dict = RequestCache.get_request_cache().data
services = kwargs.setdefault('services', {})
+ services['completion'] = CompletionService(user=kwargs.get('user'), course_key=kwargs.get('course_id'))
services['fs'] = xblock.reference.plugins.FSService()
services['i18n'] = ModuleI18nService
services['library_tools'] = LibraryToolsService(modulestore())
diff --git a/lms/static/coffee/src/courseware.coffee b/lms/static/coffee/src/courseware.coffee
index 49e27c484b8c..ff44e7b6a20d 100644
--- a/lms/static/coffee/src/courseware.coffee
+++ b/lms/static/coffee/src/courseware.coffee
@@ -10,6 +10,13 @@ class @Courseware
render: ->
XBlock.initializeBlocks($('.course-content'))
+
+ courseContentElement = $('.course-content')[0]
+ blocks = XBlock.initializeBlocks(courseContentElement)
+
+ if (courseContentElement.dataset.enableCompletionOnViewService == 'true')
+ markBlocksCompletedOnViewIfNeeded(blocks[0].runtime, courseContentElement)
+
$('.course-content .histogram').each ->
id = $(this).attr('id').replace(/histogram_/, '')
try
diff --git a/lms/static/completion/js/.eslintrc.js b/lms/static/completion/js/.eslintrc.js
new file mode 100644
index 000000000000..12cb26eef91e
--- /dev/null
+++ b/lms/static/completion/js/.eslintrc.js
@@ -0,0 +1,7 @@
+module.exports = {
+ extends: 'eslint-config-edx',
+ root: true,
+ settings: {
+ 'import/resolver': 'webpack',
+ },
+};
diff --git a/lms/static/completion/js/CompletionOnViewService.js b/lms/static/completion/js/CompletionOnViewService.js
new file mode 100644
index 000000000000..833610ca4c15
--- /dev/null
+++ b/lms/static/completion/js/CompletionOnViewService.js
@@ -0,0 +1,44 @@
+import { ViewedEventTracker } from './ViewedEvent';
+
+const completedBlocksKeys = new Set();
+
+// eslint-disable-next-line import/prefer-default-export
+export function markBlocksCompletedOnViewIfNeeded(runtime, containerElement) {
+ const blockElements = $(containerElement).find(
+ '.xblock-student_view[data-mark-completed-on-view-after-delay]',
+ ).get();
+
+ if (blockElements.length > 0) {
+ const tracker = new ViewedEventTracker();
+
+ blockElements.forEach((blockElement) => {
+ const markCompletedOnViewAfterDelay = parseInt(
+ blockElement.dataset.markCompletedOnViewAfterDelay, 10,
+ );
+ if (markCompletedOnViewAfterDelay >= 0) {
+ tracker.addElement(blockElement, markCompletedOnViewAfterDelay);
+ }
+ });
+
+ tracker.addHandler((blockElement, event) => {
+ const blockKey = blockElement.dataset.usageId;
+ if (blockKey && !completedBlocksKeys.has(blockKey)) {
+ if (event.elementHasBeenViewed) {
+ $.ajax({
+ type: 'POST',
+ url: runtime.handlerUrl(blockElement, 'publish_completion'),
+ data: JSON.stringify({
+ completion: 1.0,
+ }),
+ }).then(
+ () => {
+ completedBlocksKeys.add(blockKey);
+ // eslint-disable-next-line no-param-reassign
+ blockElement.dataset.markCompletedOnViewAfterDelay = 0;
+ },
+ );
+ }
+ }
+ });
+ }
+}
diff --git a/lms/static/completion/js/ViewedEvent.js b/lms/static/completion/js/ViewedEvent.js
new file mode 100644
index 000000000000..fe2d8f3fead1
--- /dev/null
+++ b/lms/static/completion/js/ViewedEvent.js
@@ -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() {
+ this.elementViewings = new Set();
+ this.handlers = [];
+ this.registerDomHandlers();
+ }
+
+ /** Add an element to track. */
+ addElement(element, viewedAfterMs) {
+ this.elementViewings.add(
+ new ElementViewing(
+ element,
+ viewedAfterMs,
+ (el, event) => this.callHandlers(el, event),
+ ),
+ );
+ this.updateVisible();
+ }
+
+ /** 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);
+ });
+ }
+}
+
diff --git a/lms/static/completion/js/spec/ViewedEvent_spec.js b/lms/static/completion/js/spec/ViewedEvent_spec.js
new file mode 100644
index 000000000000..62662317423c
--- /dev/null
+++ b/lms/static/completion/js/spec/ViewedEvent_spec.js
@@ -0,0 +1,98 @@
+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 = '
';
+ const tracker = new ViewedEventTracker();
+ for (const element of document.getElementsByTagName('div')) { // eslint-disable-line no-restricted-syntax
+ tracker.addElement(element, 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();
+ });
+});
diff --git a/lms/static/lms/js/spec/main.js b/lms/static/lms/js/spec/main.js
index 4395917af681..28440b25d926 100644
--- a/lms/static/lms/js/spec/main.js
+++ b/lms/static/lms/js/spec/main.js
@@ -673,6 +673,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',
diff --git a/lms/templates/courseware/courseware-chromeless.html b/lms/templates/courseware/courseware-chromeless.html
index 6ebabf686009..92cb8f694432 100644
--- a/lms/templates/courseware/courseware-chromeless.html
+++ b/lms/templates/courseware/courseware-chromeless.html
@@ -54,11 +54,15 @@
## codemirror
+ % if enable_completion_on_view_service:
+
+
+ % endif
<%static:js group='courseware'/>
<%include file="/mathjax_include.html" args="disable_fast_preview=True"/>
% if staff_access:
- <%include file="xqa_interface.html"/>
+ <%include file="xqa_interface.html"/>
% endif