diff --git a/cms/djangoapps/contentstore/tests/factories.py b/cms/djangoapps/contentstore/tests/factories.py index 3274477098..cb9f451d38 100644 --- a/cms/djangoapps/contentstore/tests/factories.py +++ b/cms/djangoapps/contentstore/tests/factories.py @@ -73,6 +73,10 @@ class XModuleItemFactory(Factory): @classmethod def _create(cls, target_class, *args, **kwargs): + """ + kwargs must include parent_location, template. Can contain display_name + target_class is ignored + """ DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info'] diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index b3f13de998..d0b8261908 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -22,6 +22,8 @@ from xmodule.modulestore.django import modulestore from xmodule.contentstore.django import contentstore from xmodule.course_module import CourseDescriptor from xmodule.modulestore.xml_exporter import export_to_xml +from cms.djangoapps.contentstore.utils import get_modulestore +from xmodule.capa_module import CapaDescriptor def parse_json(response): """Parse response, which is assumed to be json""" @@ -438,13 +440,24 @@ class ContentStoreTest(TestCase): self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf') + def test_capa_module(self): + """Test that a problem treats markdown specially.""" + CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + problem_data = { + 'parent_location' : 'i4x://MITx/999/course/Robot_Super_Course', + 'template' : 'i4x://edx/templates/problem/Empty' + } + resp = self.client.post(reverse('clone_item'), problem_data) - - - - - - - + self.assertEqual(resp.status_code, 200) + payload = parse_json(resp) + problem_loc = payload['id'] + problem = get_modulestore(problem_loc).get_item(problem_loc) + # should be a CapaDescriptor + self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor") + context = problem.get_context() + self.assertIn('markdown', context, "markdown is missing from context") + self.assertIn('markdown', problem.metadata, "markdown is missing from metadata") + self.assertNotIn('markdown', problem.editable_metadata_fields, "Markdown slipped into the editable metadata fields") \ No newline at end of file diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 560485eb26..add42436dc 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -271,6 +271,8 @@ def edit_unit(request, location): component_templates[template.location.category].append(( template.display_name, template.location.url(), + 'markdown' in template.metadata, + template.location.name == 'Empty' )) components = [ diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee index 0617b01bb4..b1157f713e 100644 --- a/cms/static/coffee/src/views/module_edit.coffee +++ b/cms/static/coffee/src/views/module_edit.coffee @@ -55,7 +55,7 @@ class CMS.Views.ModuleEdit extends Backbone.View clickSaveButton: (event) => event.preventDefault() data = @module.save() - data.metadata = @metadata() + data.metadata = _.extend(data.metadata, @metadata()) $modalCover.hide() @model.save(data).done( => # # showToastMessage("Your changes have been saved.", null, 3) diff --git a/cms/static/coffee/src/views/unit.coffee b/cms/static/coffee/src/views/unit.coffee index fe8f928746..7f5fa4adce 100644 --- a/cms/static/coffee/src/views/unit.coffee +++ b/cms/static/coffee/src/views/unit.coffee @@ -59,6 +59,9 @@ class CMS.Views.UnitEdit extends Backbone.View type = $(event.currentTarget).data('type') @$newComponentTypePicker.slideUp(250) @$(".new-component-#{type}").slideDown(250) + $('html, body').animate({ + scrollTop: @$(".new-component-#{type}").offset().top + }, 500) closeNewComponent: (event) => event.preventDefault() diff --git a/cms/static/img/choice-example.png b/cms/static/img/choice-example.png new file mode 100644 index 0000000000..ee136577a9 Binary files /dev/null and b/cms/static/img/choice-example.png differ diff --git a/cms/static/img/multi-example.png b/cms/static/img/multi-example.png new file mode 100644 index 0000000000..abe729a94b Binary files /dev/null and b/cms/static/img/multi-example.png differ diff --git a/cms/static/img/number-example.png b/cms/static/img/number-example.png new file mode 100644 index 0000000000..7cd050cb5e Binary files /dev/null and b/cms/static/img/number-example.png differ diff --git a/cms/static/img/problem-editor-icons.png b/cms/static/img/problem-editor-icons.png new file mode 100644 index 0000000000..62f078560f Binary files /dev/null and b/cms/static/img/problem-editor-icons.png differ diff --git a/cms/static/img/select-example.png b/cms/static/img/select-example.png new file mode 100644 index 0000000000..ef80e629de Binary files /dev/null and b/cms/static/img/select-example.png differ diff --git a/cms/static/img/string-example.png b/cms/static/img/string-example.png new file mode 100644 index 0000000000..6f628b20d4 Binary files /dev/null and b/cms/static/img/string-example.png differ diff --git a/cms/static/sass/_assets.scss b/cms/static/sass/_assets.scss index 23d9ea4d9a..5f735dd82b 100644 --- a/cms/static/sass/_assets.scss +++ b/cms/static/sass/_assets.scss @@ -98,69 +98,69 @@ } .upload-modal { - display: none; - width: 640px !important; - margin-left: -320px !important; + display: none; + width: 640px !important; + margin-left: -320px !important; - .modal-body { - height: auto !important; - overflow-y: auto !important; - text-align: center; - } + .modal-body { + height: auto !important; + overflow-y: auto !important; + text-align: center; + } - .file-input { - display: none; - } + .file-input { + display: none; + } - .choose-file-button { - @include blue-button; - padding: 10px 82px 12px; - font-size: 17px; - } + .choose-file-button { + @include blue-button; + padding: 10px 82px 12px; + font-size: 17px; + } - .progress-bar { - display: none; - width: 350px; - height: 50px; - margin: 30px auto 10px; - border: 1px solid $blue; + .progress-bar { + display: none; + width: 350px; + height: 50px; + margin: 30px auto 10px; + border: 1px solid $blue; - &.loaded { - border-color: #66b93d; + &.loaded { + border-color: #66b93d; - .progress-fill { - background: #66b93d; - } - } - } + .progress-fill { + background: #66b93d; + } + } + } - .progress-fill { - width: 0%; - height: 50px; - background: $blue; - color: #fff; - line-height: 48px; - } + .progress-fill { + width: 0%; + height: 50px; + background: $blue; + color: #fff; + line-height: 48px; + } - h1 { - float: none; - margin: 40px 0 30px; - font-size: 34px; - font-weight: 300; - } + h1 { + float: none; + margin: 40px 0 30px; + font-size: 34px; + font-weight: 300; + } - .close-button { - @include white-button; - position: absolute; - top: 0; - right: 15px; - width: 29px; - height: 29px; - padding: 0 !important; - border-radius: 17px !important; - line-height: 29px; - text-align: center; - } + .close-button { + @include white-button; + position: absolute; + top: 0; + right: 15px; + width: 29px; + height: 29px; + padding: 0 !important; + border-radius: 17px !important; + line-height: 29px; + text-align: center; + } .embeddable { display: none; @@ -178,9 +178,9 @@ width: 400px; } - .copy-button { - @include white-button; - display: none; - margin-bottom: 100px; - } + .copy-button { + @include white-button; + display: none; + margin-bottom: 100px; + } } \ No newline at end of file diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index 92cde28756..c79458dbe0 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -1,6 +1,6 @@ // ------------------------------------- // -// Universal +// Universal // // ------------------------------------- diff --git a/cms/static/sass/_unit.scss b/cms/static/sass/_unit.scss index 255738d32f..62586a2baf 100644 --- a/cms/static/sass/_unit.scss +++ b/cms/static/sass/_unit.scss @@ -56,6 +56,15 @@ z-index: 10; margin: 20px 40px; + + .title { + margin: 0 0 15px 0; + color: $mediumGrey; + + .value { + } + } + &.new-component-item { padding: 20px; border: none; @@ -116,7 +125,7 @@ a { position: relative; border: 1px solid $darkGreen; - background: $green; + background: tint($green,20%); color: #fff; @include transition(background-color .15s); @@ -129,23 +138,71 @@ .new-component-template { margin-bottom: 20px; - li:first-child { + li:last-child { + a { + border-radius: 0 0 3px 3px; + border-bottom: 1px solid $darkGreen; + } + } + + li:nth-child(2) { a { border-radius: 3px 3px 0 0; } } - li:last-child { - a { - border-radius: 0 0 3px 3px; - } - } - a { + @include clearfix(); display: block; padding: 7px 20px; border-bottom: none; font-weight: 300; + + .name { + float: left; + + .ss-icon { + @include transition(opacity .15s); + position: relative; + top: 1px; + font-size: 13px; + margin-right: 5px; + opacity: 0.5; + } + } + + .editor-indicator { + @include transition(opacity .15s); + float: right; + position: relative; + top: 3px; + font-size: 12px; + opacity: 0.1; + } + + &:hover { + + .ss-icon { + opacity: 1.0; + } + + .editor-indicator { + opacity: 1.0; + } + } + } + + // specific editor types + .empty { + @include box-shadow(0 1px 3px rgba(0,0,0,0.2)); + margin-bottom: 10px; + + a { + border-bottom: 1px solid $darkGreen; + border-radius: 3px; + font-weight: 500; + background: $green; + } } } diff --git a/cms/templates/unit.html b/cms/templates/unit.html index 0599411a67..ef94d51576 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -5,6 +5,7 @@ <%block name="title">CMS Unit <%block name="jsextra"> + <%block name="content">
@@ -46,20 +54,43 @@ % endfor
- % for type, templates in sorted(component_templates.items()): -
- - Cancel -
- % endfor + % for type, templates in sorted(component_templates.items()): +
+

