diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/website_slides/static/src/js | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/website_slides/static/src/js')
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; + }, +}); +}); |
