From ec9e389f2feaf79c48d68f6f9d38d75487092361 Mon Sep 17 00:00:00 2001 From: Hoste Patrick Date: Mon, 19 Aug 2019 11:31:24 +0000 Subject: [PATCH] [IMP] website_slides: allow to create and update quizzes directly in frontend Purpose is to ease designing your courses by allowing to create and update slide quiz directly in frontend, within the slide display. With this commit you are able to * add a quiz to a slide or add a question to an existing quiz; * edit an existing question from a quiz: * change the title of the Question; * add, remove or update answers; * change the right answer; * delete an existing question; * reorder the questions from a quiz; * reset the quiz for the current user if he is a website designer, allowing to test the quiz and its answers; New frontend widgets are introduced allowing to manipulate quiz. Slide page now supports a readonly / taking quiz mode and an edit mode for quizzes used notably for website designers / course maintainers. Task ID : 1999636 PR : #35326 Signed-off-by: Thibault Delavallee (tde) --- .../static/src/js/slides_course_quiz.js | 2 +- addons/website_slides/controllers/main.py | 69 +++- .../website_slides/data/slide_slide_demo.xml | 24 ++ .../website_slides/models/slide_question.py | 8 +- .../src/js/slides_course_fullscreen_player.js | 2 +- .../static/src/js/slides_course_quiz.js | 335 +++++++++++++++++- .../js/slides_course_quiz_question_form.js | 190 ++++++++++ .../static/src/scss/website_slides.scss | 49 +++ .../static/src/xml/slide_quiz.xml | 19 +- .../static/src/xml/slide_quiz_create.xml | 77 ++++ .../website_slides/tests/test_ui_wslides.py | 5 + addons/website_slides/views/assets.xml | 1 + .../views/website_slides_templates_course.xml | 3 + .../views/website_slides_templates_lesson.xml | 102 ++++-- .../views/website_slides_templates_course.xml | 3 + 15 files changed, 822 insertions(+), 67 deletions(-) create mode 100644 addons/website_slides/static/src/js/slides_course_quiz_question_form.js create mode 100644 addons/website_slides/static/src/xml/slide_quiz_create.xml diff --git a/addons/website_sale_slides/static/src/js/slides_course_quiz.js b/addons/website_sale_slides/static/src/js/slides_course_quiz.js index 9dc0154596a..1e601acc7a7 100644 --- a/addons/website_sale_slides/static/src/js/slides_course_quiz.js +++ b/addons/website_sale_slides/static/src/js/slides_course_quiz.js @@ -2,7 +2,7 @@ odoo.define('website_sale_slides.quiz', function (require) { "use strict"; var sAnimations = require('website.content.snippets.animation'); -var Quiz = require('website_slides.quiz'); +var Quiz = require('website_slides.quiz').Quiz; sAnimations.registry.websiteSlidesQuizNoFullscreen.include({ _extractChannelData: function (slideData){ diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index d501ff21e2a..a4bb141c3a6 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -127,10 +127,10 @@ class WebsiteSlides(WebsiteProfile): 'slide_questions': [{ 'id': question.id, 'question': question.question, - 'answers': [{ + 'answer_ids': [{ 'id': answer.id, 'text_value': answer.text_value, - 'is_correct': answer.is_correct if slide_completed else None + 'is_correct': answer.is_correct if slide_completed or request.website.is_publisher() else None } for answer in question.sudo().answer_ids], } for question in slide.question_ids] } @@ -700,6 +700,57 @@ class WebsiteSlides(WebsiteProfile): # QUIZZ SECTION # -------------------------------------------------- + @http.route('/slides/slide/quiz/question_add_or_update', type='json', methods=['POST'], auth='user', website=True) + def slide_quiz_question_add_or_update(self, slide_id, question, sequence, answer_ids, existing_question_id=None): + """ Add a new question to an existing slide. Completed field of slide.partner + link is set to False to make sure that the creator can take the quiz again. + + An optional question_id to udpate can be given. In this case question is + deleted first before creating a new one to simplify management. + + :param integer slide_id: Slide ID + :param string question: Question Title + :param integer sequence: Question Sequence + :param array answer_ids: Array containing all the answers : + [ + 'sequence': Answer Sequence (Integer), + 'text_value': Answer Title (String), + 'is_correct': Answer Is Correct (Boolean) + ] + :param integer existing_question_id: question ID if this is an update + + :return: rendered question template + """ + fetch_res = self._fetch_slide(slide_id) + if fetch_res.get('error'): + return fetch_res + slide = fetch_res['slide'] + if existing_question_id: + request.env['slide.question'].search([ + ('slide_id', '=', slide.id), + ('id', '=', int(existing_question_id)) + ]).unlink() + + request.env['slide.slide.partner'].search([ + ('slide_id', '=', slide_id), + ('partner_id', '=', request.env.user.partner_id.id) + ]).write({'completed': False}) + + slide_question = request.env['slide.question'].create({ + 'sequence': sequence, + 'question': question, + 'slide_id': slide_id, + 'answer_ids': [(0, 0, { + 'sequence': answer['sequence'], + 'text_value': answer['text_value'], + 'is_correct': answer['is_correct'] + }) for answer in answer_ids] + }) + return request.env.ref('website_slides.lesson_content_quiz_question').render({ + 'slide': slide, + 'question': slide_question, + }) + @http.route('/slides/slide/quiz/get', type="json", auth="public", website=True) def slide_quiz_get(self, slide_id): fetch_res = self._fetch_slide(slide_id) @@ -708,6 +759,16 @@ class WebsiteSlides(WebsiteProfile): slide = fetch_res['slide'] return self._get_slide_quiz_data(slide) + @http.route('/slides/slide/quiz/reset', type="json", auth="user", website=True) + def slide_quiz_reset(self, slide_id): + fetch_res = self._fetch_slide(slide_id) + if fetch_res.get('error'): + return fetch_res + request.env['slide.slide.partner'].search([ + ('slide_id', '=', fetch_res['slide'].id), + ('partner_id', '=', request.env.user.partner_id.id) + ]).write({'completed': False, 'quiz_attempts_count': 0}) + @http.route('/slides/slide/quiz/submit', type="json", auth="public", website=True) def slide_quiz_submit(self, slide_id, answer_ids): if request.website.is_public_user(): @@ -768,7 +829,6 @@ class WebsiteSlides(WebsiteProfile): 'motivational': next_rank.description_motivational, 'progress': progress } - # -------------------------------------------------- # CATEGORY MANAGEMENT # -------------------------------------------------- @@ -878,9 +938,6 @@ class WebsiteSlides(WebsiteProfile): redirect_url = "/slides/%s" % (slug(channel)) if slide.slide_type == 'webpage': redirect_url += "?enable_editor=1" - if slide.slide_type == "quiz": - action_id = request.env.ref('website_slides.slide_slide_action').id - redirect_url = '/web#id=%s&action=%s&model=slide.slide&view_type=form' %( slide.id, action_id) return { 'url': redirect_url, 'channel_type': channel.channel_type, diff --git a/addons/website_slides/data/slide_slide_demo.xml b/addons/website_slides/data/slide_slide_demo.xml index 6e00c21e9aa..3d6ae6aeec0 100644 --- a/addons/website_slides/data/slide_slide_demo.xml +++ b/addons/website_slides/data/slide_slide_demo.xml @@ -76,34 +76,41 @@ What is a strawberry ? + 1 A fruit + 1 A vegetable + 2 A table + 3 What is the best tool to dig a hole for your plants ? + 2 A shovel + 1 A spoon + 2 @@ -256,34 +263,41 @@ Do you think Harry Potted has a good name ? + 1 Yes + 1 No + 2 Did you read the whole article ? + 2 Yes + 1 No + 2 What was the question again ? + 3 @@ -371,34 +385,41 @@ Do you make beams out of lemon trees ? + 1 Yes + 1 No + 2 Do you make lemons out of beams ? + 2 Yes + 1 No + 2 And also bananas + 3 @@ -638,15 +659,18 @@ Do you want to reply correctly ? + 1 Yes + 1 No + 2 diff --git a/addons/website_slides/models/slide_question.py b/addons/website_slides/models/slide_question.py index 8258b26e71a..c612f90b371 100644 --- a/addons/website_slides/models/slide_question.py +++ b/addons/website_slides/models/slide_question.py @@ -9,8 +9,9 @@ class SlideQuestion(models.Model): _name = "slide.question" _rec_name = "question" _description = "Content Quiz Question" + _order = "sequence" - sequence = fields.Integer("Sequence", default=10) + sequence = fields.Integer("Sequence") question = fields.Char("Question Name", required=True, translate=True) slide_id = fields.Many2one('slide.slide', string="Content", required=True) answer_ids = fields.One2many('slide.answer', 'question_id', string="Answer") @@ -49,8 +50,9 @@ class SlideAnswer(models.Model): _name = "slide.answer" _rec_name = "text_value" _description = "Answer for a slide question" - _order = 'question_id, id' + _order = 'question_id, sequence' - question_id = fields.Many2one('slide.question', string="Question", required=True) + sequence = fields.Integer("Sequence") + question_id = fields.Many2one('slide.question', string="Question", required=True, ondelete='cascade') text_value = fields.Char("Answer", required=True, translate=True) is_correct = fields.Boolean("Is correct answer") 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 index e9dd0c8e2c3..ca8463a8f07 100644 --- a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js +++ b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js @@ -11,7 +11,7 @@ odoo.define('website_slides.fullscreen', function (require) { var session = require('web.session'); - var Quiz = require('website_slides.quiz'); + var Quiz = require('website_slides.quiz').Quiz; var Dialog = require('web.Dialog'); diff --git a/addons/website_slides/static/src/js/slides_course_quiz.js b/addons/website_slides/static/src/js/slides_course_quiz.js index cf53fe1b236..d22c386f3cc 100644 --- a/addons/website_slides/static/src/js/slides_course_quiz.js +++ b/addons/website_slides/static/src/js/slides_course_quiz.js @@ -2,10 +2,12 @@ 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 QWeb = core.qweb; var _t = core._t; @@ -28,7 +30,18 @@ odoo.define('website_slides.quiz', function (require) { "click .o_wslides_quiz_answer": '_onAnswerClick', "click .o_wslides_js_lesson_quiz_submit": '_onSubmitQuiz', "click .o_wslides_quiz_modal_btn": '_onClickNext', - "click .o_wslides_quiz_continue": '_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', + }, + + custom_events: { + display_created_question: '_displayCreatedQuestion', + display_updated_question: '_displayUpdatedQuestion', + reset_display: '_resetDisplay', + delete_question: '_deleteQuestion', }, /** @@ -46,6 +59,9 @@ odoo.define('website_slides.quiz', function (require) { readonly: false, }); this.quiz = quiz_data || false; + if (this.quiz) { + this.quiz.questionsCount = quiz_data.questions.length; + } this.readonly = slide_data.readonly || false; this.publicUser = session.is_website_user; this.userId = session.user_id; @@ -74,6 +90,8 @@ odoo.define('website_slides.quiz', function (require) { self._renderAnswers(); self._renderAnswersHighlighting(); self._renderValidationInfo(); + self._bindSortable(); + self._checkLocationHref(); new CourseJoinWidget(self, self.channel.channelId).appendTo(self.$('.o_wslides_course_join_widget')); }); }, @@ -82,10 +100,6 @@ odoo.define('website_slides.quiz', function (require) { // Private //-------------------------------------------------------------------------- - _alertHide: function () { - this.$('.o_wslides_js_lesson_quiz_error').addClass('d-none'); - }, - _alertShow: function (alert_code) { var message = _t('There was an error validating this quiz.'); if (! alert_code || alert_code === 'slide_quiz_incomplete') { @@ -97,8 +111,63 @@ odoo.define('website_slides.quiz', function (require) { else if (alert_code === 'public_user') { message = _t('You must be logged to submit the quiz.'); } - this.$('.o_wslides_js_lesson_quiz_error span:first').html(message); - this.$('.o_wslides_js_lesson_quiz_error').removeClass('d-none'); + this.displayNotification({ + type: 'warning', + title: _t('Something went wrong'), + 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)) }, /* @@ -115,6 +184,7 @@ odoo.define('website_slides.quiz', function (require) { }).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, @@ -122,6 +192,16 @@ odoo.define('website_slides.quiz', function (require) { }); }, + /** + * 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 @@ -308,12 +388,48 @@ odoo.define('website_slides.quiz', function (require) { self.slide.completed = true; self.trigger_up('slide_completed', {slide: self.slide, completion: data.channel_completion}); } + self._hideEditOptions(); self._renderAnswersHighlighting(); 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') + }); + }); + 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 //-------------------------------------------------------------------------- @@ -329,7 +445,6 @@ odoo.define('website_slides.quiz', function (require) { if (! this.slide.readonly && ! this.slide.completed) { $(ev.currentTarget).find('input[type=radio]').prop('checked', true); } - this._alertHide(); }, /** * Triggering a event to switch to next slide @@ -345,6 +460,22 @@ odoo.define('website_slides.quiz', function (require) { 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(); + }); + }, /** * Submit a quiz and get the correction. It will display messages * according to quiz result. @@ -359,13 +490,189 @@ odoo.define('website_slides.quiz', function (require) { values.push(parseInt($(inputs[i]).val())); } - if (values.length === this.quiz.questions.length){ - this._alertHide(); + if (values.length === this.quiz.questionsCount){ this._submitQuiz(values); } else { this._alertShow(); } }, + + /** + * 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(); + }, + + /** + * 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(); + if (event.data.save_and_new) { + this._onCreateQuizClick(); + } else { + 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({ @@ -453,12 +760,16 @@ odoo.define('website_slides.quiz', function (require) { questions.push({ id: $question.data('questionId'), title: $question.data('title'), - answers: answers, + answer_ids: answers, }); }); return questions; }, }); - return Quiz; + return { + Quiz: Quiz, + ConfirmationDialog: ConfirmationDialog, + websiteSlidesQuizNoFullscreen: publicWidget.registry.websiteSlidesQuizNoFullscreen + }; }); 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 00000000000..04f8532eca9 --- /dev/null +++ b/addons/website_slides/static/src/js/slides_course_quiz_question_form.js @@ -0,0 +1,190 @@ +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_add_answer': '_addAnswerLine', + 'click .o_wslides_js_quiz_remove_answer': '_removeAnswerLine', + }, + + /** + * @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 + //-------------------------------------------------------------------------- + + /** + * 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(); + } + }, + + /** + * Handler when user click on 'Save & New', 'Save & Close' + * or 'Update' buttons. + * @param ev + * @private + */ + _validateQuestion: function (ev) { + this._createOrUpdateQuestion({ + save_and_new: $(ev.currentTarget).hasClass('o_wslides_js_quiz_create_next'), + 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, + save_and_new: options.save_and_new, + questionFormWidget: self + }); + } + }); + } else { + this.displayNotification({ + type: 'warning', + title: _t('Unable to create question'), + message: _t('Please fill up 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('input[type=text]').val(); + if (value.trim() !== "") { + var answer = { + 'sequence': sequence++, + 'text_value': value, + 'is_correct': $(this).find('input[type=radio]').prop('checked') === true + }; + 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/scss/website_slides.scss b/addons/website_slides/static/src/scss/website_slides.scss index cd02d6eaedd..52628a00342 100644 --- a/addons/website_slides/static/src/scss/website_slides.scss +++ b/addons/website_slides/static/src/scss/website_slides.scss @@ -83,6 +83,14 @@ $o-wslides-fs-side-width: 300px; } } + .o_wsildes_quiz_question_input { + .o_wslides_js_quiz_is_correct > label, + .o_wslides_js_quiz_add_answer, + .o_wslides_js_quiz_remove_answer { + cursor: pointer; + } + } + .o_wslides_js_lesson_quiz_question { .list-group-item { font-size: 1rem; @@ -97,11 +105,52 @@ $o-wslides-fs-side-width: 300px; pointer-events: none; } + .o_wslides_js_quiz_edit_question, + .o_wslides_js_quiz_delete_question { + cursor: pointer; + } + + .o_wslides_js_quiz_sequence_handler { + cursor: pointer; + opacity: 0.4; + } + + .o_wslides_js_quiz_sequence_handler:hover { + opacity: 1; + } + &.completed-disabled{ pointer-events: none; } } + a.o_wslides_js_quiz_is_correct { + color: black; + input:checked + i.fa-check-circle-o { + color: $primary; + } + } + + .o_wslides_js_quiz_sequence_highlight { + background-color: #1252F3; + height: 1px; + z-index: 3; + + &:before, &:after { + content: ""; + @include size(6px); + display: block; + border-radius: 100%; + background-color: inherit; + @include o-position-absolute(-2px, -2px); + } + + &:after { + right: auto; + left: -2px; + } + } + // tools // **************************************** .text_small_caps { diff --git a/addons/website_slides/static/src/xml/slide_quiz.xml b/addons/website_slides/static/src/xml/slide_quiz.xml index abf284ac61b..a57a10c5adb 100644 --- a/addons/website_slides/static/src/xml/slide_quiz.xml +++ b/addons/website_slides/static/src/xml/slide_quiz.xml @@ -11,7 +11,7 @@ . -
@@ -77,9 +71,14 @@
- +
+ + +
diff --git a/addons/website_slides/static/src/xml/slide_quiz_create.xml b/addons/website_slides/static/src/xml/slide_quiz_create.xml new file mode 100644 index 00000000000..0d19371e0b3 --- /dev/null +++ b/addons/website_slides/static/src/xml/slide_quiz_create.xml @@ -0,0 +1,77 @@ + + + + +
+
+
+ + . + + +
+
+ Select the correct answer below : +
+
+ + + + + + + + + + +
+
+ + +
+
+ + +
+ + + + + + + + + Update + + + Cancel + + + + +
Are you sure you want to delete this question : ?
+
+ + diff --git a/addons/website_slides/tests/test_ui_wslides.py b/addons/website_slides/tests/test_ui_wslides.py index 4e60263553d..3a96a559677 100644 --- a/addons/website_slides/tests/test_ui_wslides.py +++ b/addons/website_slides/tests/test_ui_wslides.py @@ -67,10 +67,13 @@ class TestUICommon(HttpCaseWithUserDemo, HttpCaseWithUserPortal): (0, 0, { 'text_value': 'A fruit', 'is_correct': True, + 'sequence': 1, }), (0, 0, { 'text_value': 'A vegetable', + 'sequence': 2, }), (0, 0, { 'text_value': 'A table', + 'sequence': 3, }) ] }), (0, 0, { @@ -79,8 +82,10 @@ class TestUICommon(HttpCaseWithUserDemo, HttpCaseWithUserPortal): (0, 0, { 'text_value': 'A shovel', 'is_correct': True, + 'sequence': 1, }), (0, 0, { 'text_value': 'A spoon', + 'sequence': 2, }) ] }) diff --git a/addons/website_slides/views/assets.xml b/addons/website_slides/views/assets.xml index 2d214bfa17c..d4d037d8e80 100644 --- a/addons/website_slides/views/assets.xml +++ b/addons/website_slides/views/assets.xml @@ -28,6 +28,7 @@