summaryrefslogtreecommitdiff
path: root/addons/website_slides/static/src/js
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/website_slides/static/src/js
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/website_slides/static/src/js')
-rw-r--r--addons/website_slides/static/src/js/activity.js87
-rw-r--r--addons/website_slides/static/src/js/rating_field_backend.js42
-rw-r--r--addons/website_slides/static/src/js/slide_category_one2many.js182
-rw-r--r--addons/website_slides/static/src/js/slides.js124
-rw-r--r--addons/website_slides/static/src/js/slides_category_add.js84
-rw-r--r--addons/website_slides/static/src/js/slides_course_enroll_email.js83
-rw-r--r--addons/website_slides/static/src/js/slides_course_fullscreen_player.js762
-rw-r--r--addons/website_slides/static/src/js/slides_course_join.js161
-rw-r--r--addons/website_slides/static/src/js/slides_course_quiz.js775
-rw-r--r--addons/website_slides/static/src/js/slides_course_quiz_finish.js157
-rw-r--r--addons/website_slides/static/src/js/slides_course_quiz_question_form.js228
-rw-r--r--addons/website_slides/static/src/js/slides_course_slides_list.js114
-rw-r--r--addons/website_slides/static/src/js/slides_course_tag_add.js377
-rw-r--r--addons/website_slides/static/src/js/slides_course_unsubscribe.js168
-rw-r--r--addons/website_slides/static/src/js/slides_embed.js250
-rw-r--r--addons/website_slides/static/src/js/slides_share.js105
-rw-r--r--addons/website_slides/static/src/js/slides_slide_archive.js108
-rw-r--r--addons/website_slides/static/src/js/slides_slide_like.js114
-rw-r--r--addons/website_slides/static/src/js/slides_slide_toggle_is_preview.js40
-rw-r--r--addons/website_slides/static/src/js/slides_upload.js678
-rw-r--r--addons/website_slides/static/src/js/tours/slides_tour.js117
-rw-r--r--addons/website_slides/static/src/js/website_slides.editor.js188
22 files changed, 4944 insertions, 0 deletions
diff --git a/addons/website_slides/static/src/js/activity.js b/addons/website_slides/static/src/js/activity.js
new file mode 100644
index 00000000..ac0ae3cd
--- /dev/null
+++ b/addons/website_slides/static/src/js/activity.js
@@ -0,0 +1,87 @@
+odoo.define('website_slides.Activity', function (require) {
+"use strict";
+
+var field_registry = require('web.field_registry');
+
+require('mail.Activity');
+
+var KanbanActivity = field_registry.get('kanban_activity');
+
+function applyInclude(Activity) {
+ Activity.include({
+ events: _.extend({}, Activity.prototype.events, {
+ 'click .o_activity_action_grant_access': '_onGrantAccess',
+ 'click .o_activity_action_refuse_access': '_onRefuseAccess',
+ }),
+
+ _onGrantAccess: function (event) {
+ var self = this;
+ var partnerId = $(event.currentTarget).data('partner-id');
+ this._rpc({
+ model: 'slide.channel',
+ method: 'action_grant_access',
+ args: [this.res_id, partnerId],
+ }).then(function (result) {
+ self.trigger_up('reload');
+ });
+ },
+
+ _onRefuseAccess: function (event) {
+ var self = this;
+ var partnerId = $(event.currentTarget).data('partner-id');
+ this._rpc({
+ model: 'slide.channel',
+ method: 'action_refuse_access',
+ args: [this.res_id, partnerId],
+ }).then(function () {
+ self.trigger_up('reload');
+ });
+ },
+ });
+}
+
+applyInclude(KanbanActivity);
+
+});
+
+odoo.define('website_slides/static/src/components/activity/activity.js', function (require) {
+'use strict';
+
+const components = {
+ Activity: require('mail/static/src/components/activity/activity.js'),
+};
+const { patch } = require('web.utils');
+
+patch(components.Activity, 'website_slides/static/src/components/activity/activity.js', {
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _onGrantAccess(ev) {
+ await this.env.services.rpc({
+ model: 'slide.channel',
+ method: 'action_grant_access',
+ args: [[this.activity.thread.id]],
+ kwargs: { partner_id: this.activity.requestingPartner.id },
+ });
+ this.trigger('reload');
+ },
+ /**
+ * @private
+ */
+ async _onRefuseAccess(ev) {
+ await this.env.services.rpc({
+ model: 'slide.channel',
+ method: 'action_refuse_access',
+ args: [[this.activity.thread.id]],
+ kwargs: { partner_id: this.activity.requestingPartner.id },
+ });
+ this.trigger('reload');
+ },
+});
+
+});
diff --git a/addons/website_slides/static/src/js/rating_field_backend.js b/addons/website_slides/static/src/js/rating_field_backend.js
new file mode 100644
index 00000000..12a6d8b6
--- /dev/null
+++ b/addons/website_slides/static/src/js/rating_field_backend.js
@@ -0,0 +1,42 @@
+odoo.define('website_slides.ratingField', function (require) {
+"use strict";
+
+var basicFields = require('web.basic_fields');
+var fieldRegistry = require('web.field_registry');
+
+var core = require('web.core');
+
+var QWeb = core.qweb;
+
+var FieldFloatRating = basicFields.FieldFloat.extend({
+ xmlDependencies: !basicFields.FieldFloat.prototype.xmlDependencies ?
+ ['/portal_rating/static/src/xml/portal_tools.xml'] : basicFields.FieldFloat.prototype.xmlDependencies.concat(
+ ['/portal_rating/static/src/xml/portal_tools.xml']
+ ),
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ var self = this;
+
+ return Promise.resolve(this._super()).then(function () {
+ self.$el.html(QWeb.render('portal_rating.rating_stars_static', {
+ 'val': self.value / 2,
+ 'inline_mode': true
+ }));
+ });
+ },
+});
+
+fieldRegistry.add('field_float_rating', FieldFloatRating);
+
+return {
+ FieldFloatRating: FieldFloatRating,
+};
+
+});
diff --git a/addons/website_slides/static/src/js/slide_category_one2many.js b/addons/website_slides/static/src/js/slide_category_one2many.js
new file mode 100644
index 00000000..adb84d60
--- /dev/null
+++ b/addons/website_slides/static/src/js/slide_category_one2many.js
@@ -0,0 +1,182 @@
+odoo.define('survey.slide_category_one2many', function (require){
+"use strict";
+
+var Context = require('web.Context');
+var FieldOne2Many = require('web.relational_fields').FieldOne2Many;
+var FieldRegistry = require('web.field_registry');
+var ListRenderer = require('web.ListRenderer');
+var config = require('web.config');
+
+var SectionListRenderer = ListRenderer.extend({
+ init: function (parent, state, params) {
+ this.sectionFieldName = "is_category";
+ this._super.apply(this, arguments);
+ },
+ _checkIfRecordIsSection: function (id){
+ var record = this._findRecordById(id);
+ return record && record.data[this.sectionFieldName];
+ },
+ _findRecordById: function (id){
+ return _.find(this.state.data, function (record){
+ return record.id === id;
+ });
+ },
+ /**
+ * Allows to hide specific field in case the record is a section
+ * and, in this case, makes the 'title' field take the space of all the other
+ * fields
+ * @private
+ * @override
+ * @param {*} record
+ * @param {*} node
+ * @param {*} index
+ * @param {*} options
+ */
+ _renderBodyCell: function (record, node, index, options){
+ var $cell = this._super.apply(this, arguments);
+
+ var isSection = record.data[this.sectionFieldName];
+
+ if (isSection){
+ if (node.attrs.widget === "handle"){
+ return $cell;
+ } else if (node.attrs.name === "name"){
+ var nbrColumns = this._getNumberOfCols();
+ if (this.handleField){
+ nbrColumns--;
+ }
+ if (this.addTrashIcon){
+ nbrColumns--;
+ }
+ $cell.attr('colspan', nbrColumns);
+ } else {
+ $cell.removeClass('o_invisible_modifier');
+ return $cell.addClass('o_hidden');
+ }
+ }
+ return $cell;
+ },
+ /**
+ * Adds specific classes to rows that are sections
+ * to apply custom css on them
+ * @private
+ * @override
+ * @param {*} record
+ * @param {*} index
+ */
+ _renderRow: function (record, index){
+ var $row = this._super.apply(this, arguments);
+ if (record.data[this.sectionFieldName]) {
+ $row.addClass("o_is_section");
+ }
+ return $row;
+ },
+ /**
+ * Adding this class after the view is rendered allows
+ * us to limit the custom css scope to this particular case
+ * and no other
+ * @private
+ * @override
+ */
+ _renderView: function (){
+ var def = this._super.apply(this, arguments);
+ var self = this;
+ return def.then(function () {
+ self.$('table.o_list_table').addClass('o_section_list_view');
+ });
+ },
+ // Handlers
+ /**
+ * Overriden to allow different behaviours depending on
+ * the row the user clicked on.
+ * If the row is a section: edit inline
+ * else use a normal modal
+ * @private
+ * @override
+ * @param {*} ev
+ */
+ _onRowClicked: function (ev){
+ var parent = this.getParent();
+ var recordId = $(ev.currentTarget).data('id');
+ var is_section = this._checkIfRecordIsSection(recordId);
+ if (is_section && parent.mode === "edit"){
+ this.editable = "bottom";
+ } else {
+ this.editable = null;
+ }
+ this._super.apply(this, arguments);
+ },
+ /**
+ * Overriden to allow different behaviours depending on
+ * the cell the user clicked on.
+ * If the cell is part of a section: edit inline
+ * else use a normal edit modal
+ * @private
+ * @override
+ * @param {*} ev
+ */
+ _onCellClick: function (ev){
+ var parent = this.getParent();
+ var recordId = $(ev.currentTarget.parentElement).data('id');
+ var is_section = this._checkIfRecordIsSection(recordId);
+ if (is_section && parent.mode === "edit"){
+ this.editable = "bottom";
+ } else {
+ this.editable = null;
+ this.unselectRow();
+ }
+ this._super.apply(this, arguments);
+ },
+ /**
+ * In this case, navigating in the list caused issues.
+ * For example, editing a section then pressing enter would trigger
+ * the inline edition of the next element in the list. Which is not desired
+ * if the next element ends up being a question and not a section
+ * @override
+ * @param {*} ev
+ */
+ _onNavigationMove: function (ev){
+ this.unselectRow();
+ },
+});
+
+var SectionFieldOne2Many = FieldOne2Many.extend({
+ init: function (parent, name, record, options){
+ this._super.apply(this, arguments);
+ this.sectionFieldName = "is_category";
+ this.rendered = false;
+ },
+ /**
+ * Overriden to use our custom renderer
+ * @private
+ * @override
+ */
+ _getRenderer: function (){
+ if (this.view.arch.tag === 'tree'){
+ return SectionListRenderer;
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Overriden to allow different behaviours depending on
+ * the object we want to add. Adding a section would be done inline
+ * while adding a question would render a modal.
+ * @private
+ * @override
+ * @param {*} ev
+ */
+ _onAddRecord: function (ev) {
+ this.editable = null;
+ if (!config.device.isMobile){
+ var context_str = ev.data.context && ev.data.context[0];
+ var context = new Context(context_str).eval();
+ if (context['default_' + this.sectionFieldName]){
+ this.editable = "bottom";
+ }
+ }
+ this._super.apply(this, arguments);
+ },
+});
+
+FieldRegistry.add('slide_category_one2many', SectionFieldOne2Many);
+}); \ No newline at end of file
diff --git a/addons/website_slides/static/src/js/slides.js b/addons/website_slides/static/src/js/slides.js
new file mode 100644
index 00000000..4f081013
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides.js
@@ -0,0 +1,124 @@
+odoo.define('website_slides.slides', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+var time = require('web.time');
+
+publicWidget.registry.websiteSlides = publicWidget.Widget.extend({
+ selector: '#wrapwrap',
+
+ /**
+ * @override
+ * @param {Object} parent
+ */
+ start: function (parent) {
+ var defs = [this._super.apply(this, arguments)];
+
+ _.each($("timeago.timeago"), function (el) {
+ var datetime = $(el).attr('datetime');
+ var datetimeObj = time.str_to_datetime(datetime);
+ // if presentation 7 days, 24 hours, 60 min, 60 second, 1000 millis old(one week)
+ // then return fix formate string else timeago
+ var displayStr = '';
+ if (datetimeObj && new Date().getTime() - datetimeObj.getTime() > 7 * 24 * 60 * 60 * 1000) {
+ displayStr = moment(datetimeObj).format('ll');
+ } else {
+ displayStr = moment(datetimeObj).fromNow();
+ }
+ $(el).text(displayStr);
+ });
+
+ return Promise.all(defs);
+ },
+});
+
+return publicWidget.registry.websiteSlides;
+
+});
+
+//==============================================================================
+
+odoo.define('website_slides.slides_embed', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+require('website_slides.slides');
+
+var SlideSocialEmbed = publicWidget.Widget.extend({
+ events: {
+ 'change input': '_onChangePage',
+ },
+ /**
+ * @constructor
+ * @param {Object} parent
+ * @param {Number} maxPage
+ */
+ init: function (parent, maxPage) {
+ this._super.apply(this, arguments);
+ this.max_page = maxPage || false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Number} page
+ */
+ _updateEmbeddedCode: function (page) {
+ var $embedInput = this.$('.slide_embed_code');
+ var newCode = $embedInput.val().replace(/(page=).*?([^\d]+)/, '$1' + page + '$2');
+ $embedInput.val(newCode);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} ev
+ */
+ _onChangePage: function (ev) {
+ ev.preventDefault();
+ var input = this.$('input');
+ var page = parseInt(input.val());
+ if (this.max_page && !(page > 0 && page <= this.max_page)) {
+ page = 1;
+ }
+ this._updateEmbeddedCode(page);
+ },
+});
+
+publicWidget.registry.websiteSlidesEmbed = publicWidget.Widget.extend({
+ selector: '#wrapwrap',
+
+ /**
+ * @override
+ * @param {Object} parent
+ */
+ start: function (parent) {
+ var defs = [this._super.apply(this, arguments)];
+ $('iframe.o_wslides_iframe_viewer').on('ready', this._onIframeViewerReady.bind(this));
+ return Promise.all(defs);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onIframeViewerReady: function (ev) {
+ // TODO : make it work. For now, once the iframe is loaded, the value of #page_count is
+ // still now set (the pdf is still loading)
+ var $iframe = $(ev.currentTarget);
+ var maxPage = $iframe.contents().find('#page_count').val();
+ new SlideSocialEmbed(this, maxPage).attachTo($('.oe_slide_js_embed_code_widget'));
+ },
+});
+
+});
diff --git a/addons/website_slides/static/src/js/slides_category_add.js b/addons/website_slides/static/src/js/slides_category_add.js
new file mode 100644
index 00000000..e11ba2a4
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides_category_add.js
@@ -0,0 +1,84 @@
+odoo.define('website_slides.category.add', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+var Dialog = require('web.Dialog');
+var core = require('web.core');
+var _t = core._t;
+
+var CategoryAddDialog = Dialog.extend({
+ template: 'slides.category.add',
+
+ /**
+ * @override
+ */
+ init: function (parent, options) {
+ options = _.defaults(options || {}, {
+ title: _t('Add a section'),
+ size: 'medium',
+ buttons: [{
+ text: _t('Save'),
+ classes: 'btn-primary',
+ click: this._onClickFormSubmit.bind(this)
+ }, {
+ text: _t('Discard'),
+ close: true
+ }]
+ });
+
+ this.channelId = options.channelId;
+ this._super(parent, options);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _formValidate: function ($form) {
+ $form.addClass('was-validated');
+ return $form[0].checkValidity();
+ },
+
+ _onClickFormSubmit: function (ev) {
+ var $form = this.$('#slide_category_add_form');
+ if (this._formValidate($form)) {
+ $form.submit();
+ }
+ },
+});
+
+publicWidget.registry.websiteSlidesCategoryAdd = publicWidget.Widget.extend({
+ selector: '.o_wslides_js_slide_section_add',
+ xmlDependencies: ['/website_slides/static/src/xml/slide_management.xml'],
+ events: {
+ 'click': '_onAddSectionClick',
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _openDialog: function (channelId) {
+ new CategoryAddDialog(this, {channelId: channelId}).open();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onAddSectionClick: function (ev) {
+ ev.preventDefault();
+ this._openDialog($(ev.currentTarget).attr('channel_id'));
+ },
+});
+
+return {
+ categoryAddDialog: CategoryAddDialog,
+ websiteSlidesCategoryAdd: publicWidget.registry.websiteSlidesCategoryAdd
+};
+
+});
diff --git a/addons/website_slides/static/src/js/slides_course_enroll_email.js b/addons/website_slides/static/src/js/slides_course_enroll_email.js
new file mode 100644
index 00000000..a9f5f799
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides_course_enroll_email.js
@@ -0,0 +1,83 @@
+odoo.define('website_slides.course.enroll', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var publicWidget = require('web.public.widget');
+var _t = core._t;
+
+var SlideEnrollDialog = Dialog.extend({
+ template: 'slide.course.join.request',
+
+ init: function (parent, options, modalOptions) {
+ modalOptions = _.defaults(modalOptions || {}, {
+ title: _t('Request Access.'),
+ size: 'medium',
+ buttons: [{
+ text: _t('Yes'),
+ classes: 'btn-primary',
+ click: this._onSendRequest.bind(this)
+ }, {
+ text: _t('Cancel'),
+ close: true
+ }]
+ });
+ this.$element = options.$element;
+ this.channelId = options.channelId;
+ this._super(parent, modalOptions);
+ },
+
+ _onSendRequest: function () {
+ var self = this;
+ this._rpc({
+ model: 'slide.channel',
+ method: 'action_request_access',
+ args: [self.channelId]
+ }).then(function (result) {
+ if (result.error) {
+ self.$element.replaceWith('<div class="alert alert-danger" role="alert"><strong>' + result.error + '</strong></div>');
+ } else if (result.done) {
+ self.$element.replaceWith('<div class="alert alert-success" role="alert"><strong>' + _t('Request sent !') + '</strong></div>');
+ } else {
+ self.$element.replaceWith('<div class="alert alert-danger" role="alert"><strong>' + _t('Unknown error, try again.') + '</strong></div>');
+ }
+ self.close();
+ });
+ }
+
+});
+
+publicWidget.registry.websiteSlidesEnroll = publicWidget.Widget.extend({
+ selector: '.o_wslides_js_channel_enroll',
+ xmlDependencies: ['/website_slides/static/src/xml/slide_course_join.xml'],
+ events: {
+ 'click': '_onSendRequestClick',
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _openDialog: function (channelId) {
+ new SlideEnrollDialog(this, {
+ channelId: channelId,
+ $element: this.$el
+ }).open();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ _onSendRequestClick: function (ev) {
+ ev.preventDefault();
+ this._openDialog($(ev.currentTarget).data('channelId'));
+ }
+});
+
+return {
+ slideEnrollDialog: SlideEnrollDialog,
+ websiteSlidesEnroll: publicWidget.registry.websiteSlidesEnroll
+};
+
+});
diff --git a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js
new file mode 100644
index 00000000..d9aa707c
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js
@@ -0,0 +1,762 @@
+var onYouTubeIframeAPIReady = undefined;
+
+odoo.define('website_slides.fullscreen', function (require) {
+ 'use strict';
+
+ var publicWidget = require('web.public.widget');
+ var core = require('web.core');
+ var config = require('web.config');
+ var QWeb = core.qweb;
+ var _t = core._t;
+
+ var session = require('web.session');
+
+ var Quiz = require('website_slides.quiz').Quiz;
+
+ var Dialog = require('web.Dialog');
+
+ require('website_slides.course.join.widget');
+
+ /**
+ * Helper: Get the slide dict matching the given criteria
+ *
+ * @private
+ * @param {Array<Object>} slideList List of dict reprensenting a slide
+ * @param {Object} matcher (see https://underscorejs.org/#matcher)
+ */
+ var findSlide = function (slideList, matcher) {
+ var slideMatch = _.matcher(matcher);
+ return _.find(slideList, slideMatch);
+ };
+
+ /**
+ * This widget is responsible of display Youtube Player
+ *
+ * The widget will trigger an event `change_slide` when the video is at
+ * its end, and `slide_completed` when the player is at 30 sec before the
+ * end of the video (30 sec before is considered as completed).
+ */
+ var VideoPlayer = publicWidget.Widget.extend({
+ template: 'website.slides.fullscreen.video',
+ youtubeUrl: 'https://www.youtube.com/iframe_api',
+
+ init: function (parent, slide) {
+ this.slide = slide;
+ return this._super.apply(this, arguments);
+ },
+ start: function (){
+ var self = this;
+ return Promise.all([this._super.apply(this, arguments), this._loadYoutubeAPI()]).then(function() {
+ self._setupYoutubePlayer();
+ });
+ },
+ _loadYoutubeAPI: function () {
+ var self = this;
+ var prom = new Promise(function (resolve, reject) {
+ if ($(document).find('script[src="' + self.youtubeUrl + '"]').length === 0) {
+ var $youtubeElement = $('<script/>', {src: self.youtubeUrl});
+ $(document.head).append($youtubeElement);
+
+ // function called when the Youtube asset is loaded
+ // see https://developers.google.com/youtube/iframe_api_reference#Requirements
+ onYouTubeIframeAPIReady = function () {
+ resolve();
+ };
+ } else {
+ resolve();
+ }
+ });
+ return prom;
+ },
+ /**
+ * Links the youtube api to the iframe present in the template
+ *
+ * @private
+ */
+ _setupYoutubePlayer: function (){
+ this.player = new YT.Player('youtube-player' + this.slide.id, {
+ playerVars: {
+ 'autoplay': 1,
+ 'origin': window.location.origin
+ },
+ events: {
+ 'onStateChange': this._onPlayerStateChange.bind(this)
+ }
+ });
+ },
+ /**
+ * Specific method of the youtube api.
+ * Whenever the player starts playing/pausing/buffering/..., a setinterval is created.
+ * This setinterval is used to check te user's progress in the video.
+ * Once the user reaches a particular time in the video (30s before end), the slide will be considered as completed
+ * if the video doesn't have a mini-quiz.
+ * This method also allows to automatically go to the next slide (or the quiz associated to the current
+ * video) once the video is over
+ *
+ * @private
+ * @param {*} event
+ */
+ _onPlayerStateChange: function (event){
+ var self = this;
+
+ if (self.slide.completed) {
+ return;
+ }
+
+ if (event.data !== YT.PlayerState.ENDED) {
+ if (!event.target.getCurrentTime) {
+ return;
+ }
+
+ if (self.tid) {
+ clearInterval(self.tid);
+ }
+
+ self.currentVideoTime = event.target.getCurrentTime();
+ self.totalVideoTime = event.target.getDuration();
+ self.tid = setInterval(function (){
+ self.currentVideoTime += 1;
+ if (self.totalVideoTime && self.currentVideoTime > self.totalVideoTime - 30){
+ clearInterval(self.tid);
+ if (!self.slide.hasQuestion && !self.slide.completed){
+ self.trigger_up('slide_to_complete', self.slide);
+ }
+ }
+ }, 1000);
+ } else {
+ if (self.tid) {
+ clearInterval(self.tid);
+ }
+ this.player = undefined;
+ if (this.slide.hasNext) {
+ this.trigger_up('slide_go_next');
+ }
+ }
+ },
+ });
+
+
+ /**
+ * This widget is responsible of navigation for one slide to another:
+ * - by clicking on any slide list entry
+ * - by mouse click (next / prev)
+ * - by recieving the order to go to prev/next slide (`goPrevious` and `goNext` public methods)
+ *
+ * The widget will trigger an event `change_slide` with
+ * the `slideId` and `isMiniQuiz` as data.
+ */
+ var Sidebar = publicWidget.Widget.extend({
+ events: {
+ "click .o_wslides_fs_sidebar_list_item": '_onClickTab',
+ },
+ init: function (parent, slideList, defaultSlide) {
+ var result = this._super.apply(this, arguments);
+ this.slideEntries = slideList;
+ this.set('slideEntry', defaultSlide);
+ return result;
+ },
+ start: function (){
+ var self = this;
+ this.on('change:slideEntry', this, this._onChangeCurrentSlide);
+ return this._super.apply(this, arguments).then(function (){
+ $(document).keydown(self._onKeyDown.bind(self));
+ });
+ },
+ destroy: function () {
+ $(document).unbind('keydown', this._onKeyDown.bind(this));
+ return this._super.apply(this, arguments);
+ },
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+ /**
+ * Change the current slide with the next one (if there is one).
+ *
+ * @public
+ */
+ goNext: function () {
+ var currentIndex = this._getCurrentIndex();
+ if (currentIndex < this.slideEntries.length-1) {
+ this.set('slideEntry', this.slideEntries[currentIndex+1]);
+ }
+ },
+ /**
+ * Change the current slide with the previous one (if there is one).
+ *
+ * @public
+ */
+ goPrevious: function () {
+ var currentIndex = this._getCurrentIndex();
+ if (currentIndex >= 1) {
+ this.set('slideEntry', this.slideEntries[currentIndex-1]);
+ }
+ },
+ /**
+ * Greens up the bullet when the slide is completed
+ *
+ * @public
+ * @param {Integer} slideId
+ */
+ setSlideCompleted: function (slideId) {
+ var $elem = this.$('.fa-circle-thin[data-slide-id="'+slideId+'"]');
+ $elem.removeClass('fa-circle-thin').addClass('fa-check text-success o_wslides_slide_completed');
+ },
+ /**
+ * Updates the progressbar whenever a lesson is completed
+ *
+ * @public
+ * @param {*} channelCompletion
+ */
+ updateProgressbar: function (channelCompletion) {
+ var completion = Math.min(100, channelCompletion);
+ this.$('.progress-bar').css('width', completion + "%" );
+ this.$('.o_wslides_progress_percentage').text(completion);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+ /**
+ * Get the index of the current slide entry (slide and/or quiz)
+ */
+ _getCurrentIndex: function () {
+ var slide = this.get('slideEntry');
+ var currentIndex = _.findIndex(this.slideEntries, function (entry) {
+ return entry.id === slide.id && entry.isQuiz === slide.isQuiz;
+ });
+ return currentIndex;
+ },
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+ /**
+ * Handler called whenever the user clicks on a sub-quiz which is linked to a slide.
+ * This does NOT handle the case of a slide of type "quiz".
+ * By going through this handler, the widget will be able to determine that it has to render
+ * the associated quiz and not the main content.
+ *
+ * @private
+ * @param {*} ev
+ */
+ _onClickMiniQuiz: function (ev){
+ var slideID = parseInt($(ev.currentTarget).data().slide_id);
+ this.set('slideEntry',{
+ slideID: slideID,
+ isMiniQuiz: true
+ });
+ this.trigger_up('change_slide', this.get('slideEntry'));
+ },
+ /**
+ * Handler called when the user clicks on a normal slide tab
+ *
+ * @private
+ * @param {*} ev
+ */
+ _onClickTab: function (ev) {
+ ev.stopPropagation();
+ var $elem = $(ev.currentTarget);
+ if ($elem.data('canAccess') === 'True') {
+ var isQuiz = $elem.data('isQuiz');
+ var slideID = parseInt($elem.data('id'));
+ var slide = findSlide(this.slideEntries, {id: slideID, isQuiz: isQuiz});
+ this.set('slideEntry', slide);
+ }
+ },
+ /**
+ * Actively changes the active tab in the sidebar so that it corresponds
+ * the slide currently displayed
+ *
+ * @private
+ */
+ _onChangeCurrentSlide: function () {
+ var slide = this.get('slideEntry');
+ this.$('.o_wslides_fs_sidebar_list_item.active').removeClass('active');
+ var selector = '.o_wslides_fs_sidebar_list_item[data-id='+slide.id+'][data-is-quiz!="1"]';
+
+ this.$(selector).addClass('active');
+ this.trigger_up('change_slide', this.get('slideEntry'));
+ },
+
+ /**
+ * Binds left and right arrow to allow the user to navigate between slides
+ *
+ * @param {*} ev
+ * @private
+ */
+ _onKeyDown: function (ev){
+ switch (ev.key){
+ case "ArrowLeft":
+ this.goPrevious();
+ break;
+ case "ArrowRight":
+ this.goNext();
+ break;
+ }
+ },
+ });
+
+ var ShareDialog = Dialog.extend({
+ template: 'website.slide.share.modal',
+ events: {
+ 'click .o_wslides_js_share_email button': '_onShareByEmailClick',
+ 'click a.o_wslides_js_social_share': '_onSlidesSocialShare',
+ 'click .o_clipboard_button': '_onShareLinkCopy',
+ },
+
+ init: function (parent, options, slide) {
+ options = _.defaults(options || {}, {
+ title: "Share",
+ buttons: [{text: "Cancel", close: true}],
+ size: 'medium',
+ });
+ this._super(parent, options);
+ this.slide = slide;
+ this.session = session;
+ },
+
+ _onShareByEmailClick: function() {
+ var form = this.$('.o_wslides_js_share_email');
+ var input = form.find('input');
+ var slideID = form.find('button').data('slide-id');
+ if (input.val() && input[0].checkValidity()) {
+ form.removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
+ this._rpc({
+ route: '/slides/slide/send_share_email',
+ params: {
+ slide_id: slideID,
+ email: input.val(),
+ fullscreen: true
+ },
+ }).then(function () {
+ form.html('<div class="alert alert-info" role="alert">' + _t('<strong>Thank you!</strong> Mail has been sent.') + '</div>');
+ });
+ } else {
+ form.addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
+ input.focus();
+ }
+ },
+
+ _onSlidesSocialShare: function (ev) {
+ ev.preventDefault();
+ var popUpURL = $(ev.currentTarget).attr('href');
+ window.open(popUpURL, 'Share Dialog', 'width=626,height=436');
+ },
+
+ _onShareLinkCopy: function (ev) {
+ ev.preventDefault();
+ var $clipboardBtn = this.$('.o_clipboard_button');
+ $clipboardBtn.tooltip({title: "Copied !", trigger: "manual", placement: "bottom"});
+ var self = this;
+ var clipboard = new ClipboardJS('.o_clipboard_button', {
+ target: function () {
+ return self.$('.o_wslides_js_share_link')[0];
+ },
+ container: this.el
+ });
+ clipboard.on('success', function () {
+ clipboard.destroy();
+ $clipboardBtn.tooltip('show');
+ _.delay(function () {
+ $clipboardBtn.tooltip("hide");
+ }, 800);
+ });
+ clipboard.on('error', function (e) {
+ clipboard.destroy();
+ })
+ },
+
+ });
+
+ var ShareButton = publicWidget.Widget.extend({
+ events: {
+ "click .o_wslides_fs_share": '_onClickShareSlide'
+ },
+
+ init: function (el, slide) {
+ var result = this._super.apply(this, arguments);
+ this.slide = slide;
+ return result;
+ },
+
+ _openDialog: function() {
+ return new ShareDialog(this, {}, this.slide).open();
+ },
+
+ _onClickShareSlide: function (ev) {
+ ev.preventDefault();
+ this._openDialog();
+ },
+
+ _onChangeSlide: function (currentSlide) {
+ this.slide = currentSlide;
+ }
+
+ });
+
+ /**
+ * This widget's purpose is to show content of a course, naviguating through contents
+ * and correclty display it. It also handle slide completion, course progress, ...
+ *
+ * This widget is rendered sever side, and attached to the existing DOM.
+ */
+ var Fullscreen = publicWidget.Widget.extend({
+ events: {
+ "click .o_wslides_fs_toggle_sidebar": '_onClickToggleSidebar',
+ },
+ custom_events: {
+ 'change_slide': '_onChangeSlideRequest',
+ 'slide_to_complete': '_onSlideToComplete',
+ 'slide_completed': '_onSlideCompleted',
+ 'slide_go_next': '_onSlideGoToNext',
+ },
+ /**
+ * @override
+ * @param {Object} el
+ * @param {Object} slides Contains the list of all slides of the course
+ * @param {integer} defaultSlideId Contains the ID of the slide requested by the user
+ */
+ init: function (parent, slides, defaultSlideId, channelData){
+ var result = this._super.apply(this,arguments);
+ this.initialSlideID = defaultSlideId;
+ this.slides = this._preprocessSlideData(slides);
+ this.channel = channelData;
+ var slide;
+ var urlParams = $.deparam.querystring();
+ if (defaultSlideId) {
+ slide = findSlide(this.slides, {id: defaultSlideId, isQuiz: urlParams.quiz === "1" });
+ } else {
+ slide = this.slides[0];
+ }
+
+ this.set('slide', slide);
+
+ this.sidebar = new Sidebar(this, this.slides, slide);
+ this.shareButton = new ShareButton(this, slide);
+ return result;
+ },
+ /**
+ * @override
+ */
+ start: function (){
+ var self = this;
+ this.on('change:slide', this, this._onChangeSlide);
+ this._toggleSidebar();
+ return this._super.apply(this, arguments).then(function () {
+ return self._onChangeSlide(); // trigger manually once DOM ready, since slide content is not rendered server side
+ });
+ },
+ /**
+ * Extended to attach sub widget to sub DOM. This might be experimental but
+ * seems working fine.
+ *
+ * @override
+ */
+ attachTo: function (){
+ var defs = [this._super.apply(this, arguments)];
+ defs.push(this.sidebar.attachTo(this.$('.o_wslides_fs_sidebar')));
+ defs.push(this.shareButton.attachTo(this.$('.o_wslides_slide_fs_header')));
+ return $.when.apply($, defs);
+ },
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+ /**
+ * Fetches content with an rpc call for slides of type "webpage"
+ *
+ * @private
+ */
+ _fetchHtmlContent: function (){
+ var self = this;
+ var currentSlide = this.get('slide');
+ return self._rpc({
+ route:"/slides/slide/get_html_content",
+ params: {
+ 'slide_id': currentSlide.id
+ }
+ }).then(function (data){
+ if (data.html_content) {
+ currentSlide.htmlContent = data.html_content;
+ }
+ });
+ },
+ /**
+ * Fetches slide content depending on its type.
+ * If the slide doesn't need to fetch any content, return a resolved deferred
+ *
+ * @private
+ */
+ _fetchSlideContent: function (){
+ var slide = this.get('slide');
+ if (slide.type === 'webpage' && !slide.isQuiz) {
+ return this._fetchHtmlContent();
+ }
+ return Promise.resolve();
+ },
+ _markAsCompleted: function (slideId, completion) {
+ var slide = findSlide(this.slides, {id: slideId});
+ slide.completed = true;
+ this.sidebar.setSlideCompleted(slide.id);
+ this.sidebar.updateProgressbar(completion);
+ },
+ /**
+ * Extend the slide data list to add informations about rendering method, and other
+ * specific values according to their slide_type.
+ */
+ _preprocessSlideData: function (slidesDataList) {
+ slidesDataList.forEach(function (slideData, index) {
+ // compute hasNext slide
+ slideData.hasNext = index < slidesDataList.length-1;
+ // compute embed url
+ if (slideData.type === 'video') {
+ slideData.embedCode = $(slideData.embedCode).attr('src') || ""; // embedCode contains an iframe tag, where src attribute is the url (youtube or embed document from odoo)
+ var separator = slideData.embedCode.indexOf("?") !== -1 ? "&" : "?";
+ var scheme = slideData.embedCode.indexOf('//') === 0 ? 'https:' : '';
+ var params = { rel: 0, enablejsapi: 1, origin: window.location.origin };
+ if (slideData.embedCode.indexOf("//drive.google.com") === -1) {
+ params.autoplay = 1;
+ }
+ slideData.embedUrl = slideData.embedCode ? scheme + slideData.embedCode + separator + $.param(params) : "";
+ } else if (slideData.type === 'infographic') {
+ slideData.embedUrl = _.str.sprintf('/web/image/slide.slide/%s/image_1024', slideData.id);
+ } else if (_.contains(['document', 'presentation'], slideData.type)) {
+ slideData.embedUrl = $(slideData.embedCode).attr('src');
+ }
+ // fill empty property to allow searching on it with _.filter(list, matcher)
+ slideData.isQuiz = !!slideData.isQuiz;
+ slideData.hasQuestion = !!slideData.hasQuestion;
+ // technical settings for the Fullscreen to work
+ slideData._autoSetDone = _.contains(['infographic', 'presentation', 'document', 'webpage'], slideData.type) && !slideData.hasQuestion;
+ });
+ return slidesDataList;
+ },
+ /**
+ * Changes the url whenever the user changes slides.
+ * This allows the user to refresh the page and stay on the right slide
+ *
+ * @private
+ */
+ _pushUrlState: function (){
+ var urlParts = window.location.pathname.split('/');
+ urlParts[urlParts.length-1] = this.get('slide').slug;
+ var url = urlParts.join('/');
+ this.$('.o_wslides_fs_exit_fullscreen').attr('href', url);
+ var params = {'fullscreen': 1 };
+ if (this.get('slide').isQuiz){
+ params.quiz = 1;
+ }
+ var fullscreenUrl = _.str.sprintf('%s?%s', url, $.param(params));
+ history.pushState(null, '', fullscreenUrl);
+ },
+ /**
+ * Render the current slide content using specific mecanism according to slide type:
+ * - simply append content (for webpage)
+ * - template rendering (for image, document, ....)
+ * - using a sub widget (quiz and video)
+ *
+ * @private
+ * @returns Deferred
+ */
+ _renderSlide: function () {
+ var slide = this.get('slide');
+ var $content = this.$('.o_wslides_fs_content');
+ $content.empty();
+
+ // display quiz slide, or quiz attached to a slide
+ if (slide.type === 'quiz' || slide.isQuiz) {
+ $content.addClass('bg-white');
+ var QuizWidget = new Quiz(this, slide, this.channel);
+ return QuizWidget.appendTo($content);
+ }
+
+ // render slide content
+ if (_.contains(['document', 'presentation', 'infographic'], slide.type)) {
+ $content.html(QWeb.render('website.slides.fullscreen.content', {widget: this}));
+ } else if (slide.type === 'video') {
+ this.videoPlayer = new VideoPlayer(this, slide);
+ return this.videoPlayer.appendTo($content);
+ } else if (slide.type === 'webpage'){
+ var $wpContainer = $('<div>').addClass('o_wslide_fs_webpage_content bg-white block w-100 overflow-auto');
+ $(slide.htmlContent).appendTo($wpContainer);
+ $content.append($wpContainer);
+ this.trigger_up('widgets_start_request', {
+ $target: $content,
+ });
+ }
+ return Promise.resolve();
+ },
+ /**
+ * Once the completion conditions are filled,
+ * rpc call to set the the relation between the slide and the user as "completed"
+ *
+ * @private
+ * @param {Integer} slideId: the id of slide to set as completed
+ */
+ _setCompleted: function (slideId){
+ var self = this;
+ var slide = findSlide(this.slides, {id: slideId});
+ if (!slide.completed) { // no useless RPC call
+ return this._rpc({
+ route: '/slides/slide/set_completed',
+ params: {
+ slide_id: slide.id,
+ }
+ }).then(function (data){
+ self._markAsCompleted(slideId, data.channel_completion);
+ return Promise.resolve();
+ });
+ }
+ return Promise.resolve();
+ },
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+ /**
+ * Triggered whenever the user changes slides.
+ * When the current slide is changed, widget will be automatically updated
+ * and allowed to: fetch the content if needed, render it, update the url,
+ * and set slide as "completed" according to its type requirements. In
+ * mobile case (i.e. limited screensize), sidebar will be toggled since
+ * sidebar will block most or all of new slide visibility.
+ *
+ * @private
+ */
+ _onChangeSlide: function () {
+ var self = this;
+ var slide = this.get('slide');
+ self._pushUrlState();
+ return this._fetchSlideContent().then(function() { // render content
+ var websiteName = document.title.split(" | ")[1]; // get the website name from title
+ document.title = (websiteName) ? slide.name + ' | ' + websiteName : slide.name;
+ if (config.device.size_class < config.device.SIZES.MD) {
+ self._toggleSidebar(); // hide sidebar when small device screen
+ }
+ return self._renderSlide();
+ }).then(function() {
+ if (slide._autoSetDone && !session.is_website_user) { // no useless RPC call
+ if (['document', 'presentation'].includes(slide.type)) {
+ // only set the slide as completed after iFrame is loaded to avoid concurrent execution with 'embedUrl' controller
+ self.el.querySelector('iframe.o_wslides_iframe_viewer').addEventListener('load', () => self._setCompleted(slide.id));
+ } else {
+ return self._setCompleted(slide.id);
+ }
+ }
+ });
+ },
+ /**
+ * Changes current slide when receiving custom event `change_slide` with
+ * its id and if it's its quizz or not we need to display.
+ *
+ * @private
+ */
+ _onChangeSlideRequest: function (ev){
+ var slideData = ev.data;
+ var newSlide = findSlide(this.slides, {
+ id: slideData.id,
+ isQuiz: slideData.isQuiz || false,
+ });
+ this.set('slide', newSlide);
+ this.shareButton._onChangeSlide(newSlide);
+ },
+ /**
+ * Triggered when subwidget has mark the slide as done, and the UI need to be adapted.
+ *
+ * @private
+ */
+ _onSlideCompleted: function (ev) {
+ var slide = ev.data.slide;
+ var completion = ev.data.completion;
+ this._markAsCompleted(slide.id, completion);
+ },
+ /**
+ * Triggered when sub widget business is done and that slide
+ * can now be marked as done.
+ *
+ * @private
+ */
+ _onSlideToComplete: function (ev) {
+ if (!session.is_website_user) { // no useless RPC call
+ var slideId = ev.data.id;
+ this._setCompleted(slideId);
+ }
+ },
+ /**
+ * Go the next slide
+ *
+ * @private
+ */
+ _onSlideGoToNext: function (ev) {
+ this.sidebar.goNext();
+ },
+ /**
+ * Called when the sidebar toggle is clicked -> toggles the sidebar visibility.
+ *
+ * @private
+ */
+ _onClickToggleSidebar: function (ev){
+ ev.preventDefault();
+ this._toggleSidebar();
+ },
+ /**
+ * Toggles sidebar visibility.
+ *
+ * @private
+ */
+ _toggleSidebar: function () {
+ this.$('.o_wslides_fs_sidebar').toggleClass('o_wslides_fs_sidebar_hidden');
+ this.$('.o_wslides_fs_toggle_sidebar').toggleClass('active');
+ },
+ });
+
+ publicWidget.registry.websiteSlidesFullscreenPlayer = publicWidget.Widget.extend({
+ selector: '.o_wslides_fs_main',
+ xmlDependencies: ['/website_slides/static/src/xml/website_slides_fullscreen.xml', '/website_slides/static/src/xml/website_slides_share.xml'],
+ start: function (){
+ var self = this;
+ var proms = [this._super.apply(this, arguments)];
+ var fullscreen = new Fullscreen(this, this._getSlides(), this._getCurrentSlideID(), this._extractChannelData());
+ proms.push(fullscreen.attachTo(".o_wslides_fs_main"));
+ return Promise.all(proms).then(function () {
+ $('#edit-page-menu a[data-action="edit"]').on('click', self._onWebEditorClick.bind(self));
+ });
+ },
+
+ /**
+ * The web editor does not work well with the e-learning fullscreen view.
+ * It actually completely closes the fullscreen view and opens the edition on a blank page.
+ *
+ * To avoid this, we intercept the click on the 'edit' button and redirect to the
+ * non-fullscreen view of this slide with the editor enabled, which is more suited to edit
+ * in-place anyway.
+ *
+ * @param {MouseEvent} e
+ */
+ _onWebEditorClick: function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ window.location = `${window.location.pathname}?fullscreen=0&enable_editor=1`;
+ },
+
+ _extractChannelData: function (){
+ return this.$el.data();
+ },
+ _getCurrentSlideID: function (){
+ return parseInt(this.$('.o_wslides_fs_sidebar_list_item.active').data('id'));
+ },
+ /**
+ * @private
+ * Creates slides objects from every slide-list-cells attributes
+ */
+ _getSlides: function (){
+ var $slides = this.$('.o_wslides_fs_sidebar_list_item[data-can-access="True"]');
+ var slideList = [];
+ $slides.each(function () {
+ var slideData = $(this).data();
+ slideList.push(slideData);
+ });
+ return slideList;
+ },
+ });
+
+ return Fullscreen;
+});
diff --git a/addons/website_slides/static/src/js/slides_course_join.js b/addons/website_slides/static/src/js/slides_course_join.js
new file mode 100644
index 00000000..0817f372
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides_course_join.js
@@ -0,0 +1,161 @@
+odoo.define('website_slides.course.join.widget', function (require) {
+'use strict';
+
+var core = require('web.core');
+var publicWidget = require('web.public.widget');
+
+var _t = core._t;
+
+var CourseJoinWidget = publicWidget.Widget.extend({
+ template: 'slide.course.join',
+ xmlDependencies: ['/website_slides/static/src/xml/slide_course_join.xml'],
+ events: {
+ 'click .o_wslides_js_course_join_link': '_onClickJoin',
+ },
+
+ /**
+ *
+ * Overridden to add options parameters.
+ *
+ * @param {Object} parent
+ * @param {Object} options
+ * @param {Object} options.channel slide.channel information
+ * @param {boolean} options.isMember whether current user is member or not
+ * @param {boolean} options.publicUser whether current user is public or not
+ * @param {string} [options.joinMessage] the message to use for the simple join case
+ * when the course if free and the user is logged in, defaults to "Join Course".
+ * @param {Promise} [options.beforeJoin] a promise to execute before we redirect to
+ * another url within the join process (login / buy course / ...)
+ * @param {function} [options.afterJoin] a callback function called after the user has
+ * joined the course
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.channel = options.channel;
+ this.isMember = options.isMember;
+ this.publicUser = options.publicUser;
+ this.joinMessage = options.joinMessage || _t('Join Course'),
+ this.beforeJoin = options.beforeJoin || Promise.resolve();
+ this.afterJoin = options.afterJoin || function () {document.location.reload();};
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickJoin: function (ev) {
+ ev.preventDefault();
+
+ if (this.channel.channelEnroll !== 'invite') {
+ if (this.publicUser) {
+ this.beforeJoin().then(this._redirectToLogin.bind(this));
+ } else if (!this.isMember && this.channel.channelEnroll === 'public') {
+ this.joinChannel(this.channel.channelId);
+ }
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Builds a login page that then redirects to this slide page, or the channel if the course
+ * is not configured as public enroll type.
+ *
+ * @private
+ */
+ _redirectToLogin: function () {
+ var url;
+ if (this.channel.channelEnroll === 'public') {
+ url = window.location.pathname;
+ if (document.location.href.indexOf("fullscreen") !== -1) {
+ url += '?fullscreen=1';
+ }
+ } else {
+ url = `/slides/${this.channel.channelId}`;
+ }
+ document.location = _.str.sprintf('/web/login?redirect=%s', encodeURIComponent(url));
+ },
+
+ /**
+ * @private
+ * @param {Object} $el
+ * @param {String} message
+ */
+ _popoverAlert: function ($el, message) {
+ $el.popover({
+ trigger: 'focus',
+ placement: 'bottom',
+ container: 'body',
+ html: true,
+ content: function () {
+ return message;
+ }
+ }).popover('show');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+ /**
+ * @public
+ * @param {integer} channelId
+ */
+ joinChannel: function (channelId) {
+ var self = this;
+ this._rpc({
+ route: '/slides/channel/join',
+ params: {
+ channel_id: channelId,
+ },
+ }).then(function (data) {
+ if (!data.error) {
+ self.afterJoin();
+ } else {
+ if (data.error === 'public_user') {
+ var message = _t('Please <a href="/web/login?redirect=%s">login</a> to join this course');
+ var signupAllowed = data.error_signup_allowed || false;
+ if (signupAllowed) {
+ message = _t('Please <a href="/web/signup?redirect=%s">create an account</a> to join this course');
+ }
+ self._popoverAlert(self.$el, _.str.sprintf(message, (document.URL)));
+ } else if (data.error === 'join_done') {
+ self._popoverAlert(self.$el, _t('You have already joined this channel'));
+ } else {
+ self._popoverAlert(self.$el, _t('Unknown error'));
+ }
+ }
+ });
+ },
+});
+
+publicWidget.registry.websiteSlidesCourseJoin = publicWidget.Widget.extend({
+ selector: '.o_wslides_js_course_join_link',
+
+ /**
+ * @override
+ * @param {Object} parent
+ */
+ start: function () {
+ var self = this;
+ var proms = [this._super.apply(this, arguments)];
+ var data = self.$el.data();
+ var options = {channel: {channelEnroll: data.channelEnroll, channelId: data.channelId}};
+ $('.o_wslides_js_course_join').each(function () {
+ proms.push(new CourseJoinWidget(self, options).attachTo($(this)));
+ });
+ return Promise.all(proms);
+ },
+});
+
+return {
+ courseJoinWidget: CourseJoinWidget,
+ websiteSlidesCourseJoin: publicWidget.registry.websiteSlidesCourseJoin
+};
+
+});
diff --git a/addons/website_slides/static/src/js/slides_course_quiz.js b/addons/website_slides/static/src/js/slides_course_quiz.js
new file mode 100644
index 00000000..6d2fd355
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides_course_quiz.js
@@ -0,0 +1,775 @@
+odoo.define('website_slides.quiz', function (require) {
+ 'use strict';
+
+ var publicWidget = require('web.public.widget');
+ var Dialog = require('web.Dialog');
+ var core = require('web.core');
+ var session = require('web.session');
+
+ var CourseJoinWidget = require('website_slides.course.join.widget').courseJoinWidget;
+ var QuestionFormWidget = require('website_slides.quiz.question.form');
+ var SlideQuizFinishModal = require('website_slides.quiz.finish');
+
+ var SlideEnrollDialog = require('website_slides.course.enroll').slideEnrollDialog;
+
+ var QWeb = core.qweb;
+ var _t = core._t;
+
+ /**
+ * This widget is responsible of displaying quiz questions and propositions. Submitting the quiz will fetch the
+ * correction and decorate the answers according to the result. Error message or modal can be displayed.
+ *
+ * This widget can be attached to DOM rendered server-side by `website_slides.slide_type_quiz` or
+ * used client side (Fullscreen).
+ *
+ * Triggered events are :
+ * - slide_go_next: need to go to the next slide, when quiz is done. Event data contains the current slide id.
+ * - quiz_completed: when the quiz is passed and completed by the user. Event data contains current slide data.
+ */
+ var Quiz = publicWidget.Widget.extend({
+ template: 'slide.slide.quiz',
+ xmlDependencies: [
+ '/website_slides/static/src/xml/slide_quiz.xml',
+ '/website_slides/static/src/xml/slide_course_join.xml'
+ ],
+ events: {
+ "click .o_wslides_quiz_answer": '_onAnswerClick',
+ "click .o_wslides_js_lesson_quiz_submit": '_submitQuiz',
+ "click .o_wslides_quiz_modal_btn": '_onClickNext',
+ "click .o_wslides_quiz_continue": '_onClickNext',
+ "click .o_wslides_js_lesson_quiz_reset": '_onClickReset',
+ 'click .o_wslides_js_quiz_add': '_onCreateQuizClick',
+ 'click .o_wslides_js_quiz_edit_question': '_onEditQuestionClick',
+ 'click .o_wslides_js_quiz_delete_question': '_onDeleteQuestionClick',
+ 'click .o_wslides_js_channel_enroll': '_onSendRequestToResponsibleClick',
+ },
+
+ custom_events: {
+ display_created_question: '_displayCreatedQuestion',
+ display_updated_question: '_displayUpdatedQuestion',
+ reset_display: '_resetDisplay',
+ delete_question: '_deleteQuestion',
+ },
+
+ /**
+ * @override
+ * @param {Object} parent
+ * @param {Object} slide_data holding all the classic slide information
+ * @param {Object} quiz_data : optional quiz data to display. If not given, will be fetched. (questions and answers).
+ */
+ init: function (parent, slide_data, channel_data, quiz_data) {
+ this._super.apply(this, arguments);
+ this.slide = _.defaults(slide_data, {
+ id: 0,
+ name: '',
+ hasNext: false,
+ completed: false,
+ isMember: false,
+ });
+ this.quiz = quiz_data || false;
+ if (this.quiz) {
+ this.quiz.questionsCount = quiz_data.questions.length;
+ }
+ this.isMember = slide_data.isMember || false;
+ this.publicUser = session.is_website_user;
+ this.userId = session.user_id;
+ this.redirectURL = encodeURIComponent(document.URL);
+ this.channel = channel_data;
+ },
+
+ /**
+ * @override
+ */
+ willStart: function () {
+ var defs = [this._super.apply(this, arguments)];
+ if (!this.quiz) {
+ defs.push(this._fetchQuiz());
+ }
+ return Promise.all(defs);
+ },
+
+ /**
+ * Overridden to add custom rendering behavior upon start of the widget.
+ *
+ * If the user has answered the quiz before having joined the course, we check
+ * his answers (saved into his session) here as well.
+ *
+ * @override
+ */
+ start: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self._renderValidationInfo();
+ self._bindSortable();
+ self._checkLocationHref();
+ if (!self.isMember) {
+ self._renderJoinWidget();
+ } else if (self.slide.sessionAnswers) {
+ self._applySessionAnswers();
+ self._submitQuiz();
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _alertShow: function (alertCode) {
+ var message = _t('There was an error validating this quiz.');
+ if (alertCode === 'slide_quiz_incomplete') {
+ message = _t('All questions must be answered !');
+ } else if (alertCode === 'slide_quiz_done') {
+ message = _t('This quiz is already done. Retaking it is not possible.');
+ } else if (alertCode === 'public_user') {
+ message = _t('You must be logged to submit the quiz.');
+ }
+
+ this.displayNotification({
+ type: 'warning',
+ message: message,
+ sticky: true
+ });
+ },
+
+ /**
+ * Allows to reorder the questions
+ * @private
+ */
+ _bindSortable: function () {
+ this.$el.sortable({
+ handle: '.o_wslides_js_quiz_sequence_handler',
+ items: '.o_wslides_js_lesson_quiz_question',
+ stop: this._reorderQuestions.bind(this),
+ placeholder: 'o_wslides_js_quiz_sequence_highlight position-relative my-3'
+ });
+ },
+
+ /**
+ * Get all the questions ID from the displayed Quiz
+ * @returns {Array}
+ * @private
+ */
+ _getQuestionsIds: function () {
+ return this.$('.o_wslides_js_lesson_quiz_question').map(function () {
+ return $(this).data('question-id');
+ }).get();
+ },
+
+ /**
+ * Modify visually the sequence of all the questions after
+ * calling the _reorderQuestions RPC call.
+ * @private
+ */
+ _modifyQuestionsSequence: function () {
+ this.$('.o_wslides_js_lesson_quiz_question').each(function (index, question) {
+ $(question).find('span.o_wslides_quiz_question_sequence').text(index + 1);
+ });
+ },
+
+ /**
+ * RPC call to resequence all the questions. It is called
+ * after modifying the sequence of a question and also after
+ * deleting a question.
+ * @private
+ */
+ _reorderQuestions: function () {
+ this._rpc({
+ route: '/web/dataset/resequence',
+ params: {
+ model: "slide.question",
+ ids: this._getQuestionsIds()
+ }
+ }).then(this._modifyQuestionsSequence.bind(this))
+ },
+ /*
+ * @private
+ * Fetch the quiz for a particular slide
+ */
+ _fetchQuiz: function () {
+ var self = this;
+ return self._rpc({
+ route:'/slides/slide/quiz/get',
+ params: {
+ 'slide_id': self.slide.id,
+ }
+ }).then(function (quiz_data) {
+ self.quiz = {
+ questions: quiz_data.slide_questions || [],
+ questionsCount: quiz_data.slide_questions.length,
+ quizAttemptsCount: quiz_data.quiz_attempts_count || 0,
+ quizKarmaGain: quiz_data.quiz_karma_gain || 0,
+ quizKarmaWon: quiz_data.quiz_karma_won || 0,
+ };
+ });
+ },
+
+ /**
+ * Hide the edit and delete button and also the handler
+ * to resequence the question
+ * @private
+ */
+ _hideEditOptions: function () {
+ this.$('.o_wslides_js_lesson_quiz_question .o_wslides_js_quiz_edit_del,' +
+ ' .o_wslides_js_lesson_quiz_question .o_wslides_js_quiz_sequence_handler').addClass('d-none');
+ },
+
+ /**
+ * @private
+ * Decorate the answers according to state
+ */
+ _disableAnswers: function () {
+ var self = this;
+ this.$('.o_wslides_js_lesson_quiz_question').addClass('completed-disabled');
+ this.$('input[type=radio]').each(function () {
+ $(this).prop('disabled', self.slide.completed);
+ });
+ },
+
+ /**
+ * Decorate the answer inputs according to the correction and adds the answer comment if
+ * any.
+ *
+ * @private
+ */
+ _renderAnswersHighlightingAndComments: function () {
+ var self = this;
+ this.$('.o_wslides_js_lesson_quiz_question').each(function () {
+ var $question = $(this);
+ var questionId = $question.data('questionId');
+ var isCorrect = self.quiz.answers[questionId].is_correct;
+ $question.find('a.o_wslides_quiz_answer').each(function () {
+ var $answer = $(this);
+ $answer.find('i.fa').addClass('d-none');
+ if ($answer.find('input[type=radio]')[0].checked) {
+ if (isCorrect) {
+ $answer.removeClass('list-group-item-danger').addClass('list-group-item-success');
+ $answer.find('i.fa-check-circle').removeClass('d-none');
+ } else {
+ $answer.removeClass('list-group-item-success').addClass('list-group-item-danger');
+ $answer.find('i.fa-times-circle').removeClass('d-none');
+ $answer.find('label input').prop('checked', false);
+ }
+ } else {
+ $answer.removeClass('list-group-item-danger list-group-item-success');
+ $answer.find('i.fa-circle').removeClass('d-none');
+ }
+ });
+ var comment = self.quiz.answers[questionId].comment;
+ if (comment) {
+ $question.find('.o_wslides_quiz_answer_info').removeClass('d-none');
+ $question.find('.o_wslides_quiz_answer_comment').text(comment);
+ }
+ });
+ },
+
+ /**
+ * Will check if we have answers coming from the session and re-apply them.
+ */
+ _applySessionAnswers: function () {
+ if (!this.slide.sessionAnswers || this.slide.sessionAnswers.length === 0) {
+ return;
+ }
+
+ var self = this;
+ this.$('.o_wslides_js_lesson_quiz_question').each(function () {
+ var $question = $(this);
+ $question.find('a.o_wslides_quiz_answer').each(function () {
+ var $answer = $(this);
+ if (!$answer.find('input[type=radio]')[0].checked &&
+ _.contains(self.slide.sessionAnswers, $answer.data('answerId'))) {
+ $answer.find('input[type=radio]').prop('checked', true);
+ }
+ });
+ });
+
+ // reset answers coming from the session
+ this.slide.sessionAnswers = false;
+ },
+
+ /*
+ * @private
+ * Update validation box (karma, buttons) according to widget state
+ */
+ _renderValidationInfo: function () {
+ var $validationElem = this.$('.o_wslides_js_lesson_quiz_validation');
+ $validationElem.html(
+ QWeb.render('slide.slide.quiz.validation', {'widget': this})
+ );
+ },
+
+ /**
+ * Renders the button to join a course.
+ * If the user is logged in, the course is public, and the user has previously tried to
+ * submit answers, we automatically attempt to join the course.
+ *
+ * @private
+ */
+ _renderJoinWidget: function () {
+ var $widgetLocation = this.$(".o_wslides_join_course_widget");
+ if ($widgetLocation.length !== 0) {
+ var courseJoinWidget = new CourseJoinWidget(this, {
+ isQuiz: true,
+ channel: this.channel,
+ isMember: this.isMember,
+ publicUser: this.publicUser,
+ beforeJoin: this._saveQuizAnswersToSession.bind(this),
+ afterJoin: this._afterJoin.bind(this),
+ joinMessage: _t('Join & Submit'),
+ });
+
+ courseJoinWidget.appendTo($widgetLocation);
+ if (!this.publicUser && courseJoinWidget.channel.channelEnroll === 'public' && this.slide.sessionAnswers) {
+ courseJoinWidget.joinChannel(this.channel.channelId);
+ }
+ }
+ },
+
+ /**
+ * Get the quiz answers filled in by the User
+ *
+ * @private
+ */
+ _getQuizAnswers: function () {
+ return this.$('input[type=radio]:checked').map(function (index, element) {
+ return parseInt($(element).val());
+ }).get();
+ },
+
+ /**
+ * Submit a quiz and get the correction. It will display messages
+ * according to quiz result.
+ *
+ * @private
+ */
+ _submitQuiz: function () {
+ var self = this;
+
+ return this._rpc({
+ route: '/slides/slide/quiz/submit',
+ params: {
+ slide_id: self.slide.id,
+ answer_ids: this._getQuizAnswers(),
+ }
+ }).then(function (data) {
+ if (data.error) {
+ self._alertShow(data.error);
+ } else {
+ self.quiz = _.extend(self.quiz, data);
+ if (data.completed) {
+ self._disableAnswers();
+ new SlideQuizFinishModal(self, {
+ quiz: self.quiz,
+ hasNext: self.slide.hasNext,
+ userId: self.userId
+ }).open();
+ self.slide.completed = true;
+ self.trigger_up('slide_completed', {slide: self.slide, completion: data.channel_completion});
+ }
+ self._hideEditOptions();
+ self._renderAnswersHighlightingAndComments();
+ self._renderValidationInfo();
+ }
+ });
+ },
+
+ /**
+ * Get all the question information after clicking on
+ * the edit button
+ * @param $elem
+ * @returns {{id: *, sequence: number, text: *, answers: Array}}
+ * @private
+ */
+ _getQuestionDetails: function ($elem) {
+ var answers = [];
+ $elem.find('.o_wslides_quiz_answer').each(function () {
+ answers.push({
+ 'id': $(this).data('answerId'),
+ 'text_value': $(this).data('text'),
+ 'is_correct': $(this).data('isCorrect'),
+ 'comment': $(this).data('comment')
+ });
+ });
+ return {
+ 'id': $elem.data('questionId'),
+ 'sequence': parseInt($elem.find('.o_wslides_quiz_question_sequence').text()),
+ 'text': $elem.data('title'),
+ 'answers': answers,
+ };
+ },
+
+ /**
+ * If the slides has been called with the Add Quiz button on the slide list
+ * it goes straight to the 'Add Quiz' button and clicks on it.
+ * @private
+ */
+ _checkLocationHref: function () {
+ if (window.location.href.includes('quiz_quick_create')) {
+ this._onCreateQuizClick();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * When clicking on an answer, this one should be marked as "checked".
+ *
+ * @private
+ * @param OdooEvent ev
+ */
+ _onAnswerClick: function (ev) {
+ ev.preventDefault();
+ if (!this.slide.completed) {
+ $(ev.currentTarget).find('input[type=radio]').prop('checked', true);
+ }
+ },
+
+ /**
+ * Triggering a event to switch to next slide
+ *
+ * @private
+ * @param OdooEvent ev
+ */
+ _onClickNext: function (ev) {
+ if (this.slide.hasNext) {
+ this.trigger_up('slide_go_next');
+ }
+ },
+
+ /**
+ * Resets the completion of the slide so the user can take
+ * the quiz again
+ *
+ * @private
+ */
+ _onClickReset: function () {
+ this._rpc({
+ route: '/slides/slide/quiz/reset',
+ params: {
+ slide_id: this.slide.id
+ }
+ }).then(function () {
+ window.location.reload();
+ });
+ },
+ /**
+ * Saves the answers from the user and redirect the user to the
+ * specified url
+ *
+ * @private
+ */
+ _saveQuizAnswersToSession: function () {
+ var quizAnswers = this._getQuizAnswers();
+ if (quizAnswers.length === this.quiz.questions.length) {
+ return this._rpc({
+ route: '/slides/slide/quiz/save_to_session',
+ params: {
+ 'quiz_answers': {'slide_id': this.slide.id, 'slide_answers': quizAnswers},
+ }
+ });
+ } else {
+ this._alertShow('slide_quiz_incomplete');
+ return Promise.reject('The quiz is incomplete');
+ }
+ },
+ /**
+ * After joining the course, we immediately submit the quiz and get the correction.
+ * This allows a smooth onboarding when the user is logged in and the course is public.
+ *
+ * @private
+ */
+ _afterJoin: function () {
+ this.isMember = true;
+ this._renderValidationInfo();
+ this._applySessionAnswers();
+ this._submitQuiz();
+ },
+
+ /**
+ * When clicking on 'Add a Question' or 'Add Quiz' it
+ * initialize a new QuestionFormWidget to input the new
+ * question.
+ * @private
+ */
+ _onCreateQuizClick: function () {
+ var $elem = this.$('.o_wslides_js_lesson_quiz_new_question');
+ this.$('.o_wslides_js_quiz_add').addClass('d-none');
+ new QuestionFormWidget(this, {
+ slideId: this.slide.id,
+ sequence: this.quiz.questionsCount + 1
+ }).appendTo($elem);
+ },
+
+ /**
+ * When clicking on the edit button of a question it
+ * initialize a new QuestionFormWidget with the existing
+ * question as inputs.
+ * @param ev
+ * @private
+ */
+ _onEditQuestionClick: function (ev) {
+ var $editedQuestion = $(ev.currentTarget).closest('.o_wslides_js_lesson_quiz_question');
+ var question = this._getQuestionDetails($editedQuestion);
+ new QuestionFormWidget(this, {
+ editedQuestion: $editedQuestion,
+ question: question,
+ slideId: this.slide.id,
+ sequence: question.sequence,
+ update: true
+ }).insertAfter($editedQuestion);
+ $editedQuestion.hide();
+ },
+
+ /**
+ * When clicking on the delete button of a question it
+ * toggles a modal to confirm the deletion
+ * @param ev
+ * @private
+ */
+ _onDeleteQuestionClick: function (ev) {
+ var question = $(ev.currentTarget).closest('.o_wslides_js_lesson_quiz_question');
+ new ConfirmationDialog(this, {
+ questionId: question.data('questionId'),
+ questionTitle: question.data('title')
+ }).open();
+ },
+
+ /**
+ * Handler for the contact responsible link below a Quiz
+ * @param ev
+ * @private
+ */
+ _onSendRequestToResponsibleClick: function(ev) {
+ ev.preventDefault();
+ var channelId = $(ev.currentTarget).data('channelId');
+ new SlideEnrollDialog(this, {
+ channelId: channelId,
+ $element: $(ev.currentTarget).closest('.alert.alert-info')
+ }).open();
+ },
+
+ /**
+ * Displays the created Question at the correct place (after the last question or
+ * at the first place if there is no questions yet) It also displays the 'Add Question'
+ * button or open a new QuestionFormWidget if the user wants to immediately add another one.
+ *
+ * @param event
+ * @private
+ */
+ _displayCreatedQuestion: function (event) {
+ var $lastQuestion = this.$('.o_wslides_js_lesson_quiz_question:last');
+ if ($lastQuestion.length !== 0) {
+ $lastQuestion.after(event.data.newQuestionRenderedTemplate);
+ } else {
+ this.$el.prepend(event.data.newQuestionRenderedTemplate);
+ }
+ this.quiz.questionsCount++;
+ event.data.questionFormWidget.destroy();
+ this.$('.o_wslides_js_quiz_add_question').removeClass('d-none');
+ },
+
+ /**
+ * Replace the edited question by the new question and destroy
+ * the QuestionFormWidget.
+ * @param event
+ * @private
+ */
+ _displayUpdatedQuestion: function (event) {
+ var questionFormWidget = event.data.questionFormWidget;
+ event.data.$editedQuestion.replaceWith(event.data.newQuestionRenderedTemplate);
+ questionFormWidget.destroy();
+ },
+
+ /**
+ * If the user cancels the creation or update of a Question it resets the display
+ * of the updated Question or it displays back the buttons.
+ *
+ * @param event
+ * @private
+ */
+ _resetDisplay: function (event) {
+ var questionFormWidget = event.data.questionFormWidget;
+ if (questionFormWidget.update) {
+ questionFormWidget.$editedQuestion.show();
+ } else {
+ if (this.quiz.questionsCount > 0) {
+ this.$('.o_wslides_js_quiz_add_question').removeClass('d-none');
+ } else {
+ this.$('.o_wslides_js_quiz_add_quiz').removeClass('d-none');
+ }
+ }
+ questionFormWidget.destroy();
+ },
+
+ /**
+ * After deletion of a Question the display is refreshed with the removal of the Question
+ * the reordering of all the remaining Questions and the change of the new Question sequence
+ * if the QuestionFormWidget is initialized.
+ *
+ * @param event
+ * @private
+ */
+ _deleteQuestion: function (event) {
+ var questionId = event.data.questionId;
+ this.$('.o_wslides_js_lesson_quiz_question[data-question-id=' + questionId + ']').remove();
+ this.quiz.questionsCount--;
+ this._reorderQuestions();
+ var $newQuestionSequence = this.$('.o_wslides_js_lesson_quiz_new_question .o_wslides_quiz_question_sequence');
+ $newQuestionSequence.text(parseInt($newQuestionSequence.text()) - 1);
+ if (this.quiz.questionsCount === 0 && !this.$('.o_wsildes_quiz_question_input').length) {
+ this.$('.o_wslides_js_quiz_add_quiz').removeClass('d-none');
+ this.$('.o_wslides_js_quiz_add_question').addClass('d-none');
+ this.$('.o_wslides_js_lesson_quiz_validation').addClass('d-none');
+ }
+ },
+ });
+
+ /**
+ * Dialog box shown when clicking the deletion button on a Question.
+ * When confirming it sends a RPC request to delete the Question.
+ */
+ var ConfirmationDialog = Dialog.extend({
+ template: 'slide.quiz.confirm.deletion',
+ xmlDependencies: Dialog.prototype.xmlDependencies.concat(
+ ['/website_slides/static/src/xml/slide_quiz_create.xml']
+ ),
+
+ /**
+ * @override
+ * @param parent
+ * @param options
+ */
+ init: function (parent, options) {
+ options = _.defaults(options || {}, {
+ title: _t('Delete Question'),
+ buttons: [
+ { text: _t('Yes'), classes: 'btn-primary', click: this._onConfirmClick },
+ { text: _t('No'), close: true}
+ ],
+ size: 'medium'
+ });
+ this.questionId = options.questionId;
+ this.questionTitle = options.questionTitle;
+ this._super.apply(this, arguments);
+ },
+
+ /**
+ * Handler when the user confirm the deletion by clicking on 'Yes'
+ * it sends a RPC request to the server and triggers an event to
+ * visually delete the question.
+ * @private
+ */
+ _onConfirmClick: function () {
+ var self = this;
+ this._rpc({
+ model: 'slide.question',
+ method: 'unlink',
+ args: [this.questionId],
+ }).then(function () {
+ self.trigger_up('delete_question', { questionId: self.questionId });
+ self.close();
+ });
+ }
+ });
+
+ publicWidget.registry.websiteSlidesQuizNoFullscreen = publicWidget.Widget.extend({
+ selector: '.o_wslides_lesson_main', // selector of complete page, as we need slide content and aside content table
+ custom_events: {
+ slide_go_next: '_onQuizNextSlide',
+ slide_completed: '_onQuizCompleted',
+ },
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ * @param {Object} parent
+ */
+ start: function () {
+ var self = this;
+ this.quizWidgets = [];
+ var defs = [this._super.apply(this, arguments)];
+ this.$('.o_wslides_js_lesson_quiz').each(function () {
+ var slideData = $(this).data();
+ var channelData = self._extractChannelData(slideData);
+ slideData.quizData = {
+ questions: self._extractQuestionsAndAnswers(),
+ sessionAnswers: slideData.sessionAnswers || [],
+ quizKarmaMax: slideData.quizKarmaMax,
+ quizKarmaWon: slideData.quizKarmaWon || 0,
+ quizKarmaGain: slideData.quizKarmaGain,
+ quizAttemptsCount: slideData.quizAttemptsCount,
+ };
+ defs.push(new Quiz(self, slideData, channelData, slideData.quizData).attachTo($(this)));
+ });
+ return Promise.all(defs);
+ },
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //---------------------------------------------------------------------
+ _onQuizCompleted: function (ev) {
+ var slide = ev.data.slide;
+ var completion = ev.data.completion;
+ this.$('#o_wslides_lesson_aside_slide_check_' + slide.id).addClass('text-success fa-check').removeClass('text-600 fa-circle-o');
+ // need to use global selector as progress bar is outside this animation widget scope
+ $('.o_wslides_lesson_header .progress-bar').css('width', completion + "%");
+ $('.o_wslides_lesson_header .progress span').text(_.str.sprintf("%s %%", completion));
+ },
+ _onQuizNextSlide: function () {
+ var url = this.$('.o_wslides_js_lesson_quiz').data('next-slide-url');
+ window.location.replace(url);
+ },
+
+ //----------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ _extractChannelData: function (slideData) {
+ return {
+ channelId: slideData.channelId,
+ channelEnroll: slideData.channelEnroll,
+ channelRequestedAccess: slideData.channelRequestedAccess || false,
+ signupAllowed: slideData.signupAllowed
+ };
+ },
+
+ /**
+ * Extract data from exiting DOM rendered server-side, to have the list of questions with their
+ * relative answers.
+ * This method should return the same format as /slide/quiz/get controller.
+ *
+ * @return {Array<Object>} list of questions with answers
+ */
+ _extractQuestionsAndAnswers: function () {
+ var questions = [];
+ this.$('.o_wslides_js_lesson_quiz_question').each(function () {
+ var $question = $(this);
+ var answers = [];
+ $question.find('.o_wslides_quiz_answer').each(function () {
+ var $answer = $(this);
+ answers.push({
+ id: $answer.data('answerId'),
+ text: $answer.data('text'),
+ });
+ });
+ questions.push({
+ id: $question.data('questionId'),
+ title: $question.data('title'),
+ answer_ids: answers,
+ });
+ });
+ return questions;
+ },
+ });
+
+ return {
+ Quiz: Quiz,
+ ConfirmationDialog: ConfirmationDialog,
+ websiteSlidesQuizNoFullscreen: publicWidget.registry.websiteSlidesQuizNoFullscreen
+ };
+});
diff --git a/addons/website_slides/static/src/js/slides_course_quiz_finish.js b/addons/website_slides/static/src/js/slides_course_quiz_finish.js
new file mode 100644
index 00000000..8d6d11e5
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides_course_quiz_finish.js
@@ -0,0 +1,157 @@
+odoo.define('website_slides.quiz.finish', function (require) {
+'use strict';
+
+var Dialog = require('web.Dialog');
+var core = require('web.core');
+var _t = core._t;
+
+/**
+ * This modal is used when the user finishes the quiz.
+ * It handles the animation of karma gain and leveling up by animating
+ * the progress bar and the text.
+ */
+var SlideQuizFinishModal = Dialog.extend({
+ template: 'slide.slide.quiz.finish',
+ events: {
+ "click .o_wslides_quiz_modal_btn": '_onClickNext',
+ },
+
+ init: function(parent, options) {
+ var self = this;
+ this.quiz = options.quiz;
+ this.hasNext = options.hasNext;
+ this.userId = options.userId;
+ options = _.defaults(options || {}, {
+ size: 'medium',
+ dialogClass: 'd-flex p-0',
+ technical: false,
+ renderHeader: false,
+ renderFooter: false
+ });
+ this._super.apply(this, arguments);
+ this.opened(function () {
+ self._animateProgressBar();
+ self._animateText();
+ })
+ },
+
+ start: function() {
+ var self = this;
+ this._super.apply(this, arguments).then(function () {
+ self.$modal.addClass('o_wslides_quiz_modal pt-5');
+ self.$modal.find('.modal-dialog').addClass('mt-5');
+ self.$modal.find('.modal-content').addClass('shadow-lg');
+ });
+ },
+
+ //--------------------------------
+ // Handlers
+ //--------------------------------
+
+ _onClickNext: function() {
+ this.trigger_up('slide_go_next');
+ this.destroy();
+ },
+
+ //--------------------------------
+ // Private
+ //--------------------------------
+
+ /**
+ * Handles the animation of the karma gain in the following steps:
+ * 1. Initiate the tooltip which will display the actual Karma
+ * over the progress bar.
+ * 2. Animate the tooltip text to increment smoothly from the old
+ * karma value to the new karma value and updates it to make it
+ * move as the progress bar moves.
+ * 3a. The user doesn't level up
+ * I. When the user doesn't level up the progress bar simply goes
+ * from the old karma value to the new karma value.
+ * 3b. The user levels up
+ * I. The first step makes the progress bar go from the old karma
+ * value to 100%.
+ * II. The second step makes the progress bar go from 100% to 0%.
+ * III. The third and final step makes the progress bar go from 0%
+ * to the new karma value. It also changes the lower and upper
+ * bound to match the new rank.
+ * @param $modal
+ * @param rankProgress
+ * @private
+ */
+ _animateProgressBar: function () {
+ var self = this;
+ this.$('[data-toggle="tooltip"]').tooltip({
+ trigger: 'manual',
+ container: '.progress-bar-tooltip',
+ }).tooltip('show');
+
+ this.$('.tooltip-inner')
+ .prop('karma', this.quiz.rankProgress.previous_rank.karma)
+ .animate({
+ karma: this.quiz.rankProgress.new_rank.karma
+ }, {
+ duration: this.quiz.rankProgress.level_up ? 1700 : 800,
+ step: function (newKarma) {
+ self.$('.tooltip-inner').text(Math.ceil(newKarma));
+ self.$('[data-toggle="tooltip"]').tooltip('update');
+ }
+ }
+ );
+
+ var $progressBar = this.$('.progress-bar');
+ if (this.quiz.rankProgress.level_up) {
+ this.$('.o_wslides_quiz_modal_title').text(_t('Level up!'));
+ $progressBar.css('width', '100%');
+ _.delay(function () {
+ self.$('.o_wslides_quiz_modal_rank_lower_bound')
+ .text(self.quiz.rankProgress.new_rank.lower_bound);
+ self.$('.o_wslides_quiz_modal_rank_upper_bound')
+ .text(self.quiz.rankProgress.new_rank.upper_bound || "");
+
+ // we need to use _.delay to force DOM re-rendering between 0 and new percentage
+ _.delay(function () {
+ $progressBar.addClass('no-transition').width('0%');
+ }, 1);
+ _.delay(function () {
+ $progressBar
+ .removeClass('no-transition')
+ .width(self.quiz.rankProgress.new_rank.progress + '%');
+ }, 100);
+ }, 800);
+ } else {
+ $progressBar.css('width', this.quiz.rankProgress.new_rank.progress + '%');
+ }
+ },
+
+ /**
+ * Handles the animation of the different text such as the karma gain
+ * and the motivational message when the user levels up.
+ * @private
+ */
+ _animateText: function () {
+ var self = this;
+ _.delay(function () {
+ self.$('h4.o_wslides_quiz_modal_xp_gained').addClass('show in');
+ self.$('.o_wslides_quiz_modal_dismiss').removeClass('d-none');
+ }, 800);
+
+ if (this.quiz.rankProgress.level_up) {
+ _.delay(function () {
+ self.$('.o_wslides_quiz_modal_rank_motivational').addClass('fade');
+ _.delay(function () {
+ self.$('.o_wslides_quiz_modal_rank_motivational').html(
+ self.quiz.rankProgress.last_rank ?
+ self.quiz.rankProgress.description :
+ self.quiz.rankProgress.new_rank.motivational
+ );
+ self.$('.o_wslides_quiz_modal_rank_motivational').addClass('show in');
+ }, 800);
+ }, 800);
+ }
+ },
+
+});
+
+return SlideQuizFinishModal;
+
+});
diff --git a/addons/website_slides/static/src/js/slides_course_quiz_question_form.js b/addons/website_slides/static/src/js/slides_course_quiz_question_form.js
new file mode 100644
index 00000000..0fd43d43
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides_course_quiz_question_form.js
@@ -0,0 +1,228 @@
+odoo.define('website_slides.quiz.question.form', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+var core = require('web.core');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+/**
+ * This Widget is responsible of displaying the question inputs when adding a new question or when updating an
+ * existing one. When validating the question it makes an RPC call to the server and trigger an event for
+ * displaying the question by the Quiz widget.
+ */
+var QuestionFormWidget = publicWidget.Widget.extend({
+ template: 'slide.quiz.question.input',
+ xmlDependencies: ['/website_slides/static/src/xml/slide_quiz_create.xml'],
+ events: {
+ 'click .o_wslides_js_quiz_validate_question': '_validateQuestion',
+ 'click .o_wslides_js_quiz_cancel_question': '_cancelValidation',
+ 'click .o_wslides_js_quiz_comment_answer': '_toggleAnswerLineComment',
+ 'click .o_wslides_js_quiz_add_answer': '_addAnswerLine',
+ 'click .o_wslides_js_quiz_remove_answer': '_removeAnswerLine',
+ 'click .o_wslides_js_quiz_remove_answer_comment': '_removeAnswerLineComment',
+ 'change .o_wslides_js_quiz_answer_comment > input[type=text]': '_onCommentChanged'
+ },
+
+ /**
+ * @override
+ * @param parent
+ * @param options
+ */
+ init: function (parent, options) {
+ this.$editedQuestion = options.editedQuestion;
+ this.question = options.question || {};
+ this.update = options.update;
+ this.sequence = options.sequence;
+ this.slideId = options.slideId;
+ this._super.apply(this, arguments);
+ },
+
+ /**
+ * @override
+ * @returns {*}
+ */
+ start: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self.$('.o_wslides_quiz_question input').focus();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ *
+ * @param commentInput
+ * @private
+ */
+ _onCommentChanged: function (event) {
+ var input = event.currentTarget;
+ var commentIcon = $(input).closest('.o_wslides_js_quiz_answer').find('.o_wslides_js_quiz_comment_answer');
+ if (input.value.trim() !== '') {
+ commentIcon.addClass('text-primary');
+ commentIcon.removeClass('text-muted');
+ } else {
+ commentIcon.addClass('text-muted');
+ commentIcon.removeClass('text-primary');
+ }
+ },
+
+ /**
+ * Toggle the input for commenting the answer line which will be
+ * seen by the frontend user when submitting the quiz.
+ * @param ev
+ * @private
+ */
+ _toggleAnswerLineComment: function (ev) {
+ var commentLine = $(ev.currentTarget).closest('.o_wslides_js_quiz_answer').find('.o_wslides_js_quiz_answer_comment').toggleClass('d-none');
+ commentLine.find('input[type=text]').focus();
+ },
+
+ /**
+ * Adds a new answer line after the element the user clicked on
+ * e.g. If there is 3 answer lines and the user click on the add
+ * answer button on the second line, the new answer line will
+ * display between the second and the third line.
+ * @param ev
+ * @private
+ */
+ _addAnswerLine: function (ev) {
+ $(ev.currentTarget).closest('.o_wslides_js_quiz_answer').after(QWeb.render('slide.quiz.answer.line'));
+ },
+
+ /**
+ * Removes an answer line. Can't remove the last answer line.
+ * @param ev
+ * @private
+ */
+ _removeAnswerLine: function (ev) {
+ if (this.$('.o_wslides_js_quiz_answer').length > 1) {
+ $(ev.currentTarget).closest('.o_wslides_js_quiz_answer').remove();
+ }
+ },
+
+ /**
+ *
+ * @param ev
+ * @private
+ */
+ _removeAnswerLineComment: function (ev) {
+ var commentLine = $(ev.currentTarget).closest('.o_wslides_js_quiz_answer_comment').addClass('d-none');
+ commentLine.find('input[type=text]').val('').change();
+ },
+
+ /**
+ * Handler when user click on 'Save' or 'Update' buttons.
+ * @param ev
+ * @private
+ */
+ _validateQuestion: function (ev) {
+ this._createOrUpdateQuestion({
+ update: $(ev.currentTarget).hasClass('o_wslides_js_quiz_update'),
+ });
+ },
+
+ /**
+ * Handler when user click on the 'Cancel' button.
+ * Calls a method from slides_course_quiz.js widget
+ * which will handle the reset of the question display.
+ * @private
+ */
+ _cancelValidation: function () {
+ this.trigger_up('reset_display', {
+ questionFormWidget: this,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * RPC call to create or update a question.
+ * Triggers method from slides_course_quiz.js to
+ * correctly display the question.
+ * @param options
+ * @private
+ */
+ _createOrUpdateQuestion: function (options) {
+ var self = this;
+ var $form = this.$('form');
+ if (this._isValidForm($form)) {
+ var values = this._serializeForm($form);
+ this._rpc({
+ route: '/slides/slide/quiz/question_add_or_update',
+ params: values
+ }).then(function (renderedQuestion) {
+ if (options.update) {
+ self.trigger_up('display_updated_question', {
+ newQuestionRenderedTemplate: renderedQuestion,
+ $editedQuestion: self.$editedQuestion,
+ questionFormWidget: self,
+ });
+ } else {
+ self.trigger_up('display_created_question', {
+ newQuestionRenderedTemplate: renderedQuestion,
+ questionFormWidget: self
+ });
+ }
+ });
+ } else {
+ this.displayNotification({
+ type: 'warning',
+ message: _t('Please fill in the question'),
+ sticky: true
+ });
+ this.$('.o_wslides_quiz_question input').focus();
+ }
+ },
+
+ /**
+ * Check if the Question has been filled up
+ * @param $form
+ * @returns {boolean}
+ * @private
+ */
+ _isValidForm: function($form) {
+ return $form.find('.o_wslides_quiz_question input[type=text]').val().trim() !== "";
+ },
+
+ /**
+ * Serialize the form into a JSON object to send it
+ * to the server through a RPC call.
+ * @param $form
+ * @returns {{id: *, sequence: *, question: *, slide_id: *, answer_ids: Array}}
+ * @private
+ */
+ _serializeForm: function ($form) {
+ var answers = [];
+ var sequence = 1;
+ $form.find('.o_wslides_js_quiz_answer').each(function () {
+ var value = $(this).find('.o_wslides_js_quiz_answer_value').val();
+ if (value.trim() !== "") {
+ var answer = {
+ 'sequence': sequence++,
+ 'text_value': value,
+ 'is_correct': $(this).find('input[type=radio]').prop('checked') === true,
+ 'comment': $(this).find('.o_wslides_js_quiz_answer_comment > input[type=text]').val().trim()
+ };
+ answers.push(answer);
+ }
+ });
+ return {
+ 'existing_question_id': this.$el.data('id'),
+ 'sequence': this.sequence,
+ 'question': $form.find('.o_wslides_quiz_question input[type=text]').val(),
+ 'slide_id': this.slideId,
+ 'answer_ids': answers
+ };
+ },
+
+});
+
+return QuestionFormWidget;
+});
diff --git a/addons/website_slides/static/src/js/slides_course_slides_list.js b/addons/website_slides/static/src/js/slides_course_slides_list.js
new file mode 100644
index 00000000..fa45200a
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides_course_slides_list.js
@@ -0,0 +1,114 @@
+odoo.define('website_slides.course.slides.list', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+var core = require('web.core');
+var _t = core._t;
+
+publicWidget.registry.websiteSlidesCourseSlidesList = publicWidget.Widget.extend({
+ selector: '.o_wslides_slides_list',
+ xmlDependencies: ['/website_slides/static/src/xml/website_slides_upload.xml'],
+
+ start: function () {
+ this._super.apply(this,arguments);
+
+ this.channelId = this.$el.data('channelId');
+
+ this._updateHref();
+ this._bindSortable();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------,
+
+ /**
+ * Bind the sortable jQuery widget to both
+ * - course sections
+ * - course slides
+ *
+ * @private
+ */
+ _bindSortable: function () {
+ this.$('ul.o_wslides_js_slides_list_container').sortable({
+ handle: '.o_wslides_slides_list_drag',
+ stop: this._reorderSlides.bind(this),
+ items: '.o_wslides_slide_list_category',
+ placeholder: 'o_wslides_slides_list_slide_hilight position-relative mb-1'
+ });
+
+ this.$('.o_wslides_js_slides_list_container ul').sortable({
+ handle: '.o_wslides_slides_list_drag',
+ connectWith: '.o_wslides_js_slides_list_container ul',
+ stop: this._reorderSlides.bind(this),
+ items: '.o_wslides_slides_list_slide:not(.o_wslides_js_slides_list_empty)',
+ placeholder: 'o_wslides_slides_list_slide_hilight position-relative mb-1'
+ });
+ },
+
+ /**
+ * This method will check that a section is empty/not empty
+ * when the slides are reordered and show/hide the
+ * "Empty category" placeholder.
+ *
+ * @private
+ */
+ _checkForEmptySections: function (){
+ this.$('.o_wslides_slide_list_category').each(function (){
+ var $categoryHeader = $(this).find('.o_wslides_slide_list_category_header');
+ var categorySlideCount = $(this).find('.o_wslides_slides_list_slide:not(.o_not_editable)').length;
+ var $emptyFlagContainer = $categoryHeader.find('.o_wslides_slides_list_drag').first();
+ var $emptyFlag = $emptyFlagContainer.find('small');
+ if (categorySlideCount === 0 && $emptyFlag.length === 0){
+ $emptyFlagContainer.append($('<small>', {
+ 'class': "ml-1 text-muted font-weight-bold",
+ text: _t("(empty)")
+ }));
+ } else if (categorySlideCount > 0 && $emptyFlag.length > 0){
+ $emptyFlag.remove();
+ }
+ });
+ },
+
+ _getSlides: function (){
+ var categories = [];
+ this.$('.o_wslides_js_list_item').each(function (){
+ categories.push(parseInt($(this).data('slideId')));
+ });
+ return categories;
+ },
+ _reorderSlides: function (){
+ var self = this;
+ self._rpc({
+ route: '/web/dataset/resequence',
+ params: {
+ model: "slide.slide",
+ ids: self._getSlides()
+ }
+ }).then(function (res) {
+ self._checkForEmptySections();
+ });
+ },
+
+ /**
+ * Change links href to fullscreen mode for SEO.
+ *
+ * Specifications demand that links are generated (xml) without the "fullscreen"
+ * parameter for SEO purposes.
+ *
+ * This method then adds the parameter as soon as the page is loaded.
+ *
+ * @private
+ */
+ _updateHref: function () {
+ this.$(".o_wslides_js_slides_list_slide_link").each(function (){
+ var href = $(this).attr('href');
+ var operator = href.indexOf('?') !== -1 ? '&' : '?';
+ $(this).attr('href', href + operator + "fullscreen=1");
+ });
+ }
+});
+
+return publicWidget.registry.websiteSlidesCourseSlidesList;
+
+});
diff --git a/addons/website_slides/static/src/js/slides_course_tag_add.js b/addons/website_slides/static/src/js/slides_course_tag_add.js
new file mode 100644
index 00000000..b62e9cf0
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides_course_tag_add.js
@@ -0,0 +1,377 @@
+odoo.define('website_slides.channel_tag.add', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var publicWidget = require('web.public.widget');
+
+var _t = core._t;
+
+var TagCourseDialog = Dialog.extend({
+ template: 'website.slides.tag.add',
+ events: _.extend({}, Dialog.prototype.events, {
+ 'change input#tag_id' : '_onChangeTag',
+ }),
+
+ /**
+ * @override
+ * @param {Object} parent
+ * @param {Object} options holding channelId
+ *
+ */
+ init: function (parent, options) {
+ options = _.defaults(options || {}, {
+ title: _t("Add a tag"),
+ size: 'medium',
+ buttons: [{
+ text: _t("Add"),
+ classes: 'btn-primary',
+ click: this._onClickFormSubmit.bind(this)
+ }, {
+ text: _t("Discard"),
+ click: this._onClickClose.bind(this)
+ }]
+ });
+
+ this.channelID = parseInt(options.channelId, 10);
+ this.tagIds = options.channelTagIds || [];
+ // Open with a tag name as default
+ this.defaultTag = options.defaultTag;
+ this._super(parent, options);
+ },
+ start: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self._bindSelect2Dropdown();
+ self._hideTagGroup();
+ if (self.defaultTag) {
+ self._setDefaultSelection();
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * 'Tag' and 'Tag Group' management for select2
+ *
+ * @private
+ */
+ _bindSelect2Dropdown: function () {
+ var self = this;
+ this.$('#tag_id').select2(this._select2Wrapper(_t('Tag'),
+ function () {
+ return self._rpc({
+ route: '/slides/channel/tag/search_read',
+ params: {
+ fields: ['name'],
+ domain: [['id','not in',self.tagIds]],
+ }
+ });
+ })
+ );
+ this.$('#tag_group_id').select2(this._select2Wrapper(_t('Tag Group (required for new tags)'),
+ function () {
+ return self._rpc({
+ route: '/slides/channel/tag/group/search_read',
+ params: {
+ fields: ['name'],
+ domain: [],
+ }
+ });
+ })
+ );
+ },
+
+ /**
+ * Wrapper for select2 load data from server at once and store it.
+ *
+ * @private
+ * @param {String} Placeholder for element.
+ * @param {Function} Function to fetch data from remote location should return a Promise
+ * resolved data should be array of object with id and name. eg. [{'id': id, 'name': 'text'}, ...]
+ * @param {String} [nameKey='name'] (optional) the name key of the returned record
+ * ('name' if not provided)
+ * @returns {Object} select2 wrapper object
+ */
+ _select2Wrapper: function (tag, fetchFNC, nameKey) {
+ nameKey = nameKey || 'name';
+
+ var values = {
+ width: '100%',
+ placeholder: tag,
+ allowClear: true,
+ formatNoMatches: false,
+ selection_data: false,
+ fetch_rpc_fnc: fetchFNC,
+ formatSelection: function (data) {
+ if (data.tag) {
+ data.text = data.tag;
+ }
+ return data.text;
+ },
+ createSearchChoice: function (term, data) {
+ var addedTags = $(this.opts.element).select2('data');
+ if (_.filter(_.union(addedTags, data), function (tag) {
+ return tag.text.toLowerCase().localeCompare(term.toLowerCase()) === 0;
+ }).length === 0) {
+ if (this.opts.can_create) {
+ return {
+ id: _.uniqueId('tag_'),
+ create: true,
+ tag: term,
+ text: _.str.sprintf(_t("Create new %s '%s'"), tag, term),
+ };
+ } else {
+ return undefined;
+ }
+ }
+ },
+ fill_data: function (query, data) {
+ var that = this,
+ tags = {results: []};
+ _.each(data, function (obj) {
+ if (that.matcher(query.term, obj[nameKey])) {
+ tags.results.push({id: obj.id, text: obj[nameKey]});
+ }
+ });
+ query.callback(tags);
+ },
+ query: function (query) {
+ var that = this;
+ // fetch data only once and store it
+ if (!this.selection_data) {
+ this.fetch_rpc_fnc().then(function (data) {
+ that.can_create = data.can_create;
+ that.fill_data(query, data.read_results);
+ that.selection_data = data.read_results;
+ });
+ } else {
+ this.fill_data(query, this.selection_data);
+ }
+ }
+ };
+ return values;
+ },
+
+ _setDefaultSelection: function () {
+ this.$('#tag_id').select2('data', {id: _.uniqueId('tag_'), text: this.defaultTag, create: true}, true);
+ this.$('#tag_id').select2('readonly', true);
+ },
+
+ /**
+ * Get value for tag_id and [when appropriate] tag_group_id to send to server
+ *
+ * @private
+ */
+ _getSelect2DropdownValues: function () {
+ var result = {};
+ var tag = this.$('#tag_id').select2('data');
+ if (tag) {
+ if (tag.create) {
+ // new tag
+ var group = this.$('#tag_group_id').select2('data');
+ if(group) {
+ result['tag_id'] = [0, {'name': tag.text}]
+ if (group.create) {
+ // new tag group
+ result['group_id'] = [0, {'name': group.text}];
+ } else {
+ result['group_id'] = [group.id];
+ }
+ }
+ } else {
+ result['tag_id'] = [tag.id];
+ }
+ }
+ return result;
+ },
+
+ /**
+ * Select2 fields makes the "required" input hidden on the interface.
+ * Therefore we need to make a method to visually provide this requirement
+ * feedback to users. "tag group" field should only need this when a new tag
+ * is created.
+ *
+ * @private
+ */
+ _formValidate: function ($form) {
+ $form.addClass('was-validated');
+ var result = $form[0].checkValidity();
+
+ var $tagInput = this.$('#tag_id');
+ if ($tagInput.length !== 0){
+ var $tagSelect2Container = $tagInput
+ .closest('.form-group')
+ .find('.select2-container');
+ $tagSelect2Container.removeClass('is-invalid is-valid');
+ if ($tagInput.is(':invalid')) {
+ $tagSelect2Container.addClass('is-invalid');
+ } else if ($tagInput.is(':valid')) {
+ $tagSelect2Container.addClass('is-valid');
+ var $tagGroupInput = this.$('#tag_group_id');
+ if ($tagGroupInput.length !== 0){
+ var $tagGroupSelect2Container = $tagGroupInput
+ .closest('.form-group')
+ .find('.select2-container');
+ if ($tagGroupInput.is(':invalid')) {
+ $tagGroupSelect2Container.addClass('is-invalid');
+ } else if ($tagGroupInput.is(':valid')) {
+ $tagGroupSelect2Container.addClass('is-valid');
+ }
+ }
+ }
+ }
+ return result;
+ },
+
+ _alertDisplay: function (message) {
+ this._alertRemove();
+ $('<div/>', {
+ "class": 'alert alert-warning',
+ role: 'alert'
+ }).text(message).insertBefore(this.$('form'));
+ },
+ _alertRemove: function () {
+ this.$('.alert-warning').remove();
+ },
+
+ /**
+ * When the user IS NOT creating a new tag, this function hides the group tag field
+ * and makes it not required. Since the select2 field makes an extra container, this
+ * needs to be hidden along with the group tag input field and its label.
+ *
+ * @private
+ */
+ _hideTagGroup: function () {
+ var $tag_group_id = this.$('#tag_group_id');
+ var $tagGroupSelect2Container = $tag_group_id.closest('.form-group');
+ $tagGroupSelect2Container.hide();
+ $tag_group_id.removeAttr("required");
+ $tag_group_id.select2("val", "");
+ },
+
+ /**
+ * When the user IS creating a new tag, this function shows the field and
+ * makes it required. Since the select2 field makes an extra container, this
+ * needs to be shown along with the group input field and its label.
+ *
+ * @private
+ */
+ _showTagGroup: function () {
+ var $tag_group_id = this.$('#tag_group_id');
+ var $tagGroupSelect2Container = $tag_group_id.closest('.form-group');
+ $tagGroupSelect2Container.show();
+ $tag_group_id.attr("required", "required");
+ },
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ _onClickFormSubmit: function () {
+ if (this.defaultTag && !this.channelID) {
+ this._createNewTag();
+ } else {
+ this._addTagToChannel();
+ }
+ },
+
+ _addTagToChannel: function () {
+ var self = this;
+ var $form = this.$('#slides_channel_tag_add_form');
+ if (this._formValidate($form)) {
+ var values = this._getSelect2DropdownValues();
+ return this._rpc({
+ route: '/slides/channel/tag/add',
+ params: {'channel_id': this.channelID,
+ 'tag_id': values.tag_id,
+ 'group_id': values.group_id},
+ }).then(function (data) {
+ if (data.error) {
+ self._alertDisplay(data.error);
+ } else {
+ window.location = data.url;
+ }
+ });
+ }
+ },
+
+ _createNewTag: function () {
+ var self = this;
+ var $form = this.$('#slides_channel_tag_add_form');
+ this.$('#tag_id').select2('readonly', false);
+ var valid = this._formValidate($form);
+ this.$('#tag_id').select2('readonly', true);
+ if (valid) {
+ var values = this._getSelect2DropdownValues();
+ return this._rpc({
+ route: '/slide_channel_tag/add',
+ params: {
+ 'tag_id': values.tag_id,
+ 'group_id': values.group_id
+ },
+ }).then(function (data) {
+ self.trigger_up('tag_refresh', { tag_id: data.tag_id });
+ self.close();
+ });
+ }
+ },
+
+ _onClickClose: function () {
+ if (this.defaultTag && !this.channelID) {
+ this.trigger_up('tag_remove_new');
+ }
+ this.close();
+ },
+
+ _onChangeTag: function (ev) {
+ var self = this;
+ var tag = $(ev.currentTarget).select2('data');
+ if (tag && tag.create) {
+ self._showTagGroup();
+ } else {
+ self._hideTagGroup();
+ }
+ },
+});
+
+publicWidget.registry.websiteSlidesTag = publicWidget.Widget.extend({
+ selector: '.o_wslides_js_channel_tag_add',
+ xmlDependencies: ['/website_slides/static/src/xml/website_slides_channel_tag.xml'],
+ events: {
+ 'click': '_onAddTagClick',
+ },
+
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _openDialog: function ($element) {
+ var data = $element.data();
+ return new TagCourseDialog(this, data).open();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onAddTagClick: function (ev) {
+ ev.preventDefault();
+ this._openDialog($(ev.currentTarget));
+ },
+});
+
+return {
+ TagCourseDialog: TagCourseDialog,
+ websiteSlidesTag: publicWidget.registry.websiteSlidesTag
+};
+
+});
diff --git a/addons/website_slides/static/src/js/slides_course_unsubscribe.js b/addons/website_slides/static/src/js/slides_course_unsubscribe.js
new file mode 100644
index 00000000..ec0e1049
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides_course_unsubscribe.js
@@ -0,0 +1,168 @@
+odoo.define('website_slides.unsubscribe_modal', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var publicWidget = require('web.public.widget');
+var utils = require('web.utils');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+var SlideUnsubscribeDialog = Dialog.extend({
+ template: 'slides.course.unsubscribe.modal',
+ _texts: {
+ titleSubscribe: _t("Subscribe"),
+ titleUnsubscribe: _t("Notifications"),
+ titleLeaveCourse: _t("Leave the course")
+ },
+
+ /**
+ * @override
+ * @param {Object} parent
+ * @param {Object} options
+ */
+ init: function (parent, options) {
+ options = _.defaults(options || {}, {
+ title: options.isFollower === 'True' ? this._texts.titleSubscribe : this._texts.titleUnsubscribe,
+ size: 'medium',
+ });
+ this._super(parent, options);
+
+ this.set('state', '_subscription');
+ this.on('change:state', this, this._onChangeType);
+
+ this.channelID = parseInt(options.channelId, 10);
+ this.isFollower = options.isFollower === 'True';
+ this.enroll = options.enroll;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self.$('input#subscribed').prop('checked', self.isFollower);
+ self._resetModal();
+ });
+ },
+
+ getSubscriptionState: function () {
+ return this.$('input#subscribed').prop('checked');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+ _getModalButtons: function () {
+ var btnList = [];
+ var state = this.get('state');
+ if (state === '_subscription') {
+ btnList.push({text: _t("Save"), classes: "btn-primary", click: this._onClickSubscriptionSubmit.bind(this)});
+ btnList.push({text: _t("Discard"), close: true});
+ btnList.push({text: _t("or Leave the course"), classes: "btn-danger ml-auto", click: this._onClickLeaveCourse.bind(this)});
+ } else if (state === '_leave') {
+ btnList.push({text: _t("Leave the course"), classes: "btn-danger", click: this._onClickLeaveCourseSubmit.bind(this)});
+ btnList.push({text: _t("Discard"), click: this._onClickLeaveCourseCancel.bind(this)});
+ }
+ return btnList;
+ },
+
+ /**
+ * @private
+ */
+ _resetModal: function () {
+ var state = this.get('state');
+ if (state === '_subscription') {
+ this.set_title(this.isFollower ? this._texts.titleUnsubscribe : this._texts.titleSubscribe);
+ this.$('input#subscribed').prop('checked', this.isFollower);
+ }
+ else if (state === '_leave') {
+ this.set_title(this._texts.titleLeaveCourse);
+ }
+ this.set_buttons(this._getModalButtons());
+ },
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+ _onClickLeaveCourse: function () {
+ this.set('state', '_leave');
+ },
+
+ _onClickLeaveCourseCancel: function () {
+ this.set('state', '_subscription');
+ },
+
+ _onClickLeaveCourseSubmit: function () {
+ this._rpc({
+ route: '/slides/channel/leave',
+ params: {channel_id: this.channelID},
+ }).then(function () {
+ window.location.reload();
+ });
+ },
+
+ _onClickSubscriptionSubmit: function () {
+ if (this.isFollower === this.getSubscriptionState()) {
+ this.destroy();
+ return;
+ }
+ this._rpc({
+ route: this.getSubscriptionState() ? '/slides/channel/subscribe' : '/slides/channel/unsubscribe',
+ params: {channel_id: this.channelID},
+ }).then(function () {
+ window.location.reload();
+ });
+ },
+
+ _onChangeType: function () {
+ var currentType = this.get('state');
+ var tmpl;
+ if (currentType === '_subscription') {
+ tmpl = 'slides.course.unsubscribe.modal.subscription';
+ } else if (currentType === '_leave') {
+ tmpl = 'slides.course.unsubscribe.modal.leave';
+ }
+ this.$('.o_w_slide_unsubscribe_modal_container').empty();
+ this.$('.o_w_slide_unsubscribe_modal_container').append(QWeb.render(tmpl, {widget: this}));
+
+ this._resetModal();
+ },
+});
+
+publicWidget.registry.websiteSlidesUnsubscribe = publicWidget.Widget.extend({
+ selector: '.o_wslides_js_channel_unsubscribe',
+ xmlDependencies: ['/website_slides/static/src/xml/website_slides_unsubscribe.xml'],
+ events: {
+ 'click': '_onUnsubscribeClick',
+ },
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _openDialog: function ($element) {
+ var data = $element.data();
+ return new SlideUnsubscribeDialog(this, data).open();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onUnsubscribeClick: function (ev) {
+ ev.preventDefault();
+ this._openDialog($(ev.currentTarget));
+ },
+});
+
+return {
+ SlideUnsubscribeDialog: SlideUnsubscribeDialog,
+ websiteSlidesUnsubscribe: publicWidget.registry.websiteSlidesUnsubscribe
+};
+
+});
diff --git a/addons/website_slides/static/src/js/slides_embed.js b/addons/website_slides/static/src/js/slides_embed.js
new file mode 100644
index 00000000..f7dfef25
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides_embed.js
@@ -0,0 +1,250 @@
+/**
+ * This is a minimal version of the PDFViewer widget.
+ * It is NOT use in the website_slides module, but it is called when embedding
+ * a slide/video/document. This code can depend on pdf.js, JQuery and Bootstrap
+ * (see website_slides.slide_embed_assets bundle, in website_slides_embed.xml)
+ */
+$(function () {
+
+ if ($('#PDFViewer') && $('#PDFViewerCanvas')) { // check if presentation only
+ var MIN_ZOOM=1, MAX_ZOOM=10, ZOOM_INCREMENT=.5;
+
+ // define embedded viewer (minimal object of the website.slide.PDFViewer widget)
+ var EmbeddedViewer = function ($viewer) {
+ var self = this;
+ this.viewer = $viewer;
+ this.slide_url = $viewer.find('#PDFSlideViewer').data('slideurl');
+ this.slide_id = $viewer.find('#PDFSlideViewer').data('slideid');
+ this.defaultpage = parseInt($viewer.find('#PDFSlideViewer').data('defaultpage'));
+ this.canvas = $viewer.find('canvas')[0];
+
+ this.pdf_viewer = new PDFSlidesViewer(this.slide_url, this.canvas, true);
+ this.pdf_viewer.loadDocument().then(function () {
+ self.on_loaded_file();
+ });
+ };
+ EmbeddedViewer.prototype.__proto__ = {
+ // jquery inside the object (like Widget)
+ $: function (selector) {
+ return this.viewer.find($(selector));
+ },
+ // post process action (called in '.then()')
+ on_loaded_file: function () {
+ this.$('canvas').show();
+ this.$('#page_count').text(this.pdf_viewer.pdf_page_total);
+ this.$('#PDFViewerLoader').hide();
+ if (this.pdf_viewer.pdf_page_total > 1) {
+ this.$('.o_slide_navigation_buttons').removeClass('hide');
+ }
+ // init first page to display
+ var initpage = this.defaultpage;
+ var pageNum = (initpage > 0 && initpage <= this.pdf_viewer.pdf_page_total) ? initpage : 1;
+ this.render_page(pageNum);
+ },
+ on_rendered_page: function (pageNumber) {
+ if (pageNumber) {
+ this.$('#page_number').val(pageNumber);
+ this.navUpdate(pageNumber);
+ }
+ },
+ on_resize: function() {
+ this.render_page(this.pdf_viewer.pdf_page_current);
+ },
+ // page switching
+ render_page: function (pageNumber) {
+ this.pdf_viewer.queueRenderPage(pageNumber).then(this.on_rendered_page.bind(this));
+ this.navUpdate(pageNumber);
+ },
+ change_page: function () {
+ var pageAsked = parseInt(this.$('#page_number').val(), 10);
+ if (1 <= pageAsked && pageAsked <= this.pdf_viewer.pdf_page_total) {
+ this.pdf_viewer.changePage(pageAsked).then(this.on_rendered_page.bind(this));
+ this.navUpdate(pageAsked);
+ } else {
+ // if page number out of range, reset the page_counter to the actual page
+ this.$('#page_number').val(this.pdf_viewer.pdf_page_current);
+ }
+ },
+ next: function () {
+ var self = this;
+ this.pdf_viewer.nextPage().then(function (pageNum) {
+ if (pageNum) {
+ self.on_rendered_page(pageNum);
+ } else {
+ if (self.pdf_viewer.pdf) { // avoid display suggestion when pdf is not loaded yet
+ self.display_suggested_slides();
+ }
+ }
+ });
+ },
+ previous: function () {
+ var self = this;
+ this.pdf_viewer.previousPage().then(function (pageNum) {
+ if (pageNum) {
+ self.on_rendered_page(pageNum);
+ }
+ self.$("#slide_suggest").addClass('d-none');
+ });
+ },
+ first: function () {
+ var self = this;
+ this.pdf_viewer.firstPage().then(function (pageNum) {
+ self.on_rendered_page(pageNum);
+ self.$("#slide_suggest").addClass('d-none');
+ });
+ },
+ last: function () {
+ var self = this;
+ this.pdf_viewer.lastPage().then(function (pageNum) {
+ self.on_rendered_page(pageNum);
+ self.$("#slide_suggest").addClass('d-none');
+ });
+ },
+ zoomIn: function() {
+ if(this.pdf_viewer.pdf_zoom < MAX_ZOOM) {
+ this.pdf_viewer.pdf_zoom += ZOOM_INCREMENT;
+ this.render_page(this.pdf_viewer.pdf_page_current);
+ }
+ },
+ zoomOut: function() {
+ if(this.pdf_viewer.pdf_zoom > MIN_ZOOM) {
+ this.pdf_viewer.pdf_zoom -= ZOOM_INCREMENT;
+ this.render_page(this.pdf_viewer.pdf_page_current);
+ }
+ },
+ navUpdate: function (pageNum) {
+ this.$('#first').toggleClass('disabled', pageNum < 3 );
+ this.$('#previous').toggleClass('disabled', pageNum < 2 );
+ this.$('#next, #last').removeClass('disabled');
+ this.$('#zoomout').toggleClass('disabled', this.pdf_viewer.pdf_zoom <= MIN_ZOOM);
+ this.$('#zoomin').toggleClass('disabled', this.pdf_viewer.pdf_zoom >= MAX_ZOOM);
+ },
+ // full screen mode
+ fullscreen: function () {
+ this.pdf_viewer.toggleFullScreen();
+ },
+ fullScreenFooter: function (ev) {
+ if (ev.target.id === "PDFViewerCanvas") {
+ this.pdf_viewer.toggleFullScreenFooter();
+ }
+ },
+ // display suggestion displayed after last slide
+ display_suggested_slides: function () {
+ this.$("#slide_suggest").removeClass('d-none');
+ this.$('#next, #last').addClass('disabled');
+ },
+ };
+
+ // embedded pdf viewer
+ var embeddedViewer = new EmbeddedViewer($('#PDFViewer'));
+
+ // bind the actions
+ $('#previous').on('click', function () {
+ embeddedViewer.previous();
+ });
+ $('#next').on('click', function () {
+ embeddedViewer.next();
+ });
+ $('#first').on('click', function () {
+ embeddedViewer.first();
+ });
+ $('#last').on('click', function () {
+ embeddedViewer.last();
+ });
+ $('#zoomin').on('click', function () {
+ embeddedViewer.zoomIn();
+ });
+ $('#zoomout').on('click', function () {
+ embeddedViewer.zoomOut();
+ });
+ $('#page_number').on('change', function () {
+ embeddedViewer.change_page();
+ });
+ $('#fullscreen').on('click', function () {
+ embeddedViewer.fullscreen();
+ });
+ $('#PDFViewer').on('click', function (ev) {
+ embeddedViewer.fullScreenFooter(ev);
+ });
+ $('#PDFViewer').on('wheel', function (ev) {
+ if (ev.metaKey || ev.ctrlKey) {
+ if (ev.originalEvent.deltaY > 0) {
+ embeddedViewer.zoomOut();
+ } else if(ev.originalEvent.deltaY < 0) {
+ embeddedViewer.zoomIn();
+ }
+ return false;
+ }
+ });
+ $(window).on('resize', _.debounce(function() {
+ embeddedViewer.on_resize();
+ }, 500));
+
+ // switching slide with keyboard
+ $(document).keydown(function (ev) {
+ if (ev.keyCode === 37 || ev.keyCode === 38) {
+ embeddedViewer.previous();
+ }
+ if (ev.keyCode === 39 || ev.keyCode === 40) {
+ embeddedViewer.next();
+ }
+ });
+
+ // display the option panels
+ $('.oe_slide_js_embed_option_link').on('click', function (ev) {
+ ev.preventDefault();
+ var toggleDiv = $(this).data('slide-option-id');
+ $('.oe_slide_embed_option').not(toggleDiv).each(function () {
+ $(this).hide();
+ });
+ $(toggleDiv).slideToggle();
+ });
+
+ // animation for the suggested slides
+ $('.oe_slides_suggestion_media').hover(
+ function () {
+ $(this).find('.oe_slides_suggestion_caption').stop().slideDown(250);
+ },
+ function () {
+ $(this).find('.oe_slides_suggestion_caption').stop().slideUp(250);
+ }
+ );
+
+ // embed widget page selector
+ $('.oe_slide_js_embed_code_widget input').on('change', function () {
+ var page = parseInt($(this).val());
+ if (!(page > 0 && page <= embeddedViewer.pdf_viewer.pdf_page_total)) {
+ page = 1;
+ }
+ var actualCode = embeddedViewer.$('.slide_embed_code').val();
+ var newCode = actualCode.replace(/(page=).*?([^\d]+)/, '$1' + page + '$2');
+ embeddedViewer.$('.slide_embed_code').val(newCode);
+ });
+
+ // To avoid create a dependancy to openerpframework.js, we use JQuery AJAX to post data instead of ajax.jsonRpc
+ $('.oe_slide_js_share_email button').on('click', function () {
+ var widget = $('.oe_slide_js_share_email');
+ var input = widget.find('input');
+ var slideID = widget.find('button').data('slide-id');
+ if (input.val() && input[0].checkValidity()) {
+ widget.removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
+ $.ajax({
+ type: "POST",
+ dataType: 'json',
+ url: '/slides/slide/send_share_email',
+ contentType: "application/json; charset=utf-8",
+ data: JSON.stringify({'jsonrpc': "2.0", 'method': "call", "params": {'slide_id': slideID, 'email': input.val()}}),
+ success: function () {
+ widget.html($('<div class="alert alert-info" role="alert"><strong>Thank you!</strong> Mail has been sent.</div>'));
+ },
+ error: function (data) {
+ console.error("ERROR ", data);
+ },
+ });
+ } else {
+ widget.addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
+ input.focus();
+ }
+ });
+ }
+});
diff --git a/addons/website_slides/static/src/js/slides_share.js b/addons/website_slides/static/src/js/slides_share.js
new file mode 100644
index 00000000..c9942f79
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides_share.js
@@ -0,0 +1,105 @@
+odoo.define('website_slides.slides_share', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+require('website_slides.slides');
+var core = require('web.core');
+var _t = core._t;
+
+var ShareMail = publicWidget.Widget.extend({
+ events: {
+ 'click button': '_sendMail',
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _sendMail: function () {
+ var self = this;
+ var input = this.$('input');
+ var slideID = this.$('button').data('slide-id');
+ if (input.val() && input[0].checkValidity()) {
+ this.$el.removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
+ this._rpc({
+ route: '/slides/slide/send_share_email',
+ params: {
+ slide_id: slideID,
+ email: input.val(),
+ },
+ }).then(function () {
+ self.$el.html($('<div class="alert alert-info" role="alert">' + _t('<strong>Thank you!</strong> Mail has been sent.') + '</div>'));
+ });
+ } else {
+ this.$el.addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
+ input.focus();
+ }
+ },
+});
+
+publicWidget.registry.websiteSlidesShare = publicWidget.Widget.extend({
+ selector: '#wrapwrap',
+ events: {
+ 'click a.o_wslides_js_social_share': '_onSlidesSocialShare',
+ 'click .o_clipboard_button': '_onShareLinkCopy',
+ },
+
+ /**
+ * @override
+ * @param {Object} parent
+ */
+ start: function (parent) {
+ var defs = [this._super.apply(this, arguments)];
+ defs.push(new ShareMail(this).attachTo($('.oe_slide_js_share_email')));
+
+ return Promise.all(defs);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @param {Object} ev
+ */
+ _onSlidesSocialShare: function (ev) {
+ ev.preventDefault();
+ var popUpURL = $(ev.currentTarget).attr('href');
+ var popUp = window.open(popUpURL, 'Share Dialog', 'width=626,height=436');
+ $(window).on('focus', function () {
+ if (popUp.closed) {
+ $(window).off('focus');
+ }
+ });
+ },
+
+ _onShareLinkCopy: function (ev) {
+ ev.preventDefault();
+ var $clipboardBtn = $(ev.currentTarget);
+ $clipboardBtn.tooltip({title: "Copied !", trigger: "manual", placement: "bottom"});
+ var self = this;
+ var clipboard = new ClipboardJS('#' + $clipboardBtn[0].id, {
+ target: function () {
+ var share_link_el = self.$('#wslides_share_link_id_' + $clipboardBtn[0].id.split('id_')[1]);
+ return share_link_el[0];
+ },
+ container: this.el
+ });
+ clipboard.on('success', function () {
+ clipboard.destroy();
+ $clipboardBtn.tooltip('show');
+ _.delay(function () {
+ $clipboardBtn.tooltip("hide");
+ }, 800);
+ });
+ clipboard.on('error', function (e) {
+ console.log(e);
+ clipboard.destroy();
+ })
+ },
+});
+});
diff --git a/addons/website_slides/static/src/js/slides_slide_archive.js b/addons/website_slides/static/src/js/slides_slide_archive.js
new file mode 100644
index 00000000..cccf2e0b
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides_slide_archive.js
@@ -0,0 +1,108 @@
+odoo.define('website_slides.slide.archive', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+var Dialog = require('web.Dialog');
+var core = require('web.core');
+var _t = core._t;
+
+var SlideArchiveDialog = Dialog.extend({
+ template: 'slides.slide.archive',
+
+ /**
+ * @override
+ */
+ init: function (parent, options) {
+ options = _.defaults(options || {}, {
+ title: _t('Archive Slide'),
+ size: 'medium',
+ buttons: [{
+ text: _t('Archive'),
+ classes: 'btn-primary',
+ click: this._onClickArchive.bind(this)
+ }, {
+ text: _t('Cancel'),
+ close: true
+ }]
+ });
+
+ this.$slideTarget = options.slideTarget;
+ this.slideId = this.$slideTarget.data('slideId');
+ this._super(parent, options);
+ },
+ _checkForEmptySections: function (){
+ $('.o_wslides_slide_list_category').each(function (){
+ var $categoryHeader = $(this).find('.o_wslides_slide_list_category_header');
+ var categorySlideCount = $(this).find('.o_wslides_slides_list_slide:not(.o_not_editable)').length;
+ var $emptyFlagContainer = $categoryHeader.find('.o_wslides_slides_list_drag').first();
+ var $emptyFlag = $emptyFlagContainer.find('small');
+ if (categorySlideCount === 0 && $emptyFlag.length === 0){
+ $emptyFlagContainer.append($('<small>', {
+ 'class': "ml-1 text-muted font-weight-bold",
+ text: _t("(empty)")
+ }));
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Calls 'archive' on slide controller and then visually removes the slide dom element
+ */
+ _onClickArchive: function () {
+ var self = this;
+
+ this._rpc({
+ route: '/slides/slide/archive',
+ params: {
+ slide_id: this.slideId
+ },
+ }).then(function (isArchived) {
+ if (isArchived){
+ self.$slideTarget.closest('.o_wslides_slides_list_slide').remove();
+ self._checkForEmptySections();
+ }
+ self.close();
+ });
+ }
+});
+
+publicWidget.registry.websiteSlidesSlideArchive = publicWidget.Widget.extend({
+ selector: '.o_wslides_js_slide_archive',
+ xmlDependencies: ['/website_slides/static/src/xml/slide_management.xml'],
+ events: {
+ 'click': '_onArchiveSlideClick',
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _openDialog: function ($slideTarget) {
+ new SlideArchiveDialog(this, {slideTarget: $slideTarget}).open();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onArchiveSlideClick: function (ev) {
+ ev.preventDefault();
+ var $slideTarget = $(ev.currentTarget);
+ this._openDialog($slideTarget);
+ },
+});
+
+return {
+ slideArchiveDialog: SlideArchiveDialog,
+ websiteSlidesSlideArchive: publicWidget.registry.websiteSlidesSlideArchive
+};
+
+});
diff --git a/addons/website_slides/static/src/js/slides_slide_like.js b/addons/website_slides/static/src/js/slides_slide_like.js
new file mode 100644
index 00000000..88bc3db1
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides_slide_like.js
@@ -0,0 +1,114 @@
+odoo.define('website_slides.slides.slide.like', function (require) {
+'use strict';
+
+var core = require('web.core');
+var publicWidget = require('web.public.widget');
+require('website_slides.slides');
+
+var _t = core._t;
+
+var SlideLikeWidget = publicWidget.Widget.extend({
+ events: {
+ 'click .o_wslides_js_slide_like_up': '_onClickUp',
+ 'click .o_wslides_js_slide_like_down': '_onClickDown',
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} $el
+ * @param {String} message
+ */
+ _popoverAlert: function ($el, message) {
+ $el.popover({
+ trigger: 'focus',
+ placement: 'bottom',
+ container: 'body',
+ html: true,
+ content: function () {
+ return message;
+ }
+ }).popover('show');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClick: function (slideId, voteType) {
+ var self = this;
+ this._rpc({
+ route: '/slides/slide/like',
+ params: {
+ slide_id: slideId,
+ upvote: voteType === 'like',
+ },
+ }).then(function (data) {
+ if (! data.error) {
+ self.$el.find('span.o_wslides_js_slide_like_up span').text(data.likes);
+ self.$el.find('span.o_wslides_js_slide_like_down span').text(data.dislikes);
+ } else {
+ if (data.error === 'public_user') {
+ var message = _t('Please <a href="/web/login?redirect=%s">login</a> to vote this lesson');
+ var signupAllowed = data.error_signup_allowed || false;
+ if (signupAllowed) {
+ message = _t('Please <a href="/web/signup?redirect=%s">create an account</a> to vote this lesson');
+ }
+ self._popoverAlert(self.$el, _.str.sprintf(message, (document.URL)));
+ } else if (data.error === 'vote_done') {
+ self._popoverAlert(self.$el, _t('You have already voted for this lesson'));
+ } else if (data.error === 'slide_access') {
+ self._popoverAlert(self.$el, _t('You don\'t have access to this lesson'));
+ } else if (data.error === 'channel_membership_required') {
+ self._popoverAlert(self.$el, _t('You must be member of this course to vote'));
+ } else if (data.error === 'channel_comment_disabled') {
+ self._popoverAlert(self.$el, _t('Votes and comments are disabled for this course'));
+ } else if (data.error === 'channel_karma_required') {
+ self._popoverAlert(self.$el, _t('You don\'t have enough karma to vote'));
+ } else {
+ self._popoverAlert(self.$el, _t('Unknown error'));
+ }
+ }
+ });
+ },
+
+ _onClickUp: function (ev) {
+ var slideId = $(ev.currentTarget).data('slide-id');
+ return this._onClick(slideId, 'like');
+ },
+
+ _onClickDown: function (ev) {
+ var slideId = $(ev.currentTarget).data('slide-id');
+ return this._onClick(slideId, 'dislike');
+ },
+});
+
+publicWidget.registry.websiteSlidesSlideLike = publicWidget.Widget.extend({
+ selector: '#wrapwrap',
+
+ /**
+ * @override
+ * @param {Object} parent
+ */
+ start: function () {
+ var self = this;
+ var defs = [this._super.apply(this, arguments)];
+ $('.o_wslides_js_slide_like').each(function () {
+ defs.push(new SlideLikeWidget(self).attachTo($(this)));
+ });
+ return Promise.all(defs);
+ },
+});
+
+return {
+ slideLikeWidget: SlideLikeWidget,
+ websiteSlidesSlideLike: publicWidget.registry.websiteSlidesSlideLike
+};
+
+});
diff --git a/addons/website_slides/static/src/js/slides_slide_toggle_is_preview.js b/addons/website_slides/static/src/js/slides_slide_toggle_is_preview.js
new file mode 100644
index 00000000..604170b6
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides_slide_toggle_is_preview.js
@@ -0,0 +1,40 @@
+odoo.define('website_slides.slide.preview', function (require) {
+ 'use strict';
+
+ var publicWidget = require('web.public.widget');
+
+ publicWidget.registry.websiteSlidesSlideToggleIsPreview = publicWidget.Widget.extend({
+ selector: '.o_wslides_js_slide_toggle_is_preview',
+ xmlDependencies: ['/website_slides/static/src/xml/slide_management.xml'],
+ events: {
+ 'click': '_onPreviewSlideClick',
+ },
+
+ _toggleSlidePreview: function($slideTarget) {
+ this._rpc({
+ route: '/slides/slide/toggle_is_preview',
+ params: {
+ slide_id: $slideTarget.data('slideId')
+ },
+ }).then(function (isPreview) {
+ if (isPreview) {
+ $slideTarget.removeClass('badge-light badge-hide border');
+ $slideTarget.addClass('badge-success');
+ } else {
+ $slideTarget.removeClass('badge-success');
+ $slideTarget.addClass('badge-light badge-hide border');
+ }
+ });
+ },
+
+ _onPreviewSlideClick: function (ev) {
+ ev.preventDefault();
+ this._toggleSlidePreview($(ev.currentTarget));
+ },
+ });
+
+ return {
+ websiteSlidesSlideToggleIsPreview: publicWidget.registry.websiteSlidesSlideToggleIsPreview
+ };
+
+});
diff --git a/addons/website_slides/static/src/js/slides_upload.js b/addons/website_slides/static/src/js/slides_upload.js
new file mode 100644
index 00000000..3ab24914
--- /dev/null
+++ b/addons/website_slides/static/src/js/slides_upload.js
@@ -0,0 +1,678 @@
+odoo.define('website_slides.upload_modal', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var publicWidget = require('web.public.widget');
+var utils = require('web.utils');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+var SlideUploadDialog = Dialog.extend({
+ template: 'website.slide.upload.modal',
+ events: _.extend({}, Dialog.prototype.events, {
+ 'click .o_wslides_js_upload_install_button': '_onClickInstallModule',
+ 'click .o_wslides_select_type': '_onClickSlideTypeIcon',
+ 'change input#upload': '_onChangeSlideUpload',
+ 'change input#url': '_onChangeSlideUrl',
+ }),
+
+ /**
+ * @override
+ * @param {Object} parent
+ * @param {Object} options holding channelId and optionally upload and publish control parameters
+ * @param {Object} options.modulesToInstall: list of additional modules to
+ * install {id: module ID, name: module short description}
+ */
+ init: function (parent, options) {
+ options = _.defaults(options || {}, {
+ title: _t("Upload a document"),
+ size: 'medium',
+ });
+ this._super(parent, options);
+ this._setup();
+
+ this.channelID = parseInt(options.channelId, 10);
+ this.defaultCategoryID = parseInt(options.categoryId,10);
+ this.canUpload = options.canUpload === 'True';
+ this.canPublish = options.canPublish === 'True';
+ this.modulesToInstall = options.modulesToInstall ? JSON.parse(options.modulesToInstall.replace(/'/g, '"')) : null;
+ this.modulesToInstallStatus = null;
+
+ this.set('state', '_select');
+ this.on('change:state', this, this._onChangeType);
+ this.set('can_submit_form', false);
+ this.on('change:can_submit_form', this, this._onChangeCanSubmitForm);
+
+ this.file = {};
+ this.isValidUrl = true;
+ },
+ start: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self._resetModalButton();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} message
+ */
+ _alertDisplay: function (message) {
+ this._alertRemove();
+ $('<div/>', {
+ "class": 'alert alert-warning',
+ id: 'upload-alert',
+ role: 'alert'
+ }).text(message).insertBefore(this.$('form'));
+ },
+ _alertRemove: function () {
+ this.$('#upload-alert').remove();
+ },
+ /**
+ * Section and tags management from select2
+ *
+ * @private
+ */
+ _bindSelect2Dropdown: function () {
+ var self = this;
+ this.$('#category_id').select2(this._select2Wrapper(_t('Section'), false,
+ function () {
+ return self._rpc({
+ route: '/slides/category/search_read',
+ params: {
+ fields: ['name'],
+ domain: [['channel_id', '=', self.channelID]],
+ }
+ });
+ })
+ );
+ this.$('#tag_ids').select2(this._select2Wrapper(_t('Tags'), true, function () {
+ return self._rpc({
+ route: '/slides/tag/search_read',
+ params: {
+ fields: ['name'],
+ domain: [],
+ }
+ });
+ }));
+ },
+ _fetchUrlPreview: function (url) {
+ return this._rpc({
+ route: '/slides/prepare_preview/',
+ params: {
+ 'url': url,
+ 'channel_id': this.channelID
+ },
+ });
+ },
+ _formSetFieldValue: function (fieldId, value) {
+ this.$('form').find('#'+fieldId).val(value);
+ },
+ _formGetFieldValue: function (fieldId) {
+ return this.$('#'+fieldId).val();
+ },
+ _formValidate: function () {
+ var form = this.$("form");
+ form.addClass('was-validated');
+ return form[0].checkValidity() && this.isValidUrl;
+ },
+ /**
+ * Extract values to submit from form, force the slide_type according to
+ * filled values.
+ *
+ * @private
+ */
+ _formValidateGetValues: function (forcePublished) {
+ var canvas = this.$('#data_canvas')[0];
+ var values = _.extend({
+ 'channel_id': this.channelID,
+ 'name': this._formGetFieldValue('name'),
+ 'url': this._formGetFieldValue('url'),
+ 'description': this._formGetFieldValue('description'),
+ 'duration': this._formGetFieldValue('duration'),
+ 'is_published': forcePublished,
+ }, this._getSelect2DropdownValues()); // add tags and category
+
+ // default slide_type (for webpage for instance)
+ if (_.contains(this.slide_type_data), this.get('state')) {
+ values['slide_type'] = this.get('state');
+ }
+
+ if (this.file.type === 'application/pdf') {
+ _.extend(values, {
+ 'image_1920': canvas.toDataURL().split(',')[1],
+ 'slide_type': canvas.height > canvas.width ? 'document' : 'presentation',
+ 'mime_type': this.file.type,
+ 'datas': this.file.data
+ });
+ } else if (values['slide_type'] === 'webpage') {
+ _.extend(values, {
+ 'mime_type': 'text/html',
+ 'image_1920': this.file.type === 'image/svg+xml' ? this._svgToPng() : this.file.data,
+ });
+ } else if (/^image\/.*/.test(this.file.type)) {
+ if (values['slide_type'] === 'presentation') {
+ _.extend(values, {
+ 'slide_type': 'infographic',
+ 'mime_type': this.file.type === 'image/svg+xml' ? 'image/png' : this.file.type,
+ 'datas': this.file.type === 'image/svg+xml' ? this._svgToPng() : this.file.data
+ });
+ } else {
+ _.extend(values, {
+ 'image_1920': this.file.type === 'image/svg+xml' ? this._svgToPng() : this.file.data,
+ });
+ }
+ }
+ return values;
+ },
+ /**
+ * @private
+ */
+ _fileReset: function () {
+ var control = this.$('#upload');
+ control.replaceWith(control = control.clone(true));
+ this.file.name = false;
+ },
+
+ _getModalButtons: function () {
+ var btnList = [];
+ var state = this.get('state');
+ if (state === '_select') {
+ btnList.push({text: _t("Cancel"), classes: 'o_w_slide_cancel', close: true});
+ } else if (state === '_import') {
+ if (! this.modulesToInstallStatus.installing) {
+ btnList.push({text: this.modulesToInstallStatus.failed ? _t("Retry") : _t("Install"), classes: 'btn-primary', click: this._onClickInstallModuleConfirm.bind(this)});
+ }
+ btnList.push({text: _t("Discard"), classes: 'o_w_slide_go_back', click: this._onClickGoBack.bind(this)});
+ } else if (state !== '_upload') { // no button when uploading
+ if (this.canUpload) {
+ if (this.canPublish) {
+ btnList.push({text: _t("Save & Publish"), classes: 'btn-primary o_w_slide_upload o_w_slide_upload_published', click: this._onClickFormSubmit.bind(this)});
+ btnList.push({text: _t("Save"), classes: 'o_w_slide_upload', click: this._onClickFormSubmit.bind(this)});
+ } else {
+ btnList.push({text: _t("Save"), classes: 'btn-primary o_w_slide_upload', click: this._onClickFormSubmit.bind(this)});
+ }
+ }
+ btnList.push({text: _t("Discard"), classes: 'o_w_slide_go_back', click: this._onClickGoBack.bind(this)});
+ }
+ return btnList;
+ },
+ /**
+ * Get value for category_id and tag_ids (ORM cmd) to send to server
+ *
+ * @private
+ */
+ _getSelect2DropdownValues: function () {
+ var result = {};
+ var self = this;
+ // tags
+ var tagValues = [];
+ _.each(this.$('#tag_ids').select2('data'), function (val) {
+ if (val.create) {
+ tagValues.push([0, 0, {'name': val.text}]);
+ } else {
+ tagValues.push([4, val.id]);
+ }
+ });
+ if (tagValues) {
+ result['tag_ids'] = tagValues;
+ }
+ // category
+ if (!self.defaultCategoryID) {
+ var categoryValue = this.$('#category_id').select2('data');
+ if (categoryValue && categoryValue.create) {
+ result['category_id'] = [0, {'name': categoryValue.text}];
+ } else if (categoryValue) {
+ result['category_id'] = [categoryValue.id];
+ this.categoryID = categoryValue.id;
+ }
+ } else {
+ result['category_id'] = [self.defaultCategoryID];
+ this.categoryID = self.defaultCategoryID;
+ }
+ return result;
+ },
+ /**
+ * Reset the footer buttons, according to current state of modal
+ *
+ * @private
+ */
+ _resetModalButton: function () {
+ this.set_buttons(this._getModalButtons());
+ },
+ /**
+ * Wrapper for select2 load data from server at once and store it.
+ *
+ * @private
+ * @param {String} Placeholder for element.
+ * @param {bool} true for multiple selection box, false for single selection
+ * @param {Function} Function to fetch data from remote location should return a Promise
+ * resolved data should be array of object with id and name. eg. [{'id': id, 'name': 'text'}, ...]
+ * @param {String} [nameKey='name'] (optional) the name key of the returned record
+ * ('name' if not provided)
+ * @returns {Object} select2 wrapper object
+ */
+ _select2Wrapper: function (tag, multi, fetchFNC, nameKey) {
+ nameKey = nameKey || 'name';
+
+ var values = {
+ width: '100%',
+ placeholder: tag,
+ allowClear: true,
+ formatNoMatches: false,
+ selection_data: false,
+ fetch_rpc_fnc: fetchFNC,
+ formatSelection: function (data) {
+ if (data.tag) {
+ data.text = data.tag;
+ }
+ return data.text;
+ },
+ createSearchChoice: function (term, data) {
+ var addedTags = $(this.opts.element).select2('data');
+ if (_.filter(_.union(addedTags, data), function (tag) {
+ return tag.text.toLowerCase().localeCompare(term.toLowerCase()) === 0;
+ }).length === 0) {
+ if (this.opts.can_create) {
+ return {
+ id: _.uniqueId('tag_'),
+ create: true,
+ tag: term,
+ text: _.str.sprintf(_t("Create new %s '%s'"), tag, term),
+ };
+ } else {
+ return undefined;
+ }
+ }
+ },
+ fill_data: function (query, data) {
+ var self = this,
+ tags = {results: []};
+ _.each(data, function (obj) {
+ if (self.matcher(query.term, obj[nameKey])) {
+ tags.results.push({id: obj.id, text: obj[nameKey]});
+ }
+ });
+ query.callback(tags);
+ },
+ query: function (query) {
+ var self = this;
+ // fetch data only once and store it
+ if (!this.selection_data) {
+ this.fetch_rpc_fnc().then(function (data) {
+ self.can_create = data.can_create;
+ self.fill_data(query, data.read_results);
+ self.selection_data = data.read_results;
+ });
+ } else {
+ this.fill_data(query, this.selection_data);
+ }
+ }
+ };
+
+ if (multi) {
+ values['multiple'] = true;
+ }
+
+ return values;
+ },
+ /**
+ * Init the data relative to the support slide type to upload
+ *
+ * @private
+ */
+ _setup: function () {
+ this.slide_type_data = {
+ presentation: {
+ icon: 'fa-file-pdf-o',
+ label: _t('Presentation'),
+ template: 'website.slide.upload.modal.presentation',
+ },
+ webpage: {
+ icon: 'fa-file-text',
+ label: _t('Web Page'),
+ template: 'website.slide.upload.modal.webpage',
+ },
+ video: {
+ icon: 'fa-video-camera',
+ label: _t('Video'),
+ template: 'website.slide.upload.modal.video',
+ },
+ quiz: {
+ icon: 'fa-question-circle',
+ label: _t('Quiz'),
+ template: 'website.slide.upload.quiz'
+ }
+ };
+ },
+ /**
+ * Show the preview
+ * @private
+ */
+ _showPreviewColumn: function () {
+ this.$('.o_slide_tutorial').addClass('d-none');
+ this.$('.o_slide_preview').removeClass('d-none');
+ },
+ /**
+ * Hide the preview
+ * @private
+ */
+ _hidePreviewColumn: function () {
+ this.$('.o_slide_tutorial').removeClass('d-none');
+ this.$('.o_slide_preview').addClass('d-none');
+ },
+ /**
+ * @private
+ */
+ // TODO: Remove this part, as now SVG support in image resize tools is included
+ //Python PIL does not support SVG, so converting SVG to PNG
+ _svgToPng: function () {
+ var img = this.$el.find('img#slide-image')[0];
+ var canvas = document.createElement('canvas');
+ canvas.width = img.width;
+ canvas.height = img.height;
+ canvas.getContext('2d').drawImage(img, 0, 0);
+ return canvas.toDataURL('image/png').split(',')[1];
+ },
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ _onChangeType: function () {
+ var currentType = this.get('state');
+ var tmpl;
+ this.$modal.find('.modal-dialog').removeClass('modal-lg');
+ if (currentType === '_select') {
+ tmpl = 'website.slide.upload.modal.select';
+ } else if (currentType === '_upload') {
+ tmpl = 'website.slide.upload.modal.uploading';
+ } else if (currentType === '_import') {
+ tmpl = 'website.slide.upload.modal.import';
+ } else {
+ tmpl = this.slide_type_data[currentType]['template'];
+ this.$modal.find('.modal-dialog').addClass('modal-lg');
+ }
+ this.$('.o_w_slide_upload_modal_container').empty();
+ this.$('.o_w_slide_upload_modal_container').append(QWeb.render(tmpl, {widget: this}));
+
+ this._resetModalButton();
+
+ if (currentType === '_import') {
+ this.set_title(_t("New Certification"));
+ } else {
+ this.set_title(_t("Upload a document"));
+ }
+ },
+ _onChangeCanSubmitForm: function (ev) {
+ if (this.get('can_submit_form')) {
+ this.$('.o_w_slide_upload').button('reset');
+ } else {
+ this.$('.o_w_slide_upload').button('loading');
+ }
+ },
+ _onChangeSlideUpload: function (ev) {
+ var self = this;
+ this._alertRemove();
+
+ var $input = $(ev.currentTarget);
+ var preventOnchange = $input.data('preventOnchange');
+ var $preview = self.$('#slide-image');
+
+ var file = ev.target.files[0];
+ if (!file) {
+ this.$('#slide-image').attr('src', '/website_slides/static/src/img/document.png');
+ this._hidePreviewColumn();
+ return;
+ }
+ var isImage = /^image\/.*/.test(file.type);
+ var loaded = false;
+ this.file.name = file.name;
+ this.file.type = file.type;
+ if (!(isImage || this.file.type === 'application/pdf')) {
+ this._alertDisplay(_t("Invalid file type. Please select pdf or image file"));
+ this._fileReset();
+ this._hidePreviewColumn();
+ return;
+ }
+ if (file.size / 1024 / 1024 > 25) {
+ this._alertDisplay(_t("File is too big. File size cannot exceed 25MB"));
+ this._fileReset();
+ this._hidePreviewColumn();
+ return;
+ }
+
+ utils.getDataURLFromFile(file).then(function (buffer) {
+ if (isImage) {
+ $preview.attr('src', buffer);
+ }
+ buffer = buffer.split(',')[1];
+ self.file.data = buffer;
+ self._showPreviewColumn();
+ });
+
+ if (file.type === 'application/pdf') {
+ var ArrayReader = new FileReader();
+ this.set('can_submit_form', false);
+ // file read as ArrayBuffer for pdfjsLib get_Document API
+ ArrayReader.readAsArrayBuffer(file);
+ ArrayReader.onload = function (evt) {
+ var buffer = evt.target.result;
+ var passwordNeeded = function () {
+ self._alertDisplay(_t("You can not upload password protected file."));
+ self._fileReset();
+ self.set('can_submit_form', true);
+ };
+ /**
+ * The following line fixes pdfjsLib 'Util' global variable.
+ * This is (most likely) related to #32181 which lazy loads most assets.
+ *
+ * That caused an issue where the global 'Util' variable from pdfjsLib can be
+ * (depending of which libraries load first) overridden by the global 'Util'
+ * variable of bootstrap.
+ * (See 'lib/bootstrap/js/util.js' and 'web/static/lib/pdfjs/build/pdfjs.js')
+ *
+ * This commit ensures that the global 'Util' variable is set to the one of pdfjsLib
+ * right before it's used.
+ *
+ * Eventually, we should update or get rid of one of the two libraries since they're
+ * not compatible together, or make a wrapper that makes them compatible.
+ * In the mean time, this small fix allows not refactoring all of this and can not
+ * cause much harm.
+ */
+ Util = window.pdfjsLib.Util;
+ window.pdfjsLib.getDocument(new Uint8Array(buffer), null, passwordNeeded).then(function getPdf(pdf) {
+ self._formSetFieldValue('duration', (pdf._pdfInfo.numPages || 0) * 5);
+ pdf.getPage(1).then(function getFirstPage(page) {
+ var scale = 1;
+ var viewport = page.getViewport(scale);
+ var canvas = document.getElementById('data_canvas');
+ var context = canvas.getContext('2d');
+ canvas.height = viewport.height;
+ canvas.width = viewport.width;
+ // Render PDF page into canvas context
+ page.render({
+ canvasContext: context,
+ viewport: viewport
+ }).then(function () {
+ var imageData = self.$('#data_canvas')[0].toDataURL();
+ $preview.attr('src', imageData);
+ if (loaded) {
+ self.set('can_submit_form', true);
+ }
+ loaded = true;
+ self._showPreviewColumn();
+ });
+ });
+ });
+ };
+ }
+
+ if (!preventOnchange) {
+ var input = file.name;
+ var inputVal = input.substr(0, input.lastIndexOf('.')) || input;
+ if (this._formGetFieldValue('name') === "") {
+ this._formSetFieldValue('name', inputVal);
+ }
+ }
+ },
+ _onChangeSlideUrl: function (ev) {
+ var self = this;
+ var url = $(ev.target).val();
+ this._alertRemove();
+ this.isValidUrl = false;
+ this.set('can_submit_form', false);
+ this._fetchUrlPreview(url).then(function (data) {
+ self.set('can_submit_form', true);
+ if (data.error) {
+ self._alertDisplay(data.error);
+ } else {
+ if (data.completion_time) {
+ // hours to minutes conversion
+ self._formSetFieldValue('duration', Math.round(data.completion_time * 60));
+ }
+ self.$('#slide-image').attr('src', data.url_src);
+ self._formSetFieldValue('name', data.title);
+ self._formSetFieldValue('description', data.description);
+
+ self.isValidUrl = true;
+ self._showPreviewColumn();
+ }
+ });
+ },
+
+ _onClickInstallModule: function (ev) {
+ var $btn = $(ev.currentTarget);
+ var moduleId = $btn.data('moduleId');
+ if (this.modulesToInstallStatus) {
+ this.set('state', '_import');
+ if (this.modulesToInstallStatus.installing) {
+ this.$('#o_wslides_install_module_text')
+ .text(_.str.sprintf(_t('Already installing "%s".'), this.modulesToInstallStatus.name));
+ } else if (this.modulesToInstallStatus.failed) {
+ this.$('#o_wslides_install_module_text')
+ .text(_.str.sprintf(_t('Failed to install "%s".'), this.modulesToInstallStatus.name));
+ }
+ } else {
+ this.modulesToInstallStatus = _.extend({}, _.find(this.modulesToInstall, function (item) { return item.id === moduleId; }));
+ this.set('state', '_import');
+ this.$('#o_wslides_install_module_text')
+ .text(_.str.sprintf(_t('Do you want to install the "%s" app?'), this.modulesToInstallStatus.name));
+ }
+ },
+
+ _onClickInstallModuleConfirm: function () {
+ var self = this;
+ var $el = this.$('#o_wslides_install_module_text');
+ $el.text(_.str.sprintf(_t('Installing "%s".'), this.modulesToInstallStatus.name));
+ this.modulesToInstallStatus.installing = true;
+ this._resetModalButton();
+ this._rpc({
+ model: 'ir.module.module',
+ method: 'button_immediate_install',
+ args: [[this.modulesToInstallStatus.id]],
+ }).then(function () {
+ window.location.href = window.location.origin + window.location.pathname + '?enable_slide_upload';
+ }, function () {
+ $el.text(_.str.sprintf(_t('Failed to install "%s".'), self.modulesToInstallStatus.name));
+ self.modulesToInstallStatus.installing = false;
+ self.modulesToInstallStatus.failed = true;
+ self._resetModalButton();
+ });
+ },
+
+ _onClickGoBack: function () {
+ this.set('state', '_select');
+ this.isValidUrl = true;
+ if (this.modulesToInstallStatus && !this.modulesToInstallStatus.installing) {
+ this.modulesToInstallStatus = null;
+ }
+ },
+
+ _onClickFormSubmit: function (ev) {
+ var self = this;
+ var $btn = $(ev.currentTarget);
+ if (this._formValidate()) {
+ var values = this._formValidateGetValues($btn.hasClass('o_w_slide_upload_published')); // get info before changing state
+ var oldType = this.get('state');
+ this.set('state', '_upload');
+ return this._rpc({
+ route: '/slides/add_slide',
+ params: values,
+ }).then(function (data) {
+ self._onFormSubmitDone(data, oldType);
+ });
+ }
+ },
+
+ _onFormSubmitDone: function (data, oldType) {
+ if (data.error) {
+ this.set('state', oldType);
+ this._alertDisplay(data.error);
+ } else {
+ window.location = data.url;
+ }
+ },
+
+ _onClickSlideTypeIcon: function (ev) {
+ var $elem = this.$(ev.currentTarget);
+ var slideType = $elem.data('slideType');
+ this.set('state', slideType);
+
+ this._bindSelect2Dropdown(); // rebind select2 at each modal body rendering
+ },
+});
+
+publicWidget.registry.websiteSlidesUpload = publicWidget.Widget.extend({
+ selector: '.o_wslides_js_slide_upload',
+ xmlDependencies: ['/website_slides/static/src/xml/website_slides_upload.xml'],
+ events: {
+ 'click': '_onUploadClick',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ // Automatically open the upload dialog if requested from query string
+ if (this.$el.attr('data-open-modal')) {
+ this.$el.removeAttr('data-open-modal');
+ this._openDialog(this.$el);
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _openDialog: function ($element) {
+ var data = $element.data();
+ return new SlideUploadDialog(this, data).open();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onUploadClick: function (ev) {
+ ev.preventDefault();
+ this._openDialog($(ev.currentTarget));
+ },
+});
+
+return {
+ SlideUploadDialog: SlideUploadDialog,
+ websiteSlidesUpload: publicWidget.registry.websiteSlidesUpload
+};
+
+});
diff --git a/addons/website_slides/static/src/js/tours/slides_tour.js b/addons/website_slides/static/src/js/tours/slides_tour.js
new file mode 100644
index 00000000..249a92f3
--- /dev/null
+++ b/addons/website_slides/static/src/js/tours/slides_tour.js
@@ -0,0 +1,117 @@
+odoo.define('website_slides.slides_tour', function (require) {
+"use strict";
+
+var core = require('web.core');
+var _t = core._t;
+
+var tour = require('web_tour.tour');
+
+tour.register('slides_tour', {
+ url: '/slides',
+}, [{
+ trigger: '#new-content-menu > a',
+ content: _t("Welcome on your course's home page. It's still empty for now. Click on \"<b>New</b>\" to write your first course."),
+ position: 'bottom',
+}, {
+ trigger: 'a[data-action="new_slide_channel"]',
+ content: _t("Select <b>Course</b> to create it and manage it."),
+ position: 'bottom',
+ width: 210,
+}, {
+ trigger: 'input[name="name"]',
+ content: _t("Give your course an engaging <b>Title</b>."),
+ position: 'bottom',
+ width: 280,
+ run: 'text My New Course',
+}, {
+ trigger: 'textarea[name="description"]',
+ content: _t("Give your course a helpful <b>Description</b>."),
+ position: 'bottom',
+ width: 300,
+ run: 'text This course is for advanced users.',
+}, {
+ trigger: 'button.btn-primary',
+ content: _t("Click on the <b>Create</b> button to create your first course."),
+}, {
+ trigger: '.o_wslides_js_slide_section_add',
+ content: _t("Congratulations, your course has been created, but there isn't any content yet. First, let's add a <b>Section</b> to give your course a structure."),
+ position: 'bottom',
+}, {
+ trigger: 'input[name="name"]',
+ content: _t("A good course has structure and a table of content. Your first section will be the <b>Introduction</b>."),
+ position: 'bottom',
+}, {
+ trigger: 'button.btn-primary',
+ content: _t("Click on <b>Save</b> to apply changes."),
+ position: 'bottom',
+ width: 260,
+}, {
+ trigger: 'a.btn-primary.o_wslides_js_slide_upload',
+ content: _t("Your first section is created, now it's time to add lessons to your course. Click on <b>Add Content</b> to upload a document, create a web page or link a video."),
+ position: 'bottom',
+}, {
+ trigger: 'a[data-slide-type="presentation"]',
+ content: _t("First, let's add a <b>Presentation</b>. It can be a .pdf or an image."),
+ position: 'bottom',
+}, {
+ trigger: 'input#upload',
+ content: _t("Choose a <b>File</b> on your computer."),
+}, {
+ trigger: 'input#name',
+ content: _t("The <b>Title</b> of your lesson is autocompleted but you can change it if you want.</br>A <b>Preview</b> of your file is available on the right side of the screen."),
+}, {
+ trigger: 'input#duration',
+ content: _t("The <b>Duration</b> of the lesson is based on the number of pages of your document. You can change this number if your attendees will need more time to assimilate the content."),
+}, {
+ trigger: 'button.o_w_slide_upload_published',
+ content: _t("<b>Save & Publish</b> your lesson to make it available to your attendees."),
+ position: 'bottom',
+ width: 285,
+}, {
+ trigger: 'span.badge-info:contains("New")',
+ content: _t("Congratulations! Your first lesson is available. Let's see the options available here. The tag \"<b>New</b>\" indicates that this lesson was created less than 7 days ago."),
+ position: 'bottom',
+}, {
+ trigger: 'a[name="o_wslides_list_slide_add_quizz"]',
+ extra_trigger: '.o_wslides_slides_list_slide:hover',
+ content: _t("If you want to be sure that attendees have understood and memorized the content, you can add a Quiz on the lesson. Click on <b>Add Quiz</b>."),
+}, {
+ trigger: 'input[name="question-name"]',
+ content: _t("Enter your <b>Question</b>. Be clear and concise."),
+ position: 'left',
+ width: 330,
+}, {
+ trigger: 'input.o_wslides_js_quiz_answer_value',
+ content: _t("Enter at least two possible <b>Answers</b>."),
+ position: 'left',
+ width: 290,
+}, {
+ trigger: 'a.o_wslides_js_quiz_is_correct',
+ content: _t("Mark the correct answer by checking the <b>correct</b> mark."),
+ position: 'right',
+ width: 230,
+}, {
+ trigger: 'i.o_wslides_js_quiz_comment_answer:last',
+ content: _t("You can add <b>comments</b> on answers. This will be visible with the results if the user select this answer."),
+ position: 'right',
+
+}, {
+ trigger: 'a.o_wslides_js_quiz_validate_question',
+ content: _t("<b>Save</b> your question."),
+ position: 'left',
+ width: 170,
+}, {
+ trigger: 'li.breadcrumb-item:nth-child(2)',
+ content: _t("Click on your <b>Course</b> to go back to the table of content."),
+ position: 'top',
+}, {
+ trigger: 'label.js_publish_btn',
+ content: _t("Once you're done, don't forget to <b>Publish</b> your course."),
+ position: 'bottom',
+}, {
+ trigger: 'a.o_wslides_js_slides_list_slide_link',
+ content: _t("Congratulations, you've created your first course.<br/>Click on the title of this content to see it in fullscreen mode."),
+ position: 'bottom',
+}]);
+
+});
diff --git a/addons/website_slides/static/src/js/website_slides.editor.js b/addons/website_slides/static/src/js/website_slides.editor.js
new file mode 100644
index 00000000..8ee780a2
--- /dev/null
+++ b/addons/website_slides/static/src/js/website_slides.editor.js
@@ -0,0 +1,188 @@
+odoo.define('website_slides.editor', function (require) {
+"use strict";
+
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var QWeb = core.qweb;
+var WebsiteNewMenu = require('website.newMenu');
+var TagCourseDialog = require('website_slides.channel_tag.add').TagCourseDialog;
+var wUtils = require('website.utils');
+
+var _t = core._t;
+
+
+var ChannelCreateDialog = Dialog.extend({
+ template: 'website.slide.channel.create',
+ xmlDependencies: Dialog.prototype.xmlDependencies.concat(
+ ['/website_slides/static/src/xml/website_slides_channel.xml',
+ '/website_slides/static/src/xml/website_slides_channel_tag.xml']
+ ),
+ events: _.extend({}, Dialog.prototype.events, {
+ 'change input#tag_ids' : '_onChangeTag',
+ }),
+ custom_events: _.extend({}, Dialog.prototype.custom_events, {
+ 'tag_refresh': '_onTagRefresh',
+ 'tag_remove_new': '_onTagRemoveNew',
+ }),
+ /**
+ * @override
+ * @param {Object} parent
+ * @param {Object} options
+ */
+ init: function (parent, options) {
+ options = _.defaults(options || {}, {
+ title: _t("New Course"),
+ size: 'medium',
+ buttons: [{
+ text: _t("Create"),
+ classes: 'btn-primary',
+ click: this._onClickFormSubmit.bind(this)
+ }, {
+ text: _t("Discard"),
+ close: true
+ },]
+ });
+ this._super(parent, options);
+ },
+ start: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ var $input = self.$('#tag_ids');
+ $input.select2({
+ width: '100%',
+ allowClear: true,
+ formatNoMatches: false,
+ multiple: true,
+ selection_data: false,
+ formatSelection: function (data) {
+ if (data.tag) {
+ data.text = data.tag;
+ }
+ return data.text;
+ },
+ createSearchChoice: function(term, data) {
+ var addedTags = $(this.opts.element).select2('data');
+ if (_.filter(_.union(addedTags, data), function (tag) {
+ return tag.text.toLowerCase().localeCompare(term.toLowerCase()) === 0;
+ }).length === 0) {
+ if (this.opts.can_create) {
+ return {
+ id: _.uniqueId('tag_'),
+ create: true,
+ tag: term,
+ text: _.str.sprintf(_t("Create new Tag '%s'"), term),
+ };
+ } else {
+ return undefined;
+ }
+ }
+ },
+ fill_data: function (query, data) {
+ var that = this,
+ tags = {results: []};
+ _.each(data, function (obj) {
+ if (that.matcher(query.term, obj.name)) {
+ tags.results.push({id: obj.id, text: obj.name});
+ }
+ });
+ query.callback(tags);
+ },
+ query: function (query) {
+ var that = this;
+ // fetch data only once and store it
+ if (!this.selection_data) {
+ self._rpc({
+ route: '/slides/channel/tag/search_read',
+ params: {
+ fields: ['name'],
+ domain: [],
+ }
+ }).then(function (data) {
+ that.can_create = data.can_create;
+ that.fill_data(query, data.read_results);
+ that.selection_data = data.read_results;
+ });
+ } else {
+ this.fill_data(query, this.selection_data);
+ }
+ }
+ });
+ });
+ },
+ _onClickFormSubmit: function (ev) {
+ var $form = this.$("#slide_channel_add_form");
+ var $title = this.$("#title");
+ if (!$title[0].value){
+ $title.addClass('border-danger');
+ this.$("#title-required").removeClass('d-none');
+ } else {
+ $form.submit();
+ }
+ },
+ _onChangeTag: function (ev) {
+ var self = this;
+ var tags = $(ev.currentTarget).select2('data');
+ tags.forEach(function (element) {
+ if (element.create) {
+ new TagCourseDialog(self, { defaultTag: element.text }).open();
+ }
+ });
+ },
+ /**
+ * Replace the new tag ID by its real ID
+ * @param ev
+ * @private
+ */
+ _onTagRefresh: function (ev) {
+ var $tag_ids = $('#tag_ids');
+ var tags = $tag_ids.select2('data');
+ tags.forEach(function (element) {
+ if (element.create) {
+ element.id = ev.data.tag_id;
+ element.create = false;
+ }
+ });
+ $tag_ids.select2('data', tags);
+ // Set selection_data to false to force tag reload
+ $tag_ids.data('select2').opts.selection_data = false;
+ },
+ /**
+ * Remove the created tag if the user clicks on 'Discard' on the create tag Dialog
+ * @private
+ */
+ _onTagRemoveNew: function () {
+ var tags = $('#tag_ids').select2('data');
+ tags = tags.filter(function (value) {
+ return !value.create;
+ });
+ $('#tag_ids').select2('data', tags);
+ },
+});
+
+WebsiteNewMenu.include({
+ actions: _.extend({}, WebsiteNewMenu.prototype.actions || {}, {
+ new_slide_channel: '_createNewSlideChannel',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Actions
+ //--------------------------------------------------------------------------
+
+ /**
+ * Displays the popup to create a new slide channel,
+ * and redirects the user to this channel.
+ *
+ * @private
+ * @returns {Promise} Unresolved if there is a redirection
+ */
+ _createNewSlideChannel: function () {
+ var self = this;
+ var def = new Promise(function (resolve) {
+ var dialog = new ChannelCreateDialog(self, {});
+ dialog.open();
+ dialog.on('closed', self, resolve);
+ });
+ return def;
+ },
+});
+});