[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) <tde@openerp.com>
This commit is contained in:
Hoste Patrick
2019-11-29 13:36:25 +00:00
committed by Thibault Delavallée
parent a4abdfa316
commit ec9e389f2f
15 changed files with 822 additions and 67 deletions
@@ -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){
+63 -6
View File
@@ -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,
@@ -76,34 +76,41 @@
</record>
<record id="slide_slide_demo_0_4_question_0" model="slide.question">
<field name="question">What is a strawberry ?</field>
<field name="sequence">1</field>
<field name="slide_id" ref="slide_slide_demo_0_4"/>
</record>
<record id="slide_slide_demo_0_4_question_0_0" model="slide.answer">
<field name="text_value">A fruit</field>
<field name="sequence">1</field>
<field name="is_correct" eval="True"/>
<field name="question_id" ref="slide_slide_demo_0_4_question_0"/>
</record>
<record id="slide_slide_demo_0_4_question_0_1" model="slide.answer">
<field name="text_value">A vegetable</field>
<field name="sequence">2</field>
<field name="is_correct" eval="False"/>
<field name="question_id" ref="slide_slide_demo_0_4_question_0"/>
</record>
<record id="slide_slide_demo_0_4_question_0_2" model="slide.answer">
<field name="text_value">A table</field>
<field name="sequence">3</field>
<field name="is_correct" eval="False"/>
<field name="question_id" ref="slide_slide_demo_0_4_question_0"/>
</record>
<record id="slide_slide_demo_0_4_question_1" model="slide.question">
<field name="question">What is the best tool to dig a hole for your plants ?</field>
<field name="sequence">2</field>
<field name="slide_id" ref="slide_slide_demo_0_4"/>
</record>
<record id="slide_slide_demo_0_4_question_1_0" model="slide.answer">
<field name="text_value">A shovel</field>
<field name="sequence">1</field>
<field name="is_correct" eval="True"/>
<field name="question_id" ref="slide_slide_demo_0_4_question_1"/>
</record>
<record id="slide_slide_demo_0_4_question_1_1" model="slide.answer">
<field name="text_value">A spoon</field>
<field name="sequence">2</field>
<field name="is_correct" eval="False"/>
<field name="question_id" ref="slide_slide_demo_0_4_question_1"/>
</record>
@@ -256,34 +263,41 @@
</record>
<record id="slide_slide_demo_1_4_question_0" model="slide.question">
<field name="question">Do you think Harry Potted has a good name ?</field>
<field name="sequence">1</field>
<field name="slide_id" ref="slide_slide_demo_1_4"/>
</record>
<record id="slide_slide_demo_1_4_question_0_0" model="slide.answer">
<field name="text_value">Yes</field>
<field name="sequence">1</field>
<field name="is_correct" eval="True"/>
<field name="question_id" ref="slide_slide_demo_1_4_question_0"/>
</record>
<record id="slide_slide_demo_1_4_question_0_1" model="slide.answer">
<field name="text_value">No</field>
<field name="sequence">2</field>
<field name="is_correct" eval="False"/>
<field name="question_id" ref="slide_slide_demo_1_4_question_0"/>
</record>
<record id="slide_slide_demo_1_4_question_1" model="slide.question">
<field name="question">Did you read the whole article ?</field>
<field name="sequence">2</field>
<field name="slide_id" ref="slide_slide_demo_1_4"/>
</record>
<record id="slide_slide_demo_1_4_question_1_0" model="slide.answer">
<field name="text_value">Yes</field>
<field name="sequence">1</field>
<field name="is_correct" eval="True"/>
<field name="question_id" ref="slide_slide_demo_1_4_question_1"/>
</record>
<record id="slide_slide_demo_1_4_question_1_1" model="slide.answer">
<field name="text_value">No</field>
<field name="sequence">2</field>
<field name="is_correct" eval="False"/>
<field name="question_id" ref="slide_slide_demo_1_4_question_1"/>
</record>
<record id="slide_slide_demo_1_4_question_1_2" model="slide.answer">
<field name="text_value">What was the question again ?</field>
<field name="sequence">3</field>
<field name="is_correct" eval="False"/>
<field name="question_id" ref="slide_slide_demo_1_4_question_1"/>
</record>
@@ -371,34 +385,41 @@
<!-- QUIZZ -->
<record id="slide_slide_demo_2_0_question_0" model="slide.question">
<field name="question">Do you make beams out of lemon trees ?</field>
<field name="sequence">1</field>
<field name="slide_id" ref="slide_slide_demo_2_0"/>
</record>
<record id="slide_slide_demo_2_0_question_0_0" model="slide.answer">
<field name="text_value">Yes</field>
<field name="sequence">1</field>
<field name="is_correct" eval="False"/>
<field name="question_id" ref="slide_slide_demo_2_0_question_0"/>
</record>
<record id="slide_slide_demo_2_0_question_0_1" model="slide.answer">
<field name="text_value">No</field>
<field name="sequence">2</field>
<field name="is_correct" eval="True"/>
<field name="question_id" ref="slide_slide_demo_2_0_question_0"/>
</record>
<record id="slide_slide_demo_2_0_question_1" model="slide.question">
<field name="question">Do you make lemons out of beams ?</field>
<field name="sequence">2</field>
<field name="slide_id" ref="slide_slide_demo_2_0"/>
</record>
<record id="slide_slide_demo_2_0_question_1_0" model="slide.answer">
<field name="text_value">Yes</field>
<field name="sequence">1</field>
<field name="is_correct" eval="False"/>
<field name="question_id" ref="slide_slide_demo_2_0_question_1"/>
</record>
<record id="slide_slide_demo_2_0_question_1_1" model="slide.answer">
<field name="text_value">No</field>
<field name="sequence">2</field>
<field name="is_correct" eval="True"/>
<field name="question_id" ref="slide_slide_demo_2_0_question_1"/>
</record>
<record id="slide_slide_demo_2_0_question_1_2" model="slide.answer">
<field name="text_value">And also bananas</field>
<field name="sequence">3</field>
<field name="is_correct" eval="False"/>
<field name="question_id" ref="slide_slide_demo_2_0_question_1"/>
</record>
@@ -638,15 +659,18 @@
</record>
<record id="slide_slide_demo_5_3_question_0" model="slide.question">
<field name="question">Do you want to reply correctly ?</field>
<field name="sequence">1</field>
<field name="slide_id" ref="slide_slide_demo_5_3"/>
</record>
<record id="slide_slide_demo_5_3_question_0_0" model="slide.answer">
<field name="text_value">Yes</field>
<field name="sequence">1</field>
<field name="is_correct" eval="True"/>
<field name="question_id" ref="slide_slide_demo_5_3_question_0"/>
</record>
<record id="slide_slide_demo_5_3_question_0_1" model="slide.answer">
<field name="text_value">No</field>
<field name="sequence">2</field>
<field name="is_correct" eval="False"/>
<field name="question_id" ref="slide_slide_demo_5_3_question_0"/>
</record>
@@ -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")
@@ -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');
@@ -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
};
});
@@ -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;
});
@@ -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 {
@@ -11,7 +11,7 @@
<small class="text-muted"><span t-esc="question_index+1"/>. </small> <span t-esc="question.question"/>
</div>
<div class="list-group">
<t t-foreach="question.answers" t-as="answer">
<t t-foreach="question.answer_ids" t-as="answer">
<a t-att-data-answer-id="answer.id" href="#"
t-att-data-text="answer.text_value"
t-attf-class="o_wslides_quiz_answer list-group-item d-flex align-items-center #{widget.readonly ? 'disabled bg-transparent' : 'list-group-item-action'} #{widget.slide.completed &amp;&amp; answer.is_correct ? 'list-group-item-success' : '' }">
@@ -30,12 +30,6 @@
</t>
</div>
</div>
<div class="alert alert-danger o_wslides_js_lesson_quiz_error mt-2 d-none" role="alert">
<span></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span>&amp;times;</span>
</button>
</div>
<div t-if="!widget.slide.completed" class="o_wslides_js_lesson_quiz_validation border-top pt-3"/>
<div t-else="" class="row">
<div class="o_wslides_js_lesson_quiz_validation col py-2 bg-100 mb-2 border-bottom"/>
@@ -77,9 +71,14 @@
</span>
</span>
</div>
<button t-if="widget.slide.completed &amp;&amp; widget.slide.hasNext" class="btn btn-primary o_wslides_quiz_continue">
Continue <i class="fa fa-chevron-right ml-1"/>
</button>
<div class="ml-auto">
<button t-if="widget.quiz.quizAttemptsCount > 0 &amp;&amp; widget.slide.channelCanUpload" class="btn btn-light border o_wslides_js_lesson_quiz_reset">
Reset
</button>
<button t-if="widget.slide.completed &amp;&amp; widget.slide.hasNext" class="btn btn-primary o_wslides_quiz_continue">
Continue <i class="fa fa-chevron-right ml-1"/>
</button>
</div>
</div>
</div>
</t>
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="slide.quiz.question.input">
<div t-attf-class="o_wsildes_quiz_question_input mt-3 #{!widget.update ? 'col' : ''}" t-att-data-id="widget.question.id || ''">
<form class="mb-3">
<div class="o_wslides_quiz_question row align-items-center mr-0 mb-2">
<small class="text-muted col-1 text-center">
<span class="h4 o_wslides_quiz_question_sequence"><t t-esc="widget.sequence"/></span>.
</small>
<input type="text" name="question-name" class="form-control col-11" placeholder="Enter your question"
t-att-value="widget.question.text" />
</div>
<div class="text-muted mb-2">
<span>Select the correct answer below :</span>
</div>
<div class="list-group">
<t t-if="widget.question.answers" >
<t t-foreach="widget.question.answers" t-as="answer" >
<t t-call="slide.quiz.answer.line"/>
</t>
</t>
<t t-else="" >
<t t-foreach="[1, 2, 3]">
<t t-call="slide.quiz.answer.line" />
</t>
</t>
</div>
</form>
<t t-if="widget.update" t-call="slide.quiz.update.buttons"/>
<t t-else="" t-call="slide.quiz.create.buttons"/>
</div>
</t>
<t t-name="slide.quiz.answer.line">
<div class="o_wslides_js_quiz_answer row align-items-center mb-1" t-attf-data-answer-id="#{answer ? answer.id : ''}" >
<a class="o_wslides_js_quiz_is_correct col-1 text-center" title="Select one good answer">
<label class="my-0">
<input t-if="answer and answer.is_correct" class="d-none" type="radio" name="radio" checked="true" />
<input t-else="" class="d-none" type="radio" name="radio" />
<i class="fa fa-lg fa-check-circle-o" />
</label>
</a>
<input type="text" class="form-control col-9" placeholder="Enter your answer" t-attf-value="#{answer ? answer.text_value : ''}"/>
<i class="o_wslides_js_quiz_add_answer fa fa-lg fa-plus-circle p-2" title="Add another answer" />
<i class="o_wslides_js_quiz_remove_answer fa fa-lg fa-trash-o p-2" title="Remove this answer" />
</div>
</t>
<t t-name="slide.quiz.create.buttons">
<div class="d-flex">
<a class="o_wslides_js_quiz_validate_question btn btn-primary text-white border" role="button">
<span class="d-none d-sm-inline-block">Save &amp; Close</span>
</a>
<a class="o_wslides_js_quiz_validate_question o_wslides_js_quiz_create_next btn btn-primary text-white border ml-2" role="button">
<span class="d-none d-sm-inline-block">Save &amp; New</span>
</a>
<a class="o_wslides_js_quiz_cancel_question btn btn-light border ml-2" role="button">
<span class="d-none d-sm-inline-block">Cancel</span>
</a>
</div>
</t>
<t t-name="slide.quiz.update.buttons">
<a class="o_wslides_js_quiz_validate_question o_wslides_js_quiz_update btn btn-primary text-white border" role="button">
<span class="d-none d-sm-inline-block">Update</span>
</a>
<a class="o_wslides_js_quiz_cancel_question btn btn-light border ml-2" role="button">
<span class="d-none d-sm-inline-block">Cancel</span>
</a>
</t>
<t t-name="slide.quiz.confirm.deletion">
<div>Are you sure you want to delete this question : <strong t-esc="widget.questionTitle"/> ?</div>
</t>
</templates>
@@ -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,
})
]
})
+1
View File
@@ -28,6 +28,7 @@
<script type="text/javascript" src="/website_slides/static/src/js/slides_course_fullscreen_player.js"/>
<script type="text/javascript" src="/website_slides/static/src/js/slides_course_join.js"/>
<script type="text/javascript" src="/website_slides/static/src/js/slides_course_quiz.js"/>
<script type="text/javascript" src="/website_slides/static/src/js/slides_course_quiz_question_form.js"/>
<script type="text/javascript" src="/website_slides/static/src/js/slides_course_unsubscribe.js"/>
</xpath>
</template>
@@ -431,6 +431,9 @@
<i t-attf-class="fa-fw #{'fas fa-check' if channel_progress[slide.id].get('completed') else 'far fa-flag'}"/>
<t t-esc="channel_progress[slide.id].get('quiz_karma_won', 0) if channel_progress[slide.id].get('completed') else channel_progress[slide.id].get('quiz_karma_gain', 0)"/> xp
</span>
<a name="o_wslides_list_slide_add_quizz" t-if="channel.can_upload and not slide.question_ids" t-attf-href="/slides/slide/#{slug(slide)}?quiz_quick_create">
<span class="badge badge-light badge-hide border font-weight-normal px-2 py-1 m-1"><i class="fa fa-plus mr-1"/>Add Quiz</span>
</a>
<span class="badge badge-danger font-weight-normal px-2 py-1 m-1" t-if="not slide.website_published">Unpublished</span>
</div>
@@ -255,7 +255,8 @@
<div t-field="slide.html_content"/>
</div>
</div>
<div class="my-3">
<div class="mb-5">
<ul class="nav nav-tabs o_wslides_lesson_nav" role="tablist">
<li class="nav-item">
<a href="#about" aria-controls="about" class="nav-link active" role="tab" data-toggle="tab">
@@ -370,8 +371,15 @@
</div>
</div>
</div>
<div class="row" t-if="slide.question_ids">
<t t-call="website_slides.lesson_content_quiz"/>
<div class="o_wslides_js_quiz_container" t-att-data-slide-id="slide.id">
<div class="row" t-if="slide.slide_type != 'certification'">
<t t-if="slide.question_ids">
<t t-call="website_slides.lesson_content_quiz"/>
</t>
<div t-else="" class="o_wslides_js_lesson_quiz col" t-att-data-id="slide.id">
<t t-if="slide.channel_id.can_upload" t-call="website_slides.lesson_content_quiz_add_buttons"/>
</div>
</div>
</div>
<div class="row mt-3 mb-3">
<div class="col-12 col-md d-flex align-items-start mb-4 mb-md-0" t-if="len(slide.link_ids)">
@@ -418,42 +426,68 @@
t-att-data-next-slide-url="'/slides/slide/%s' % (slug(next_slide)) if next_slide else None"
t-att-data-channel-id="slide.channel_id.id"
t-att-data-channel-enroll="slide.channel_id.enroll"
t-att-data-channel-can-upload="slide.channel_id.can_upload"
t-att-data-signup-allowed="signup_allowed">
<div t-foreach="slide_questions" t-as="question"
t-att-class="'o_wslides_js_lesson_quiz_question my-5 %s' % ('completed-disabled' if slide_completed else ('disabled' if not slide.channel_id.is_member else ''))" t-att-data-question-id="question['id']" t-att-data-title="question['question']">
<div class="h4">
<small class="text-muted"><span t-esc="question_index+1"/>. </small> <span t-esc="question['question']"/>
</div>
<div class="list-group">
<t t-foreach="question['answers']" t-as="answer">
<a t-att-data-answer-id="answer['id']" href="#"
t-att-data-text="answer['text_value']"
t-att-class="'o_wslides_quiz_answer list-group-item list-group-item-action d-flex align-items-center %s' % ('list-group-item-success' if slide_completed and answer['is_correct'] else '')">
<label class="my-0 d-flex align-items-center justify-content-center mr-2">
<input type="radio"
t-att-name="question['id']"
t-att-value="answer['id']"
class="d-none"
t-att-disabled="True if not slide.channel_id.is_member or slide_completed else ''"/>
<i t-att-class="'fas fa-circle text-400 %s' % ('d-none' if slide_completed and answer['is_correct'] else '')"/>
<i class="fa fa-times-circle text-danger d-none"></i>
<i t-att-class="'fas fa-check-circle text-success %s' % ('d-none' if not (slide_completed and answer['is_correct']) else '')"></i>
</label>
<span t-esc="answer['text_value']"/>
</a>
</t>
</div>
</div>
<div class="alert alert-danger o_wslides_js_lesson_quiz_error mt-2 d-none" role="alert">
<span></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&amp;times;</span>
</button>
</div>
<t t-foreach="slide_questions" t-as="question">
<t t-call="website_slides.lesson_content_quiz_question"/>
</t>
<t t-if="slide.channel_id.can_upload" t-call="website_slides.lesson_content_quiz_add_buttons"/>
<div class="o_wslides_js_lesson_quiz_validation pt-3"/>
</div>
</template>
<template id="lesson_content_quiz_question" name="Lesson: Quiz question template">
<div t-att-class="'o_wslides_js_lesson_quiz_question mt-3 %s' % ('completed-disabled' if slide_completed else ('disabled' if not slide.channel_id.is_member else ''))"
t-att-data-question-id="question['id']" t-att-data-title="question['question']" >
<div class="row d-flex mb-2 mx-0">
<div class="h4">
<small class="text-muted">
<i class="o_wslides_js_quiz_sequence_handler fa fa-bars mr-1" t-if="slide.channel_id.can_upload and not slide_completed" />
<t t-if="question_index != NoneType"><span class="o_wslides_quiz_question_sequence" t-esc="question_index+1"/>.</t>
<t t-else=""><span class="o_wslides_quiz_question_sequence" t-esc="question['sequence']"/>.</t>
</small>
<span t-esc="question['question']"/>
</div>
<div class="ml-auto o_wslides_js_quiz_edit_del" t-if="slide.channel_id.can_upload and not slide_completed" >
<i class="o_wslides_js_quiz_edit_question fa fa-pencil-square-o p-1"></i>
<i class="o_wslides_js_quiz_delete_question fa fa-trash p-1"></i>
</div>
</div>
<div class="list-group">
<t t-foreach="question['answer_ids']" t-as="answer">
<a t-att-data-answer-id="answer['id']" href="#"
t-att-data-text="answer['text_value']" t-att-data-is-correct="answer['is_correct']"
t-att-class="'o_wslides_quiz_answer list-group-item list-group-item-action d-flex align-items-center %s' % ('list-group-item-success' if slide_completed and answer['is_correct'] else '')">
<label class="my-0 d-flex align-items-center justify-content-center mr-2">
<input type="radio"
t-att-name="question['id']"
t-att-value="answer['id']"
class="d-none"
t-att-disabled="True if not slide.channel_id.is_member or slide_completed else ''"/>
<i t-att-class="'fa fa-circle text-400 %s' % ('d-none' if slide_completed and answer['is_correct'] else '')"/>
<i class="fa fa-times-circle text-danger d-none"></i>
<i t-att-class="'fa fa-check-circle text-success %s' % ('d-none' if not (slide_completed and answer['is_correct']) else '')"></i>
</label>
<span t-esc="answer['text_value']"/>
</a>
</t>
</div>
</div>
</template>
<template id="lesson_content_quiz_add_buttons" name="Lesson: Quiz Add Buttons template">
<div class="o_wslides_js_lesson_quiz_new_question row mt-3">
<a t-attf-class="o_wslides_js_quiz_add o_wslides_js_quiz_add_quiz btn btn-light border ml-3 #{'d-none ' if slide.question_ids else ''}" role="button">
<i class="fa fa-plus mr-2"/>
<span class="d-none d-sm-inline-block">Add Quiz</span>
</a>
<a t-attf-class="o_wslides_js_quiz_add o_wslides_js_quiz_add_question btn btn-light border ml-3 #{'' if slide.question_ids else 'd-none '}" role="button">
<i class="fa fa-plus mr-2"/>
<span class="d-none d-sm-inline-block">Add Question</span>
</a>
</div>
</template>
<!-- Slide sub-template: share: send by email -->
<template id='slide_social_email' name="Share by Email">
<h5 class="mt-4">Share by mail</h5>
@@ -28,6 +28,9 @@
<xpath expr="//a[hasclass('o_wslides_js_slides_list_slide_link')]" position="attributes">
<attribute name="t-attf-href">#{'/slides_survey/slide/get_certification_url?slide_id=%s' %(slide.id) if slide.slide_type == 'certification' and slide.channel_id.total_slides == 1 else '/slides/slide/%s' %(slug(slide))}</attribute>
</xpath>
<xpath expr="//a[@name='o_wslides_list_slide_add_quizz']" position="attributes">
<attribute name="t-if">channel.can_upload and not slide.question_ids and slide.slide_type != 'certification'</attribute>
</xpath>
</template>
</data>
</odoo>