Select ${type} component type:

+ + + Cancel +
+ % endfor diff --git a/cms/templates/widgets/problem-edit.html b/cms/templates/widgets/problem-edit.html new file mode 100644 index 0000000000..6176486c9a --- /dev/null +++ b/cms/templates/widgets/problem-edit.html @@ -0,0 +1,83 @@ +<%include file="metadata-edit.html" /> +
+
+ %if markdown != '' or data == '\n\n': +
+
    +
  • +
  • +
  • +
  • +
  • +
+ +
+ + %endif + +
+
+ + diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 86af5cdf2a..67fc46d25a 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -670,11 +670,29 @@ class CapaDescriptor(RawDescriptor): stores_state = True has_score = True template_dir_name = 'problem' + mako_template = "widgets/problem-edit.html" + js = {'coffee': [resource_string(__name__, 'js/src/problem/edit.coffee')]} + js_module_name = "MarkdownEditingDescriptor" + css = {'scss': [resource_string(__name__, 'css/problem/edit.scss')]} # Capa modules have some additional metadata: # TODO (vshnayder): do problems have any other metadata? Do they # actually use type and points? metadata_attributes = RawDescriptor.metadata_attributes + ('type', 'points') + + def get_context(self): + _context = RawDescriptor.get_context(self) + _context.update({'markdown': self.metadata.get('markdown', '')}) + return _context + + @property + def editable_metadata_fields(self): + """Remove metadata from the editable fields since it has its own editor""" + subset = super(CapaDescriptor,self).editable_metadata_fields + if 'markdown' in subset: + subset.remove('markdown') + return subset + # VS[compat] # TODO (cpennington): Delete this method once all fall 2012 course are being diff --git a/common/lib/xmodule/xmodule/css/problem/edit.scss b/common/lib/xmodule/xmodule/css/problem/edit.scss new file mode 100644 index 0000000000..ba5a87feb4 --- /dev/null +++ b/common/lib/xmodule/xmodule/css/problem/edit.scss @@ -0,0 +1,153 @@ +.editor-bar { + position: relative; + @include linear-gradient(top, #d4dee8, #c9d5e2); + padding: 5px; + border: 1px solid #3c3c3c; + border-radius: 3px 3px 0 0; + border-bottom-color: #a5aaaf; + @include clearfix; + + a { + display: block; + float: left; + padding: 3px 10px 7px; + margin-left: 7px; + border-radius: 2px; + + &:hover { + background: rgba(255, 255, 255, .5); + } + } + + .editor-tabs { + position: absolute; + top: 10px; + right: 10px; + + li { + float: left; + } + + .tab { + height: 24px; + padding: 7px 20px 3px; + border: 1px solid #a5aaaf; + border-radius: 3px 3px 0 0; + @include linear-gradient(top, rgba(0, 0, 0, 0) 87%, rgba(0, 0, 0, .06)); + background-color: #e5ecf3; + font-size: 13px; + color: #3c3c3c; + box-shadow: 1px -1px 1px rgba(0, 0, 0, .05); + + &.current { + background: #fff; + border-bottom-color: #fff; + } + } + + .cheatsheet-toggle { + width: 21px; + height: 21px; + padding: 0; + margin: 3px 5px 0 16px; + border-radius: 22px; + border: 1px solid #a5aaaf; + background: #e5ecf3; + font-size: 13px; + font-weight: 700; + color: #565d64; + text-align: center; + } + } +} + +.simple-editor-cheatsheet { + position: absolute; + top: 0; + left: 100%; + width: 0; + border-radius: 0 3px 3px 0; + @include linear-gradient(left, rgba(0, 0, 0, .1), rgba(0, 0, 0, 0) 15px); + background-color: #fff; + overflow: hidden; + @include transition(width .3s); + + &.shown { + width: 300px; + } + + .cheatsheet-wrapper { + width: 240px; + padding: 20px 30px; + } + + h6 { + margin-bottom: 7px; + font-size: 15px; + font-weight: 700; + } + + .row { + @include clearfix; + padding-bottom: 5px !important; + margin-bottom: 10px !important; + border-bottom: 1px solid #ddd !important; + + &:last-child { + border-bottom: none !important; + margin-bottom: 0 !important; + } + } + + .col { + float: left; + + &.sample { + width: 60px; + margin-right: 30px; + } + } + + pre { + font-size: 12px; + line-height: 18px; + } + + code { + padding: 0; + background: none; + } +} + +.problem-editor-icon { + display: inline-block; + width: 26px; + height: 21px; + vertical-align: middle; + background: url(../img/problem-editor-icons.png) no-repeat; +} + +.problem-editor-icon.multiple-choice { + background-position: 0 0; +} + +.problem-editor-icon.checks { + background-position: -56px 0; +} + +.problem-editor-icon.string { + width: 28px; + background-position: -111px 0; +} + +.problem-editor-icon.number { + width: 24px; + background-position: -168px 0; +} + +.problem-editor-icon.dropdown { + width: 17px; + background-position: -220px 0; +} + + diff --git a/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee index 0ecc8fe275..9e2aab0c25 100644 --- a/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee @@ -142,7 +142,7 @@ describe 'Problem', -> xdescribe 'when the response is undetermined', -> it 'alert the response', -> spyOn window, 'alert' - spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) -> + spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) -> callback(success: 'Number Only!') @problem.check() expect(window.alert).toHaveBeenCalledWith 'Number Only!' diff --git a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee new file mode 100644 index 0000000000..3289c5fe7d --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee @@ -0,0 +1,338 @@ +describe 'MarkdownEditingDescriptor', -> + + describe 'insertMultipleChoice', -> + it 'inserts the template if selection is empty', -> + revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice('') + expect(revisedSelection).toEqual(MarkdownEditingDescriptor.multipleChoiceTemplate) + it 'wraps existing text', -> + revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice('foo\nbar') + expect(revisedSelection).toEqual('( ) foo\n( ) bar\n') + it 'recognizes x as a selection if there is non-whitespace after x', -> + revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice('a\nx b\nc\nx \nd\n x e') + expect(revisedSelection).toEqual('( ) a\n(x) b\n( ) c\n( ) x \n( ) d\n(x) e\n') + it 'recognizes x as a selection if it is first non whitespace and has whitespace with other non-whitespace', -> + revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice(' x correct\n x \nex post facto\nb x c\nx c\nxxp') + expect(revisedSelection).toEqual('(x) correct\n( ) x \n( ) ex post facto\n( ) b x c\n(x) c\n( ) xxp\n') + it 'removes multiple newlines but not last one', -> + revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice('a\nx b\n\n\nc\n') + expect(revisedSelection).toEqual('( ) a\n(x) b\n( ) c\n') + + describe 'insertCheckboxChoice', -> + # Note, shares code with insertMultipleChoice. Therefore only doing smoke test. + it 'inserts the template if selection is empty', -> + revisedSelection = MarkdownEditingDescriptor.insertCheckboxChoice('') + expect(revisedSelection).toEqual(MarkdownEditingDescriptor.checkboxChoiceTemplate) + it 'wraps existing text', -> + revisedSelection = MarkdownEditingDescriptor.insertCheckboxChoice('foo\nbar') + expect(revisedSelection).toEqual('[ ] foo\n[ ] bar\n') + + describe 'insertStringInput', -> + it 'inserts the template if selection is empty', -> + revisedSelection = MarkdownEditingDescriptor.insertStringInput('') + expect(revisedSelection).toEqual(MarkdownEditingDescriptor.stringInputTemplate) + it 'wraps existing text', -> + revisedSelection = MarkdownEditingDescriptor.insertStringInput('my text') + expect(revisedSelection).toEqual('= my text') + + describe 'insertNumberInput', -> + it 'inserts the template if selection is empty', -> + revisedSelection = MarkdownEditingDescriptor.insertNumberInput('') + expect(revisedSelection).toEqual(MarkdownEditingDescriptor.numberInputTemplate) + it 'wraps existing text', -> + revisedSelection = MarkdownEditingDescriptor.insertNumberInput('my text') + expect(revisedSelection).toEqual('= my text') + + describe 'insertSelect', -> + it 'inserts the template if selection is empty', -> + revisedSelection = MarkdownEditingDescriptor.insertSelect('') + expect(revisedSelection).toEqual(MarkdownEditingDescriptor.selectTemplate) + it 'wraps existing text', -> + revisedSelection = MarkdownEditingDescriptor.insertSelect('my text') + expect(revisedSelection).toEqual('[[my text]]') + + describe 'markdownToXml', -> + it 'converts raw text to paragraph', -> + data = MarkdownEditingDescriptor.markdownToXml('foo') + expect(data).toEqual('\n

