diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6455e439e3..a9f17d6c7f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,9 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Blades: Adds CookieStorage utility for video player that provides convenient + way to work with cookies. + Blades: Fix comparison of float numbers. BLD-434. Blades: Allow regexp strings as the correct answer to a string response question. BLD-475. diff --git a/common/lib/xmodule/xmodule/js/spec/video/cookie_storage_spec.js b/common/lib/xmodule/xmodule/js/spec/video/cookie_storage_spec.js new file mode 100644 index 0000000000..dff25bb141 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/video/cookie_storage_spec.js @@ -0,0 +1,149 @@ +(function (requirejs, require, define) { +require( +['video/00_cookie_storage.js'], +function (CookieStorage) { + describe('CookieStorage', function () { + var mostRecentCall; + + beforeEach(function () { + mostRecentCall = $.cookie.mostRecentCall; + }); + + afterEach(function () { + CookieStorage('test_storage').clear(); + }); + + describe('intialize', function () { + it('with namespace', function () { + var storage = CookieStorage('test_storage'); + + storage.setItem('item_1', 'value_1'); + expect(mostRecentCall.args[0]).toBe('test_storage'); + }); + + it('without namespace', function () { + var storage = CookieStorage(); + + storage.setItem('item_1', 'value_1'); + expect(mostRecentCall.args[0]).toBe('cookieStorage'); + }); + }); + + it('unload', function () { + var expected = JSON.stringify({ + storage: { + 'item_2': { + value: 'value_2', + session: false + } + }, + keys: ['item_2'] + }), + storage = CookieStorage('test_storage'); + + storage.setItem('item_1', 'value_1', true); + storage.setItem('item_2', 'value_2'); + + $(window).trigger('unload'); + expect(mostRecentCall.args[1]).toBe(expected); + }); + + describe('methods: ', function () { + var data = { + storage: { + 'item_1': { + value: 'value_1', + session: false + } + }, + keys: ['item_1'] + }, + storage; + + beforeEach(function () { + $.cookie.andReturn(JSON.stringify(data)); + storage = CookieStorage('test_storage'); + }); + + describe('setItem', function () { + it('pass correct data', function () { + var expected = JSON.stringify({ + storage: { + 'item_1': { + value: 'value_1', + session: false + }, + 'item_2': { + value: 'value_2', + session: false + }, + 'item_3': { + value: 'value_3', + session: true + }, + }, + keys: ['item_1', 'item_2', 'item_3'] + }); + + storage.setItem('item_2', 'value_2'); + storage.setItem('item_3', 'value_3', true); + expect(mostRecentCall.args[0]).toBe('test_storage'); + expect(mostRecentCall.args[1]).toBe(expected); + }); + + it('pass broken arguments', function () { + $.cookie.reset(); + storage.setItem(null, 'value_1'); + expect($.cookie).not.toHaveBeenCalled(); + }); + }); + + describe('getItem', function () { + it('item exist', function () { + $.each(data['storage'], function(key, value) { + expect(storage.getItem(key)).toBe(value['value']); + }); + }); + + it('item does not exist', function () { + expect(storage.getItem('nonexistent')).toBe(null); + }); + }); + + describe('removeItem', function () { + it('item exist', function () { + var expected = JSON.stringify({ + storage: {}, + keys: [] + }); + + storage.removeItem('item_1'); + expect(mostRecentCall.args[1]).toBe(expected); + }); + + it('item does not exist', function () { + storage.removeItem('nonexistent'); + expect(mostRecentCall.args[1]).toBe(JSON.stringify(data)); + }); + }); + + it('clear', function () { + storage.clear(); + expect(mostRecentCall.args[1]).toBe(null); + }); + + describe('key', function () { + it('key exist', function () { + $.each(data['keys'], function(index, name) { + expect(storage.key(index)).toBe(name); + }); + }); + + it('key is grater than keys list', function () { + expect(storage.key(100)).toBe(null); + }); + }); + }); + }); +}); +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_volume_control_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_volume_control_spec.js index c8e2db97f7..8f09030189 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_volume_control_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_volume_control_spec.js @@ -22,11 +22,12 @@ describe('constructor', function() { beforeEach(function() { spyOn($.fn, 'slider').andCallThrough(); + $.cookie.andReturn('75'); initialize(); }); - it('initialize currentVolume to 100', function() { - expect(state.videoVolumeControl.currentVolume).toEqual(1); + it('initialize currentVolume to 75', function() { + expect(state.videoVolumeControl.currentVolume).toEqual(75); }); it('render the volume control', function() { diff --git a/common/lib/xmodule/xmodule/js/src/video/00_cookie_storage.js b/common/lib/xmodule/xmodule/js/src/video/00_cookie_storage.js new file mode 100644 index 0000000000..fb46dfb9dc --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/video/00_cookie_storage.js @@ -0,0 +1,196 @@ +(function (requirejs, require, define) { + +define( +'video/00_cookie_storage.js', +[], +function() { + "use strict"; +/** + * Provides convenient way to work with cookies. + * + * Maximum 4096 bytes can be stored per namespace. + * + * @TODO: Uses localStorage if available. + * + * @param {string} namespace Namespace that is used to store data. + * @return {object} CookieStorage API. + */ + + + var CookieStorage = function (namespace) { + var Storage; + + /** + * Returns an empty storage with proper data structure. + * + * @private + * @return {object} Empty storage. + */ + var _getEmptyStorage = function () { + return { + storage: {}, + keys: [] + }; + }; + + /** + * Returns the current value associated with the given namespace. + * If data doesn't exist or has data with incorrect interface, it creates + * an empty storage with proper data structure. + * + * @private + * @param {string} namespace Namespace that is used to store data. + * @return {object} Stored data or an empty storage. + */ + var _getData = function (namespace) { + var data; + + try { + data = JSON.parse($.cookie(namespace)); + } catch (err) { } + + if (!data || !data['storage'] || !data['keys']) { + return _getEmptyStorage(); + } + + return data; + }; + + /** + * Clears cookies that has flag `session` equals true. + * + * @private + */ + var _clearSession = function () { + Storage['keys'] = $.grep(Storage['keys'], function(key_name, index) { + if (Storage['storage'][key_name]['session']) { + delete Storage['storage'][key_name]; + + return false; + } + + return true; + }); + + $.cookie(namespace, JSON.stringify(Storage), { + expires: -1, + path: '/' + }); + }; + + /** + * Adds new value to the storage or rewrites existent. + * + * @param {string} name Identifier of the data. + * @param {any} value Data to store. + * @param {boolean} useSession Data with this flag will be removed on + * window unload. + */ + var setItem = function (name, value, useSession) { + if (name) { + if ($.inArray(name, Storage['keys']) === -1) { + Storage['keys'].push(name); + } + + Storage['storage'][name] = { + value: value, + session: useSession ? true : false + }; + + $.cookie(namespace, JSON.stringify(Storage), { + expires: 3650, + path: '/' + }); + } + }; + + /** + * Returns the current value associated with the given name. + * + * @param {string} name Identifier of the data. + * @return {any} The current value associated with the given name. + * If the given key does not exist in the list + * associated with the object then this method must return null. + */ + var getItem = function (name) { + try { + return Storage['storage'][name]['value']; + } catch (err) { } + + return null; + }; + + /** + * Removes the current value associated with the given name. + * + * @param {string} name Identifier of the data. + */ + var removeItem = function (name) { + delete Storage['storage'][name]; + + Storage['keys'] = $.grep(Storage['keys'], function(key_name, index) { + return name !== key_name; + }); + + $.cookie(namespace, JSON.stringify(Storage), { + expires: 3650, + path: '/' + }); + }; + + /** + * Empties the storage. + * + */ + var clear = function () { + Storage = _getEmptyStorage(); + $.cookie(namespace, null, { + expires: -1, + path: '/' + }); + }; + + /** + * Returns the name of the `n`th key in the list. + * + * @param {number} n Index of the key. + * @return {string} Name of the `n`th key in the list. + * If `n` is greater than or equal to the number of key/value pairs + * in the object, then this method must return `null`. + */ + var key = function (n) { + if (n >= Storage['keys'].length) { + return null; + } + + return Storage['keys'][n]; + }; + + /** + * Initializes the module: creates a storage with proper namespace, binds + * `unload` event. + * + * @private + */ + (function initialize() { + if (!namespace) { + namespace = 'cookieStorage'; + } + Storage = _getData(namespace); + + $(window).unload(_clearSession); + }()); + + return { + clear: clear, + getItem: getItem, + key: key, + removeItem: removeItem, + setItem: setItem + }; + }; + + return CookieStorage; +}); + +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index 364a8ee417..2a27c03c45 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -25,7 +25,6 @@ from xmodule.x_module import XModule from xmodule.editing_module import TabsEditingDescriptor from xmodule.raw_module import EmptyDataRawDescriptor from xmodule.xml_module import is_pointer_tag, name_to_pathname, deserialize_field -from xmodule.modulestore import Location from xblock.fields import Scope, String, Boolean, List, Integer, ScopeIds from xmodule.fields import RelativeTime @@ -134,8 +133,11 @@ class VideoModule(VideoFields, XModule): video_time = 0 icon_class = 'video' + # To make sure that js files are called in proper order we use numerical + # index. We do that to avoid issues that occurs in tests. js = { 'js': [ + resource_string(__name__, 'js/src/video/00_cookie_storage.js'), resource_string(__name__, 'js/src/video/00_resizer.js'), resource_string(__name__, 'js/src/video/01_initialize.js'), resource_string(__name__, 'js/src/video/025_focus_grabber.js'), diff --git a/docs/en_us/developers/source/video_player.rst b/docs/en_us/developers/source/video_player.rst new file mode 100644 index 0000000000..ce0744cbb8 --- /dev/null +++ b/docs/en_us/developers/source/video_player.rst @@ -0,0 +1,10 @@ +Video player persists some user preferences between videos and these preferences are stored on server. + +Content for sequential positions is loaded just once on page load and is not updated when the user navigates between sequential positions. So, we doesn't have an actual data from server. +To resolve this issue, cookies are used as temporary storage and are removed on page unload. + +How it works: + 1) On page load: cookies are empty and player get an actual data from server. + 2) When user change some preferences, new value is stored to cookie; + 3) If we navigate to another sequential position, video player get an actual data from cookies. + 4) Close the page: `unload` event fires and we clear our cookies and send user preferences to the server. diff --git a/docs/en_us/developers/source/xmodule.rst b/docs/en_us/developers/source/xmodule.rst index 77ee2ea684..879eab485c 100644 --- a/docs/en_us/developers/source/xmodule.rst +++ b/docs/en_us/developers/source/xmodule.rst @@ -154,7 +154,7 @@ Vertical Video ===== - +.. include:: video_player.rst .. automodule:: xmodule.video_module :members: :show-inheritance: