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/slides_course_quiz.js | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/website_slides/static/src/js/slides_course_quiz.js')
| -rw-r--r-- | addons/website_slides/static/src/js/slides_course_quiz.js | 775 |
1 files changed, 775 insertions, 0 deletions
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 + }; +}); |