foo

\n
') + # test default templates + it 'converts numerical response to xml', -> + data = MarkdownEditingDescriptor.markdownToXml("""A numerical response problem accepts a line of text input from the student, and evaluates the input for correctness based on its numerical value. + + The answer is correct if it is within a specified numerical tolerance of the expected answer. + + Enter the numerical value of Pi: + = 3.14159 +- .02 + + Enter the approximate value of 502*9: + = 4518 +- 15% + + Enter the number of fingers on a human hand: + = 5 + + +
+ Explanation + + Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number known to extreme precision. It is value is approximately equal to 3.14. + + Although you can get an exact value by typing 502*9 into a calculator, the result will be close to 500*10, or 5,000. The grader accepts any response within 15% of the true value, 4518, so that you can use any estimation technique that you like. + + If you look at your hand, you can count that you have five fingers. +
+
+ """) + expect(data).toEqual(""" +

A numerical response problem accepts a line of text input from the student, and evaluates the input for correctness based on its numerical value.

+ +

The answer is correct if it is within a specified numerical tolerance of the expected answer.

+ +

Enter the numerical value of Pi:

+ + + + + +

Enter the approximate value of 502*9:

+ + + + + +

Enter the number of fingers on a human hand:

+ + + + + +
+

Explanation

+ +

Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number known to extreme precision. It is value is approximately equal to 3.14.

