diff --git a/xblocks_contrib/poll/poll.py b/xblocks_contrib/poll/poll.py index 37cbfc4..57d12e3 100644 --- a/xblocks_contrib/poll/poll.py +++ b/xblocks_contrib/poll/poll.py @@ -1,31 +1,55 @@ """TO-DO: Write a description of what this XBlock is.""" +import uuid +import html +import json from importlib.resources import files +from collections import OrderedDict from django.utils import translation from web_fragments.fragment import Fragment from xblock.core import XBlock -from xblock.fields import Integer, Scope +from xblock.fields import Boolean, Dict, List, Scope, String from xblock.utils.resources import ResourceLoader +_ = lambda text: text resource_loader = ResourceLoader(__name__) -# This Xblock is just to test the strucutre of xblocks-contrib @XBlock.needs("i18n") class PollBlock(XBlock): - """ - TO-DO: document what your XBlock does. - """ - - # Fields are defined on the class. You can access them in your code as - # self.. + + display_name = String( + help=_("The display name for this component."), + scope=Scope.settings + ) - # TO-DO: delete count, and define your own fields. - count = Integer( - default=0, + voted = Boolean( + help=_("Whether this student has voted on the poll"), + scope=Scope.user_state, + default=False + ) + poll_answer = String( + help=_("Student answer"), scope=Scope.user_state, - help="A simple counter, to show something happening", + default='' + ) + poll_answers = Dict( + help=_("Poll answers from all students"), + scope=Scope.user_state_summary + ) + + # List of answers, in the form {'id': 'some id', 'text': 'the answer text'} + answers = List( + help=_("Poll answers from xml"), + scope=Scope.content, + default=[] + ) + + question = String( + help=_("Poll question"), + scope=Scope.content, + default='' ) # Indicates that this XBlock has been extracted from edx-platform. @@ -35,44 +59,110 @@ def resource_string(self, path): """Handy helper for getting resources from our kit.""" return files(__package__).joinpath(path).read_text(encoding="utf-8") - # TO-DO: change this view to display your data your own way. def student_view(self, context=None): """ Create primary view of the XBlock, shown to students when viewing courses. """ - if context: - pass # TO-DO: do something based on the context. - - frag = Fragment() - frag.add_content( + fragment = Fragment() + params = { + 'element_id': str(uuid.uuid1()), + 'element_class': '_poll_question_extracted', + 'configuration_json': self.dump_poll(), + } + fragment.add_content( resource_loader.render_django_template( "templates/poll.html", - { - "count": self.count, - }, - i18n_service=self.runtime.service(self, "i18n"), + params ) ) - frag.add_css(self.resource_string("static/css/poll.css")) - frag.add_javascript(self.resource_string("static/js/src/poll.js")) - frag.initialize_js("PollBlock") - return frag + fragment.add_css(resource_loader.load_unicode("static/css/poll.css")) + fragment.add_javascript(resource_loader.load_unicode("static/js/src/poll.js")) + fragment.initialize_js("PollBlock") + return fragment + + def dump_poll(self): + """Dump poll information. + + Returns: + string - Serialize json. + """ + # FIXME: hack for resolving caching `default={}` during definition + # poll_answers field + if self.poll_answers is None: + self.poll_answers = {} + + answers_to_json = OrderedDict() + + # FIXME: fix this, when xblock support mutable types. + # Now we use this hack. + temp_poll_answers = self.poll_answers + + # Fill self.poll_answers, prepare data for template context. + for answer in self.answers: + # Set default count for answer = 0. + if answer['id'] not in temp_poll_answers: + temp_poll_answers[answer['id']] = 0 + answers_to_json[answer['id']] = html.escape(answer['text'], quote=False) + self.poll_answers = temp_poll_answers + + return json.dumps({ + 'answers': answers_to_json, + 'question': html.escape(self.question, quote=False), + # to show answered poll after reload: + 'poll_answer': self.poll_answer, + 'poll_answers': self.poll_answers if self.voted else {}, + 'total': sum(self.poll_answers.values()) if self.voted else 0, + # 'reset': str(self.xml_attributes.get('reset', 'true')).lower() + }) # TO-DO: change this handler to perform your own actions. You may need more # than one handler, or you may not need any handlers at all. + + + @XBlock.json_handler - def increment_count(self, data, suffix=""): - """ - Increments data. An example handler. - """ - if suffix: - pass # TO-DO: Use the suffix when storing data. - # Just to show data coming in... - assert data["hello"] == "world" + def handle_get_state(self): + return { + 'poll_answer': self.poll_answer, + 'poll_answers': self.poll_answers, + 'total': sum(self.poll_answers.values()) + } + + @XBlock.json_handler + def handle_submit_state(self, data): + answer = data.get('answer') # Extract the answer from the data payload + if not answer: + return {'error': 'No answer provided!'} + + if answer in self.poll_answers and not self.voted: + # FIXME: fix this, when xblock will support mutable types. + # Now we use this hack. + temp_poll_answers = self.poll_answers + temp_poll_answers[answer] += 1 + self.poll_answers = temp_poll_answers + + self.voted = True + self.poll_answer = answer + return { + 'poll_answers': self.poll_answers, + 'total': sum(self.poll_answers.values()), + 'callback': {'objectName': 'Conditional'} + } + + @XBlock.json_handler + def handle_reset_state(self): + self.voted = False + + # FIXME: fix this, when xblock will support mutable types. + # Now we use this hack. + temp_poll_answers = self.poll_answers + temp_poll_answers[self.poll_answer] -= 1 + self.poll_answers = temp_poll_answers + + self.poll_answer = '' + return {'status': 'success'} - self.count += 1 - return {"count": self.count} # TO-DO: change this to create the scenarios you'd like to see in the # workbench while developing your XBlock. diff --git a/xblocks_contrib/poll/static/css/poll.css b/xblocks_contrib/poll/static/css/poll.css index b337caf..ca3d29e 100644 --- a/xblocks_contrib/poll/static/css/poll.css +++ b/xblocks_contrib/poll/static/css/poll.css @@ -1,9 +1,221 @@ -/* CSS for PollBlock */ +@import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); -.poll .count { +.xmodule_display.xmodule_PollBlock { + /* stylelint-disable-line */ + /* stylelint-disable-line */ +} + +@media print { + .xmodule_display.xmodule_PollBlock div.poll_question { + display: block; + width: auto; + padding: 0; + } + + .xmodule_display.xmodule_PollBlock div.poll_question canvas, .xmodule_display.xmodule_PollBlock div.poll_question img { + page-break-inside: avoid; + } +} + +.xmodule_display.xmodule_PollBlock div.poll_question .inline { + display: inline; +} + +.xmodule_display.xmodule_PollBlock div.poll_question h3 { + margin-top: 0; + margin-bottom: calc((var(--baseline) * 0.75)); + color: #fe57a1; + font-size: 1.9em; +} + +.xmodule_display.xmodule_PollBlock div.poll_question h3.problem-header div.staff { + margin-top: calc((var(--baseline) * 1.5)); + font-size: 80%; +} + +@media print { + .xmodule_display.xmodule_PollBlock div.poll_question h3 { + display: block; + width: auto; + border-right: 0; + } +} + +.xmodule_display.xmodule_PollBlock div.poll_question p { + text-align: justify; font-weight: bold; } -.poll p { - cursor: pointer; +.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer { + margin-bottom: var(--baseline); +} + +.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer.short { + clear: both; } + +.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .question { + height: auto; + clear: both; + min-height: 30px; +} + +.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .question.short { + clear: none; + width: 30%; + display: inline; + float: left; +} + +.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .question .button { + -webkit-appearance: none; + -webkit-background-clip: padding-box; + -webkit-border-image: none; + -webkit-box-align: center; + -webkit-box-shadow: white 0px 1px 0px 0px inset; + -webkit-font-smoothing: antialiased; + -webkit-rtl-ordering: logical; + -webkit-user-select: text; + -webkit-writing-mode: horizontal-tb; + background-clip: padding-box; + background-color: #eeeeee; + background-image: -webkit-linear-gradient(top, #eeeeee, #d2d2d2); + border-bottom-color: #cacaca; + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; + border-bottom-style: solid; + border-bottom-width: 1px; + border-left-color: #cacaca; + border-left-style: solid; + border-left-width: 1px; + border-right-color: #cacaca; + border-right-style: solid; + border-right-width: 1px; + border-top-color: #cacaca; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + border-top-style: solid; + border-top-width: 1px; + box-shadow: white 0px 1px 0px 0px inset; + box-sizing: border-box; + color: #333333; + /* display: inline-block; */ + display: inline; + float: left; + font-family: 'Open Sans', Verdana, Geneva, sans-serif; + font-size: 13px; + font-style: normal; + font-variant: normal; + font-weight: bold; + letter-spacing: normal; + line-height: 25.59375px; + margin-bottom: calc((var(--baseline) * 0.75)); + margin: 0; + padding: 0px; + text-align: center; + text-decoration: none; + text-indent: 0px; + text-shadow: #f8f8f8 0px 1px 0px; + text-transform: none; + vertical-align: top; + white-space: pre-line; + width: 25px; + height: 25px; + word-spacing: 0px; + writing-mode: lr-tb; +} + +.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .question .button.answered { + -webkit-box-shadow: #61b8e1 0px 1px 0px 0px inset; + background-color: #1d9dd9; + background-image: -webkit-linear-gradient(top, #1d9dd9, #0e7cb0); + border-bottom-color: #0d72a2; + border-left-color: #0d72a2; + border-right-color: #0d72a2; + border-top-color: #0d72a2; + box-shadow: #61b8e1 0px 1px 0px 0px inset; + color: white; + text-shadow: #076794 0px 1px 0px; + background-image: none; +} + +.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .question .text { + display: inline; + float: left; + width: 80%; + text-align: left; + min-height: 30px; + margin-left: var(--baseline); + height: auto; + margin-bottom: var(--baseline); +} + +.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .question .text.short { + width: 100px; +} + +.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats { + min-height: 40px; + margin-top: var(--baseline); + clear: both; +} + +.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats.short { + margin-top: 0; + clear: none; + display: inline; + float: right; + width: 70%; +} + +.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats .bar { + width: 75%; + height: 20px; + border: 1px solid black; + display: inline; + float: left; + margin-right: calc((var(--baseline) / 2)); +} + +.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats .bar.short { + width: 65%; + height: 20px; + margin-top: 3px; +} + +.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats .bar .percent { + background-color: gray; + width: 0; + height: 20px; +} + +.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats .number { + width: 80px; + display: inline; + float: right; + height: 28px; + text-align: right; +} + +.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats .number.short { + width: 120px; + height: auto; +} + +.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer.answered { + -webkit-box-shadow: #61b8e1 0 1px 0 0 inset; + background-color: #1d9dd9; + background-image: -webkit-linear-gradient(top, #1d9dd9, #0e7cb0); + border-bottom-color: #0d72a2; + border-left-color: #0d72a2; + border-right-color: #0d72a2; + border-top-color: #0d72a2; + box-shadow: #61b8e1 0 1px 0 0 inset; + color: white; + text-shadow: #076794 0 1px 0; +} + +.xmodule_display.xmodule_PollBlock div.poll_question .button.reset-button { + clear: both; + float: right; +} \ No newline at end of file diff --git a/xblocks_contrib/poll/static/js/src/poll.js b/xblocks_contrib/poll/static/js/src/poll.js index 7389c87..3613b79 100644 --- a/xblocks_contrib/poll/static/js/src/poll.js +++ b/xblocks_contrib/poll/static/js/src/poll.js @@ -1,38 +1,317 @@ /* JavaScript for PollBlock. */ +var _this +const blockIdentifier = '._poll_question_extracted' + function PollBlock(runtime, element) { - const updateCount = (result) => { - $('.count', element).text(result.count); - }; - const handlerUrl = runtime.handlerUrl(element, 'increment_count'); + const questionEl = $(element).find(blockIdentifier); + if (questionEl.length !== 1) { + // We require one question DOM element. + console.log('ERROR: PollMain constructor requires one question DOM element.'); + + return; + } + + // Just a safety precussion. If we run this code more than once, multiple 'click' callback handlers will be + // attached to the same DOM elements. We don't want this to happen. + if (questionEl.attr('poll_main_processed') === 'true') { + console.log( + 'ERROR: PolMain JS constructor was called on a DOM element that has already been processed once.' + ); + + return; + } + + // This element was not processed earlier. + // Make sure that next time we will not process this element a second time. + questionEl.attr('poll_main_processed', 'true'); + + // Access this object inside inner functions. + _this = this; + + // DOM element which contains the current poll along with any conditionals. By default we assume that such + // element is not present. We will try to find it. + this.wrapperSectionEl = null; + + (function(tempEl, c1) { + while (tempEl.tagName.toLowerCase() !== 'body') { + tempEl = $(tempEl).parent()[0]; + c1 += 1; + + if ( + (tempEl.tagName.toLowerCase() === 'div') + && ($(tempEl).data('block-type') === 'wrapper') + ) { + _this.wrapperSectionEl = tempEl; + + break; + } else if (c1 > 50) { + // In case something breaks, and we enter an endless loop, a sane + // limit for loop iterations. + + break; + } + } + }($(element)[0], 0)); + + try { + this.jsonConfig = JSON.parse(questionEl.children('.poll_question_div').html()); - $('p', element).on('click', (eventObject) => { $.ajax({ type: 'POST', - url: handlerUrl, - contentType: 'application/json', - data: JSON.stringify({hello: 'world'}), - success: updateCount + url: runtime.handleUrl(element, 'handle_get_state'), + data: JSON.stringify(null), + success: function(response) { + _this.jsonConfig.poll_answer = response.poll_answer; + _this.jsonConfig.total = response.total; + + $.each(response.poll_answers, function(index, value) { + _this.jsonConfig.poll_answers[index] = value; + }); + + // xss-lint: disable=javascript-jquery-html + questionEl.children('.poll_question_div').html(JSON.stringify(_this.jsonConfig)); + + _this.postInit(runtime); + } + }); + + return; + } catch (err) { + console.log( + 'ERROR: Invalid JSON config for poll ID "' + this.id + '".', + 'Error messsage: "' + err.message + '".' + ); + } + +} + +function showAnswerGraph(poll_answers, total) { + + var totalValue; + totalValue = parseFloat(total); + if (isFinite(totalValue) === false) { + return; + } + + _this = this; + + $.each(poll_answers, function(index, value) { + var numValue, percentValue; + + numValue = parseFloat(value); + if (isFinite(numValue) === false) { + return; + } + + percentValue = (numValue / totalValue) * 100.0; + + _this.answersObj[index].statsEl.show(); + // eslint-disable-next-line max-len + _this.answersObj[index].numberEl.html(HtmlUtils.HTML('' + value + ' (' + percentValue.toFixed(1) + '%)').toString()); + _this.answersObj[index].percentEl.css({ + width: '' + percentValue.toFixed(1) + '%' }); }); +} + +function submitAnswer(runtime, answer, answerObj) { + + // Make sure that the user can answer a question only once. + if (this.questionAnswered === true) { + return; + } + this.questionAnswered = true; + + _this = this; - $(() => { - /* - Use `gettext` provided by django-statici18n for static translations - */ + answerObj.buttonEl.addClass('answered'); + + var data = { + answer: answer + } - // eslint-disable-next-line no-undef - const dummyText = gettext('Hello World'); + // Send the data to the server as an AJAX request. Attach a callback that will + // be fired on server's response. + $.ajax({ + type: 'POST', + url: runtime.handleUrl(answerObj, 'handle_submit_state'), + data: JSON.stringify(data), + success: function (response) { + console.log('success! response = '); + console.log(response); - // Example usage of interpolation for translated strings - // eslint-disable-next-line no-undef - const message = StringUtils.interpolate( - gettext('You are enrolling in {courseName}'), - { - courseName: 'Rock & Roll 101' + _this.showAnswerGraph(response.poll_answers, response.total); + + if (_this.canReset === true) { + _this.resetButton.show(); } - ); - console.log(message); // This is just for demonstration purposes + + // Initialize Conditional constructors. + if (_this.wrapperSectionEl !== null) { + $(_this.wrapperSectionEl).find('.xmodule_ConditionalModule').each(function(index, value) { + // eslint-disable-next-line no-new + new window.Conditional(value, _this.runtime, _this.id.replace(/^poll_/, '')); + }); + } + } + }); +} + +function submitReset(runtime, element) { + + _this = this; + + console.log('submit reset'); + const questionEl = $(element).find(blockIdentifier); + // Send the data to the server as an AJAX request. Attach a callback that will + // be fired on server's response. + $.ajax({ + type: 'POST', + url: runtime.handleUrl(element, 'handle_reset_state'), + data: JSON.stringify({}), + success: function(response) { + console.log('success! response = '); + console.log(response); + + if ((response.hasOwnProperty('status') !== true) || (typeof response.status !== 'string') + || (response.status.toLowerCase() !== 'success')) { + return; + } + + _this.questionAnswered = false; + questionEl.find('.button.answered').removeClass('answered'); + questionEl.find('.stats').hide(); + _this.resetButton.hide(); + + // Initialize Conditional constructors. We will specify the third parameter as 'true' + // notifying the constructor that this is a reset operation. + if (_this.wrapperSectionEl !== null) { + $(_this.wrapperSectionEl).find('.xmodule_ConditionalModule').each(function(index, value) { + // eslint-disable-next-line no-new + new window.Conditional(value, _this.runtime, _this.id.replace(/^poll_/, '')); + }); + } + } }); } + +function postInit(runtime) { + + // Access this object inside inner functions. + _this = this; + const questionEl = $(element).find(blockIdentifier); + if ((this.jsonConfig.poll_answer.length > 0) && (this.jsonConfig.answers.hasOwnProperty(this.jsonConfig.poll_answer) === false) + ) { + HtmlUtils.append(questionEl, HtmlUtils.joinHtml( + HtmlUtils.HTML('