+ +

Although you can get an exact value by typing 502*9 into a calculator, the result will be close to 500*10, or 5,000. The grader accepts any response within 15% of the true value, 4518, so that you can use any estimation technique that you like.

+ +

If you look at your hand, you can count that you have five fingers.

+
+
+
""") + it 'converts multiple choice to xml', -> + data = MarkdownEditingDescriptor.markdownToXml("""A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets. + + One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make. + + What Apple device competed with the portable CD player? + ( ) The iPad + ( ) Napster + (x) The iPod + ( ) The vegetable peeler + ( ) Android + ( ) The Beatles + + +
+ Explanation + + The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks. +
+
+ """) + expect(data).toEqual(""" +

A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets.

+ +

One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make.

+ +

What Apple device competed with the portable CD player?

+ + + The iPad + Napster + The iPod + The vegetable peeler + Android + The Beatles + + + + +
+

Explanation

+ +

The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks.

+
+
+
""") + it 'converts OptionResponse to xml', -> + data = MarkdownEditingDescriptor.markdownToXml("""OptionResponse gives a limited set of options for students to respond with, and presents those options in a format that encourages them to search for a specific answer rather than being immediately presented with options from which to recognize the correct answer. + + The answer options and the identification of the correct answer is defined in the optioninput tag. + + Translation between Option Response and __________ is extremely straightforward: + [[(Multiple Choice), String Response, Numerical Response, External Response, Image Response]] + + +
+ Explanation + + Multiple Choice also allows students to select from a variety of pre-written responses, although the format makes it easier for students to read very long response options. Optionresponse also differs slightly because students are more likely to think of an answer and then search for it rather than relying purely on recognition to answer the question. +
+
+ """) + expect(data).toEqual(""" +

OptionResponse gives a limited set of options for students to respond with, and presents those options in a format that encourages them to search for a specific answer rather than being immediately presented with options from which to recognize the correct answer.

+ +

The answer options and the identification of the correct answer is defined in the optioninput tag.

+ +

Translation between Option Response and __________ is extremely straightforward:

+ + + + + + +
+

Explanation

+ +

Multiple Choice also allows students to select from a variety of pre-written responses, although the format makes it easier for students to read very long response options. Optionresponse also differs slightly because students are more likely to think of an answer and then search for it rather than relying purely on recognition to answer the question.

+
+
+
""") + it 'converts OptionResponse to xml', -> + data = MarkdownEditingDescriptor.markdownToXml("""A string response problem accepts a line of text input from the student, and evaluates the input for correctness based on an expected answer within each input box. + + The answer is correct if it matches every character of the expected answer. This can be a problem with international spelling, dates, or anything where the format of the answer is not clear. + + Which US state has Lansing as its capital? + = Michigan + + +
+ Explanation + + Lansing is the capital of Michigan, although it is not Michgan's largest city, or even the seat of the county in which it resides. + +
+
+ """) + expect(data).toEqual(""" +

A string response problem accepts a line of text input from the student, and evaluates the input for correctness based on an expected answer within each input box.