Error!

'), + HtmlUtils.HTML( + '

XML data format changed. List of answers was modified, but poll data was not updated.

' + ) + )); + + return; + } + + // Get the DOM id of the question. + this.id = questionEl.attr('id'); + + // Get the URL to which we will post the users answer to the question. + // this.ajax_url = questionEl.data('ajax-url'); + + this.questionHtmlMarkup = $('
').html(HtmlUtils.HTML(this.jsonConfig.question).toString()).text(); + questionEl.append(HtmlUtils.HTML(this.questionHtmlMarkup).toString()); + + // When the user selects and answer, we will set this flag to true. + this.questionAnswered = false; + + this.answersObj = {}; + this.shortVersion = true; + + $.each(this.jsonConfig.answers, function(index, value) { + if (value.length >= 18) { + _this.shortVersion = false; + } + }); + + $.each(this.jsonConfig.answers, function(index, value) { + var answer; + + answer = {}; + + _this.answersObj[index] = answer; + + answer.el = $('
'); + + answer.questionEl = $('
'); + answer.buttonEl = $('
'); + answer.textEl = $('
'); + answer.questionEl.append(HtmlUtils.HTML(answer.buttonEl).toString()); + answer.questionEl.append(HtmlUtils.HTML(answer.textEl).toString()); + + answer.el.append(HtmlUtils.HTML(answer.questionEl).toString()); + + answer.statsEl = $('
'); + answer.barEl = $('
'); + answer.percentEl = $('
'); + answer.barEl.append(HtmlUtils.HTML(answer.percentEl).toString()); + answer.numberEl = $('
'); + answer.statsEl.append(HtmlUtils.HTML(answer.barEl).toString()); + answer.statsEl.append(HtmlUtils.HTML(answer.numberEl).toString()); + + answer.statsEl.hide(); + + answer.el.append(HtmlUtils.HTML(answer.statsEl).toString()); + + answer.textEl.html(HtmlUtils.HTML(value).toString()); + + if (_this.shortVersion === true) { + // eslint-disable-next-line no-shadow + $.each(answer, function(index, value) { + if (value instanceof jQuery) { + value.addClass('short'); + } + }); + } + + answer.el.appendTo(questionEl); + + answer.textEl.on('click', function() { + _this.submitAnswer(runtime, index, answer); + }); + + answer.buttonEl.on('click', function() { + _this.submitAnswer(runtime, index, answer); + }); + + if (index === _this.jsonConfig.poll_answer) { + answer.buttonEl.addClass('answered'); + _this.questionAnswered = true; + } + }); + + console.log(this.jsonConfig.reset); + + if ((typeof this.jsonConfig.reset === 'string') && (this.jsonConfig.reset.toLowerCase() === 'true')) { + this.canReset = true; + + this.resetButton = $('
Change your vote
'); + + if (this.questionAnswered === false) { + this.resetButton.hide(); + } + + HtmlUtils.append(questionEl, this.resetButton); + this.resetButton.on('click', function() { + _this.submitReset(); + }); + } else { + this.canReset = false; + } + + // If it turns out that the user already answered the question, show the answers graph. + if (this.questionAnswered === true) { + this.showAnswerGraph(this.jsonConfig.poll_answers, this.jsonConfig.total); + } +} \ No newline at end of file diff --git a/xblocks_contrib/poll/templates/poll.html b/xblocks_contrib/poll/templates/poll.html index 3bd5fec..6f062b5 100644 --- a/xblocks_contrib/poll/templates/poll.html +++ b/xblocks_contrib/poll/templates/poll.html @@ -1,7 +1,7 @@ -{% load i18n %} - -
-

- PollBlock: {% trans "count is now" %} {{ count }} {% trans "click me to increment." %} -

+
+ +