+ +

The answer is correct if it matches every character of the expected answer. This can be a problem with international spelling, dates, or anything where the format of the answer is not clear.

+ +

Which US state has Lansing as its capital?

+ + + + + +
+

Explanation

+ +

Lansing is the capital of Michigan, although it is not Michgan's largest city, or even the seat of the county in which it resides.

+ +
+
+
""") + # test oddities + it 'converts headers and oddities to xml', -> + data = MarkdownEditingDescriptor.markdownToXml("""Not a header + A header + ============== + + Multiple choice w/ parentheticals + ( ) option (with parens) + ( ) xd option (x) + ()) parentheses inside + () no space b4 close paren + + Choice checks + [ ] option1 [x] + [x] correct + [x] redundant + [(] distractor + [] no space + + {{video abcd1s}} + + Option with multiple correct ones + [[one option, (correct one), (should not be correct)]] + + Option with embedded parens + [[My (heart), another, (correct)]] + + What happens w/ empty correct options? + [[()]] + + No p tags in the below + + + But in this there should be +
+ Great ideas require offsetting. + + bad tests require drivel +
+ """) + expect(data).toEqual(""" +

Not a header

+

A header

+ +

Multiple choice w/ parentheticals

+ + + option (with parens) + xd option (x) + parentheses inside + no space b4 close paren + + + +

Choice checks

+ + + option1 [x] + correct + redundant + distractor + no space + + + +
""") + # failure tests diff --git a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee new file mode 100644 index 0000000000..02de93d3d2 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee @@ -0,0 +1,289 @@ +class @MarkdownEditingDescriptor extends XModule.Descriptor + @multipleChoiceTemplate : "( ) incorrect\n( ) incorrect\n(x) correct\n" + @checkboxChoiceTemplate: "[x] correct\n[ ] incorrect\n[x] correct\n" + @stringInputTemplate: "= answer\n" + @numberInputTemplate: "= answer +- x%\n" + @selectTemplate: "[[incorrect, (correct), incorrect]]\n" + + constructor: (element) -> + @element = element + + if $(".markdown-box", @element).length != 0 + @markdown_editor = CodeMirror.fromTextArea($(".markdown-box", element)[0], { + lineWrapping: true + mode: null + }) + @setCurrentEditor(@markdown_editor) + # Add listeners for toolbar buttons (only present for markdown editor) + @element.on('click', '.xml-tab', @onShowXMLButton) + @element.on('click', '.format-buttons a', @onToolbarButton) + @element.on('click', '.cheatsheet-toggle', @toggleCheatsheet) + # Hide the XML text area + $(@element.find('.xml-box')).hide() + else + @createXMLEditor() + + ### + Creates the XML Editor and sets it as the current editor. If text is passed in, + it will replace the text present in the HTML template. + + text: optional argument to override the text passed in via the HTML template + ### + createXMLEditor: (text) -> + @xml_editor = CodeMirror.fromTextArea($(".xml-box", @element)[0], { + mode: "xml" + lineNumbers: true + lineWrapping: true + }) + if text + @xml_editor.setValue(text) + @setCurrentEditor(@xml_editor) + + ### + User has clicked to show the XML editor. Before XML editor is swapped in, + the user will need to confirm the one-way conversion. + ### + onShowXMLButton: (e) => + e.preventDefault(); + if @confirmConversionToXml() + @createXMLEditor(MarkdownEditingDescriptor.markdownToXml(@markdown_editor.getValue())) + # Need to refresh to get line numbers to display properly (and put cursor position to 0) + @xml_editor.setCursor(0) + @xml_editor.refresh() + # Hide markdown-specific toolbar buttons + $(@element.find('.editor-bar')).hide() + + ### + Have the user confirm the one-way conversion to XML. + Returns true if the user clicked OK, else false. + ### + confirmConversionToXml: -> + # TODO: use something besides a JavaScript confirm dialog? + return confirm("If you convert to the XML source representation, which is used by the Advanced Editor, you cannot go back to using the Simple Editor.\n\nProceed with conversion to XML?") + + ### + Event listener for toolbar buttons (only possible when markdown editor is visible). + ### + onToolbarButton: (e) => + e.preventDefault(); + selection = @markdown_editor.getSelection() + revisedSelection = null + switch $(e.currentTarget).attr('class') + when "multiple-choice-button" then revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice(selection) + when "string-button" then revisedSelection = MarkdownEditingDescriptor.insertStringInput(selection) + when "number-button" then revisedSelection = MarkdownEditingDescriptor.insertNumberInput(selection) + when "checks-button" then revisedSelection = MarkdownEditingDescriptor.insertCheckboxChoice(selection) + when "dropdown-button" then revisedSelection = MarkdownEditingDescriptor.insertSelect(selection) + else # ignore click + + if revisedSelection != null + @markdown_editor.replaceSelection(revisedSelection) + @markdown_editor.focus() + + ### + Event listener for toggling cheatsheet (only possible when markdown editor is visible). + ### + toggleCheatsheet: (e) => + e.preventDefault(); + if !$(@markdown_editor.getWrapperElement()).find('.simple-editor-cheatsheet')[0] + @cheatsheet = $($('#simple-editor-cheatsheet').html()) + $(@markdown_editor.getWrapperElement()).append(@cheatsheet) + + setTimeout (=> @cheatsheet.toggleClass('shown')), 10 + + ### + Stores the current editor and hides the one that is not displayed. + ### + setCurrentEditor: (editor) -> + if @current_editor + $(@current_editor.getWrapperElement()).hide() + @current_editor = editor + $(@current_editor.getWrapperElement()).show() + $(@current_editor).focus(); + + ### + Called when save is called. Listeners are unregistered because editing the block again will + result in a new instance of the descriptor. Note that this is NOT the case for cancel-- + when cancel is called the instance of the descriptor is reused if edit is selected again. + ### + save: -> + @element.off('click', '.xml-tab', @changeEditor) + @element.off('click', '.format-buttons a', @onToolbarButton) + @element.off('click', '.cheatsheet-toggle', @toggleCheatsheet) + if @current_editor == @markdown_editor + { + data: MarkdownEditingDescriptor.markdownToXml(@markdown_editor.getValue()) + metadata: + markdown: @markdown_editor.getValue() + } + else + { + data: @xml_editor.getValue() + metadata: + markdown: null + } + + @insertMultipleChoice: (selectedText) -> + return MarkdownEditingDescriptor.insertGenericChoice(selectedText, '(', ')', MarkdownEditingDescriptor.multipleChoiceTemplate) + + @insertCheckboxChoice: (selectedText) -> + return MarkdownEditingDescriptor.insertGenericChoice(selectedText, '[', ']', MarkdownEditingDescriptor.checkboxChoiceTemplate) + + @insertGenericChoice: (selectedText, choiceStart, choiceEnd, template) -> + if selectedText.length > 0 + # Replace adjacent newlines with a single newline, strip any trailing newline + cleanSelectedText = selectedText.replace(/\n+/g, '\n').replace(/\n$/,'') + lines = cleanSelectedText.split('\n') + revisedLines = '' + for line in lines + revisedLines += choiceStart + # a stand alone x before other text implies that this option is "correct" + if /^\s*x\s+(\S)/i.test(line) + # Remove the x and any initial whitespace as long as there's more text on the line + line = line.replace(/^\s*x\s+(\S)/i, '$1') + revisedLines += 'x' + else + revisedLines += ' ' + revisedLines += choiceEnd + ' ' + line + '\n' + return revisedLines + else + return template + + @insertStringInput: (selectedText) -> + return MarkdownEditingDescriptor.insertGenericInput(selectedText, '= ', '', MarkdownEditingDescriptor.stringInputTemplate) + + @insertNumberInput: (selectedText) -> + return MarkdownEditingDescriptor.insertGenericInput(selectedText, '= ', '', MarkdownEditingDescriptor.numberInputTemplate) + + @insertSelect: (selectedText) -> + return MarkdownEditingDescriptor.insertGenericInput(selectedText, '[[', ']]', MarkdownEditingDescriptor.selectTemplate) + + @insertGenericInput: (selectedText, lineStart, lineEnd, template) -> + if selectedText.length > 0 + # TODO: should this insert a newline afterwards? + return lineStart + selectedText + lineEnd + else + return template + +# We may wish to add insertHeader and insertVideo. Here is Tom's code. +# function makeHeader() { +# var selection = simpleEditor.getSelection(); +# var revisedSelection = selection + '\n'; +# for(var i = 0; i < selection.length; i++) { +#revisedSelection += '='; +# } +# simpleEditor.replaceSelection(revisedSelection); +#} +# +#function makeVideo() { +#var selection = simpleEditor.getSelection(); +#simpleEditor.replaceSelection('{{video ' + selection + '}}'); +#} +# + @markdownToXml: (markdown)-> + toXml = `function(markdown) { + var xml = markdown; + + // replace headers + xml = xml.replace(/(^.*?$)(?=\n\=\=+$)/gm, '

$1

'); + xml = xml.replace(/\n^\=\=+$/gm, ''); + + // group multiple choice answers + xml = xml.replace(/(^\s*\(.?\).*?$\n*)+/gm, function(match, p) { + var groupString = '\n'; + groupString += ' \n'; + var options = match.split('\n'); + for(var i = 0; i < options.length; i++) { + if(options[i].length > 0) { + var value = options[i].split(/^\s*\(.?\)\s*/)[1]; + var correct = /^\s*\(x\)/i.test(options[i]); + groupString += ' ' + value + '\n'; + } + } + groupString += ' \n'; + groupString += '\n\n'; + return groupString; + }); + + // group check answers + xml = xml.replace(/(^\s*\[.?\].*?$\n*)+/gm, function(match, p) { + var groupString = '\n'; + groupString += ' \n'; + var options = match.split('\n'); + for(var i = 0; i < options.length; i++) { + if(options[i].length > 0) { + var value = options[i].split(/^\s*\[.?\]\s*/)[1]; + var correct = /^\s*\[x\]/i.test(options[i]); + groupString += ' ' + value + '\n'; + } + } + groupString += ' \n'; + groupString += '\n\n'; + return groupString; + }); + + // replace videos + xml = xml.replace(/\{\{video\s(.*?)\}\}/g, '