MDL-40988 quiz: ability to break quizzes into sections
authorM Kassaei <m.kassaei@open.ac.uk>
Thu, 2 Apr 2015 14:23:04 +0000 (15:23 +0100)
committerTim Hunt <T.J.Hunt@open.ac.uk>
Sun, 5 Apr 2015 13:30:53 +0000 (14:30 +0100)
The sections are created on the edit quiz page, and then appear in the
navigation panel when the quiz is being attempted to help students find
their way around.

The 'Shuffle questions' setting has been moved from being per-quiz
to being a per-section.

This commit is actually the joint work of Mahmoud Kassaei and Tim Hunt
from The Open University. We could only use one persons name for the
commit and this time Mahmoud gets the credit/blame.

60 files changed:
mod/quiz/attemptlib.php
mod/quiz/backup/moodle2/backup_quiz_stepslib.php
mod/quiz/backup/moodle2/restore_quiz_stepslib.php
mod/quiz/classes/output/edit_renderer.php
mod/quiz/classes/structure.php
mod/quiz/db/install.xml
mod/quiz/db/upgrade.php
mod/quiz/edit.php
mod/quiz/edit_rest.php
mod/quiz/lang/en/quiz.php
mod/quiz/lib.php
mod/quiz/locallib.php
mod/quiz/mod_form.php
mod/quiz/renderer.php
mod/quiz/repaginate.php
mod/quiz/settings.php
mod/quiz/styles.css
mod/quiz/tests/attempt_test.php
mod/quiz/tests/behat/attempt_basic.feature
mod/quiz/tests/behat/attempt_require_previous.feature
mod/quiz/tests/behat/backup.feature [new file with mode: 0644]
mod/quiz/tests/behat/behat_mod_quiz.php
mod/quiz/tests/behat/editing_click_delete_icon.feature [deleted file]
mod/quiz/tests/behat/editing_move_by_click.feature [moved from mod/quiz/tests/behat/editing_click_move_icon.feature with 85% similarity]
mod/quiz/tests/behat/editing_remove_question.feature [new file with mode: 0644]
mod/quiz/tests/behat/editing_require_previous.feature
mod/quiz/tests/behat/editing_section_headings.feature [new file with mode: 0644]
mod/quiz/tests/fixtures/moodle_28_quiz.mbz [new file with mode: 0644]
mod/quiz/tests/generator/lib.php
mod/quiz/tests/structure_test.php
mod/quiz/upgrade.txt
mod/quiz/version.php
mod/quiz/yui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop-debug.js
mod/quiz/yui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop-min.js
mod/quiz/yui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop.js
mod/quiz/yui/build/moodle-mod_quiz-modform/moodle-mod_quiz-modform-debug.js
mod/quiz/yui/build/moodle-mod_quiz-modform/moodle-mod_quiz-modform-min.js
mod/quiz/yui/build/moodle-mod_quiz-modform/moodle-mod_quiz-modform.js
mod/quiz/yui/build/moodle-mod_quiz-quizbase/moodle-mod_quiz-quizbase-debug.js
mod/quiz/yui/build/moodle-mod_quiz-quizbase/moodle-mod_quiz-quizbase-min.js
mod/quiz/yui/build/moodle-mod_quiz-quizbase/moodle-mod_quiz-quizbase.js
mod/quiz/yui/build/moodle-mod_quiz-toolboxes/moodle-mod_quiz-toolboxes-debug.js
mod/quiz/yui/build/moodle-mod_quiz-toolboxes/moodle-mod_quiz-toolboxes-min.js
mod/quiz/yui/build/moodle-mod_quiz-toolboxes/moodle-mod_quiz-toolboxes.js
mod/quiz/yui/build/moodle-mod_quiz-util-page/moodle-mod_quiz-util-page-debug.js
mod/quiz/yui/build/moodle-mod_quiz-util-page/moodle-mod_quiz-util-page-min.js
mod/quiz/yui/build/moodle-mod_quiz-util-page/moodle-mod_quiz-util-page.js
mod/quiz/yui/build/moodle-mod_quiz-util-slot/moodle-mod_quiz-util-slot-debug.js
mod/quiz/yui/build/moodle-mod_quiz-util-slot/moodle-mod_quiz-util-slot-min.js
mod/quiz/yui/build/moodle-mod_quiz-util-slot/moodle-mod_quiz-util-slot.js
mod/quiz/yui/src/dragdrop/js/dragdrop.js
mod/quiz/yui/src/dragdrop/js/resource.js
mod/quiz/yui/src/dragdrop/js/section.js
mod/quiz/yui/src/modform/js/modform.js
mod/quiz/yui/src/quizbase/js/quizbase.js
mod/quiz/yui/src/toolboxes/js/resource.js
mod/quiz/yui/src/toolboxes/js/section.js
mod/quiz/yui/src/toolboxes/js/toolbox.js
mod/quiz/yui/src/util/js/page.js
mod/quiz/yui/src/util/js/slot.js

index aeb882f..d9108ab 100644 (file)
@@ -60,15 +60,22 @@ class moodle_quiz_exception extends moodle_exception {
  * @since      Moodle 2.0
  */
 class quiz {
-    // Fields initialised in the constructor.
+    /** @var stdClass the course settings from the database. */
     protected $course;
+    /** @var stdClass the course_module settings from the database. */
     protected $cm;
+    /** @var stdClass the quiz settings from the database. */
     protected $quiz;
+    /** @var context the quiz context. */
     protected $context;
 
-    // Fields set later if that data is needed.
+    /** @var array of questions augmented with slot information. */
     protected $questions = null;
+    /** @var array of quiz_section rows. */
+    protected $sections = null;
+    /** @var quiz_access_manager the access manager for this quiz. */
     protected $accessmanager = null;
+    /** @var bool whether the current user has capability mod/quiz:preview. */
     protected $ispreviewuser = null;
 
     // Constructor =============================================================
@@ -262,6 +269,21 @@ class quiz {
     }
 
     /**
+     * Get all the sections in this quiz.
+     * @return array 0, 1, 2, ... => quiz_sections row from the database.
+     */
+    public function get_sections() {
+        global $DB;
+        if ($this->sections === null) {
+            $this->sections = array_values($DB->get_records('quiz_sections',
+                    array('quizid' => $this->get_quizid()), 'firstslot'));
+        }
+        return $this->sections;
+    }
+
+    /**
+     * Return quiz_access_manager and instance of the quiz_access_manager class
+     * for this quiz at this time.
      * @param int $timenow the current time as a unix timestamp.
      * @return quiz_access_manager and instance of the quiz_access_manager class
      *      for this quiz at this time.
@@ -455,9 +477,18 @@ class quiz_attempt {
     /** @var question_usage_by_activity the question usage for this quiz attempt. */
     protected $quba;
 
-    /** @var array of quiz_slots rows. */
+    /**
+     * @var array of slot information. These objects contain ->slot (int),
+     *      ->requireprevious (bool), ->questionids (int) the original question for random questions,
+     *      ->firstinsection (bool), ->section (stdClass from $this->sections).
+     *      This does not contain page - get that from {@link get_question_page()} -
+     *      or maxmark - get that from $this->quba.
+     */
     protected $slots;
 
+    /** @var array of quiz_sections rows, with a ->lastslot field added. */
+    protected $sections;
+
     /** @var array page no => array of slot numbers on the page in order. */
     protected $pagelayout;
 
@@ -494,8 +525,11 @@ class quiz_attempt {
         $this->quba = question_engine::load_questions_usage_by_activity($this->attempt->uniqueid);
         $this->slots = $DB->get_records('quiz_slots',
                 array('quizid' => $this->get_quizid()), 'slot',
-                'slot, page, requireprevious, questionid, maxmark');
+                'slot, requireprevious, questionid');
+        $this->sections = array_values($DB->get_records('quiz_sections',
+                array('quizid' => $this->get_quizid()), 'firstslot'));
 
+        $this->link_sections_and_slots();
         $this->determine_layout();
         $this->number_questions();
     }
@@ -546,6 +580,22 @@ class quiz_attempt {
         return quiz_attempt_state_name($state);
     }
 
+    /**
+     * Let each slot know which section it is part of.
+     */
+    protected function link_sections_and_slots() {
+        foreach ($this->sections as $i => $section) {
+            if (isset($this->sections[$i + 1])) {
+                $section->lastslot = $this->sections[$i + 1]->firstslot - 1;
+            } else {
+                $section->lastslot = count($this->slots);
+            }
+            for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) {
+                $this->slots[$slot]->section = $section;
+            }
+        }
+    }
+
     /**
      * Parse attempt->layout to populate the other arrays the represent the layout.
      */
@@ -561,6 +611,11 @@ class quiz_attempt {
         }
 
         // File the ids into the arrays.
+        // Tracking which is the first slot in each section in this attempt is
+        // trickier than you might guess, since the slots in this section
+        // may be shuffled, so $section->firstslot (the lowest numbered slot in
+        // the section) may not be the first one.
+        $unseensections = $this->sections;
         $this->pagelayout = array();
         foreach ($pagelayouts as $page => $pagelayout) {
             $pagelayout = trim($pagelayout, ',');
@@ -568,6 +623,15 @@ class quiz_attempt {
                 continue;
             }
             $this->pagelayout[$page] = explode(',', $pagelayout);
+            foreach ($this->pagelayout[$page] as $slot) {
+                $sectionkey = array_search($this->slots[$slot]->section, $unseensections);
+                if ($sectionkey !== false) {
+                    $this->slots[$slot]->firstinsection = true;
+                    unset($unseensections[$sectionkey]);
+                } else {
+                    $this->slots[$slot]->firstinsection = false;
+                }
+            }
         }
     }
 
@@ -1038,7 +1102,8 @@ class quiz_attempt {
      */
     public function is_blocked_by_previous_question($slot) {
         return $slot > 1 && isset($this->slots[$slot]) && $this->slots[$slot]->requireprevious &&
-                !$this->get_quiz()->shufflequestions &&
+                !$this->slots[$slot]->section->shufflequestions &&
+                !$this->slots[$slot - 1]->section->shufflequestions &&
                 $this->get_navigation_method() != QUIZ_NAVMETHOD_SEQ &&
                 !$this->get_question_state($slot - 1)->is_finished() &&
                 $this->quba->can_question_finish_during_attempt($slot - 1);
@@ -1081,6 +1146,20 @@ class quiz_attempt {
     }
 
     /**
+     * If the section heading, if any, that should come just before this slot.
+     * @param int $slot identifies a particular question in this attempt.
+     * @return string the required heading, or null if there is not one here.
+     */
+    public function get_heading_before_slot($slot) {
+        if ($this->slots[$slot]->firstinsection) {
+            return $this->slots[$slot]->section->heading;
+        } else {
+            return null;
+        }
+    }
+
+    /**
+     * Return the page of the quiz where this question appears.
      * @param int $slot the number used to identify this question within this attempt.
      * @return int the page of the quiz this question appears on.
      */
@@ -1809,7 +1888,7 @@ class quiz_attempt {
         global $DB;
         if ($this->attempt->timecheckstate !== $time) {
             $this->attempt->timecheckstate = $time;
-            $DB->set_field('quiz_attempts', 'timecheckstate', $time, array('id'=>$this->attempt->id));
+            $DB->set_field('quiz_attempts', 'timecheckstate', $time, array('id' => $this->attempt->id));
         }
     }
 
@@ -1965,6 +2044,27 @@ class quiz_attempt {
 }
 
 
+/**
+ * Represents a heading in the navigation panel.
+ *
+ * @copyright  2015 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 2.9
+ */
+class quiz_nav_section_heading implements renderable {
+    /** @var string the heading text. */
+    public $heading;
+
+    /**
+     * Constructor.
+     * @param string $heading the heading text
+     */
+    public function __construct($heading) {
+        $this->heading = $heading;
+    }
+}
+
+
 /**
  * Represents a single link in the navigation panel.
  *
@@ -2018,9 +2118,17 @@ abstract class quiz_nav_panel_base {
         $this->showall = $showall;
     }
 
+    /**
+     * Get the buttons and section headings to go in the quiz navigation block.
+     * @return renderable[] the buttons, possibly interleaved with section headings.
+     */
     public function get_question_buttons() {
         $buttons = array();
         foreach ($this->attemptobj->get_slots() as $slot) {
+            if ($heading = $this->attemptobj->get_heading_before_slot($slot)) {
+                $buttons[] = new quiz_nav_section_heading(format_string($heading));
+            }
+
             $qa = $this->attemptobj->get_question_attempt($slot);
             $showcorrectness = $this->options->correctness && $qa->has_marks();
 
index 79fe698..10ada06 100644 (file)
@@ -46,7 +46,7 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru
             'reviewattempt', 'reviewcorrectness', 'reviewmarks',
             'reviewspecificfeedback', 'reviewgeneralfeedback',
             'reviewrightanswer', 'reviewoverallfeedback',
-            'questionsperpage', 'navmethod', 'shufflequestions', 'shuffleanswers',
+            'questionsperpage', 'navmethod', 'shuffleanswers',
             'sumgrades', 'grade', 'timecreated',
             'timemodified', 'password', 'subnet', 'browsersecurity',
             'delay1', 'delay2', 'showuserpicture', 'showblocks', 'completionattemptsexhausted', 'completionpass'));
@@ -59,6 +59,11 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru
         $qinstance = new backup_nested_element('question_instance', array('id'), array(
             'slot', 'page', 'requireprevious', 'questionid', 'maxmark'));
 
+        $sections = new backup_nested_element('sections');
+
+        $section = new backup_nested_element('section', array('id'), array(
+            'firstslot', 'heading', 'shufflequestions'));
+
         $feedbacks = new backup_nested_element('feedbacks');
 
         $feedback = new backup_nested_element('feedback', array('id'), array(
@@ -92,6 +97,9 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru
         $quiz->add_child($qinstances);
         $qinstances->add_child($qinstance);
 
+        $quiz->add_child($sections);
+        $sections->add_child($section);
+
         $quiz->add_child($feedbacks);
         $feedbacks->add_child($feedback);
 
@@ -110,6 +118,9 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru
         $qinstance->set_source_table('quiz_slots',
                 array('quizid' => backup::VAR_PARENTID));
 
+        $section->set_source_table('quiz_sections',
+                array('quizid' => backup::VAR_PARENTID));
+
         $feedback->set_source_table('quiz_feedback',
                 array('quizid' => backup::VAR_PARENTID));
 
index de7f1eb..493dabd 100644 (file)
@@ -33,6 +33,19 @@ defined('MOODLE_INTERNAL') || die();
  */
 class restore_quiz_activity_structure_step extends restore_questions_activity_structure_step {
 
+    /**
+     * @var bool tracks whether the quiz contains at least one section. Before
+     * Moodle 2.9 quiz sections did not exist, so if the file being restored
+     * did not contain any, we need to create one in {@link after_execute()}.
+     */
+    protected $sectioncreated = false;
+
+    /**
+     * @var bool when restoring old quizzes (2.8 or before) this records the
+     * shufflequestionsoption quiz option which has moved to the quiz_sections table.
+     */
+    protected $legacyshufflequestionsoption = false;
+
     protected function define_structure() {
 
         $paths = array();
@@ -46,6 +59,7 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st
 
         $paths[] = new restore_path_element('quiz_question_instance',
                 '/activity/quiz/question_instances/question_instance');
+        $paths[] = new restore_path_element('quiz_section', '/activity/quiz/sections/section');
         $paths[] = new restore_path_element('quiz_feedback', '/activity/quiz/feedbacks/feedback');
         $paths[] = new restore_path_element('quiz_override', '/activity/quiz/overrides/override');
 
@@ -276,6 +290,15 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st
         $DB->insert_record('quiz_slots', $data);
     }
 
+    protected function process_quiz_section($data) {
+        global $DB;
+
+        $data = (object) $data;
+        $data->quizid = $this->get_new_parentid('quiz');
+        $newitemid = $DB->insert_record('quiz_sections', $data);
+        $this->sectioncreated = true;
+    }
+
     protected function process_quiz_feedback($data) {
         global $DB;
 
@@ -387,10 +410,19 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st
     }
 
     protected function after_execute() {
+        global $DB;
+
         parent::after_execute();
         // Add quiz related files, no need to match by itemname (just internally handled context).
         $this->add_related_files('mod_quiz', 'intro', null);
         // Add feedback related files, matching by itemname = 'quiz_feedback'.
         $this->add_related_files('mod_quiz', 'feedback', 'quiz_feedback');
+
+        if (!$this->sectioncreated) {
+            $DB->insert_record('quiz_sections', array(
+                    'quizid' => $this->get_new_parentid('quiz'),
+                    'firstslot' => 1, 'heading' => '',
+                    'shufflequestions' => $this->legacyshufflequestionsoption));
+        }
     }
 }
index 60167f8..af696b3 100644 (file)
@@ -66,17 +66,17 @@ class edit_renderer extends \plugin_renderer_base {
         // Show the questions organised into sections and pages.
         $output .= $this->start_section_list();
 
-        $sections = $structure->get_quiz_sections();
-        $lastsection = end($sections);
-        foreach ($sections as $section) {
-            $output .= $this->start_section($section);
+        foreach ($structure->get_sections() as $section) {
+            $output .= $this->start_section($structure, $section);
             $output .= $this->questions_in_section($structure, $section, $contexts, $pagevars, $pageurl);
-            if ($section === $lastsection) {
+
+            if ($structure->is_last_section($section)) {
                 $output .= \html_writer::start_div('last-add-menu');
                 $output .= html_writer::tag('span', $this->add_menu_actions($structure, 0,
                         $pageurl, $contexts, $pagevars), array('class' => 'add-menu-outer'));
                 $output .= \html_writer::end_div();
             }
+
             $output .= $this->end_section();
         }
 
@@ -271,58 +271,112 @@ class edit_renderer extends \plugin_renderer_base {
     /**
      * Display the start of a section, before the questions.
      *
+     * @param structure $structure the structure of the quiz being edited.
      * @param \stdClass $section The quiz_section entry from DB
      * @return string HTML to output.
      */
-    protected function start_section($section) {
+    protected function start_section($structure, $section) {
 
         $output = '';
+
         $sectionstyle = '';
+        if ($structure->is_only_one_slot_in_section($section)) {
+            $sectionstyle = ' only-has-one-slot';
+        }
 
         $output .= html_writer::start_tag('li', array('id' => 'section-'.$section->id,
             'class' => 'section main clearfix'.$sectionstyle, 'role' => 'region',
             'aria-label' => $section->heading));
 
-        $leftcontent = $this->section_left_content($section);
-        $output .= html_writer::div($leftcontent, 'left side');
-
-        $rightcontent = $this->section_right_content($section);
-        $output .= html_writer::div($rightcontent, 'right side');
         $output .= html_writer::start_div('content');
 
+        $output .= html_writer::start_div('section-heading');
+
+        $headingtext = $this->heading(html_writer::span(
+                html_writer::span($section->heading, 'instancesection'), 'sectioninstance'), 3);
+
+        if (!$structure->can_be_edited()) {
+            $editsectionheadingicon = '';
+        } else {
+            $editsectionheadingicon = html_writer::link(new \moodle_url('#'),
+                $this->pix_icon('t/editstring', get_string('sectionheadingedit', 'quiz', $section->heading),
+                        'moodle', array('class' => 'editicon visibleifjs')),
+                        array('class' => 'editing_section', 'data-action' => 'edit_section_title'));
+        }
+        $output .= html_writer::div($headingtext . $editsectionheadingicon, 'instancesectioncontainer');
+
+        if (!$structure->is_first_section($section) && $structure->can_be_edited()) {
+            $output .= $this->section_remove_icon($section);
+        }
+        $output .= $this->section_shuffle_questions($structure, $section);
+
+        $output .= html_writer::end_div($output, 'section-heading');
+
         return $output;
     }
 
     /**
-     * Display the end of a section, after the questions.
+     * Display a checkbox for shuffling question within a section.
      *
+     * @param structure $structure object containing the structure of the quiz.
+     * @param \stdClass $section data from the quiz_section table.
      * @return string HTML to output.
      */
-    protected function end_section() {
-        $output = html_writer::end_tag('div');
-        $output .= html_writer::end_tag('li');
+    public function section_shuffle_questions(structure $structure, $section) {
+        $checkboxattributes = array(
+            'type' => 'checkbox',
+            'id' => 'shuffle-' . $section->id,
+            'value' => 1,
+            'data-action' => 'shuffle_questions',
+            'class' => 'cm-edit-action',
+        );
 
-        return $output;
+        if (!$structure->can_be_edited()) {
+            $checkboxattributes['disabled'] = 'disabled';
+        }
+        if ($section->shufflequestions) {
+            $checkboxattributes['checked'] = 'checked';
+        }
+
+        if ($structure->is_first_section($section)) {
+            $help = $this->help_icon('shufflequestions', 'quiz');
+        } else {
+            $help = '';
+        }
+
+        $progressspan = html_writer::span('', 'shuffle-progress');
+        $checkbox = html_writer::empty_tag('input', $checkboxattributes);
+        $label = html_writer::label(get_string('shufflequestions', 'quiz') . ' ' . $help,
+                $checkboxattributes['id'], false);
+        return html_writer::span($progressspan . $checkbox . $label,
+                'instanceshufflequestions', array('data-action' => 'shuffle_questions'));
     }
 
     /**
-     * Generate the content to be displayed on the left part of a section.
+     * Display the end of a section, after the questions.
      *
-     * @param \stdClass $section The quiz_section entry from DB
      * @return string HTML to output.
      */
-    protected function section_left_content($section) {
-        return $this->output->spacer();
+    protected function end_section() {
+        $output = html_writer::end_tag('div');
+        $output .= html_writer::end_tag('li');
+
+        return $output;
     }
 
     /**
-     * Generate the content to displayed on the right part of a section.
+     * Render an icon to remove a section from the quiz.
      *
-     * @param \stdClass $section The quiz_section entry from DB
+     * @param object $section the section to be removed.
      * @return string HTML to output.
      */
-    protected function section_right_content($section) {
-        return $this->output->spacer();
+    public function section_remove_icon($section) {
+        $title = get_string('sectionheadingremove', 'quiz', $section->heading);
+        $url = new \moodle_url('/mod/quiz/edit.php',
+                array('sesskey' => sesskey(), 'removesection' => '1', 'sectionid' => $section->id));
+        $image = $this->pix_icon('t/delete', $title);
+        return $this->action_link($url, $image, null, array(
+                'class' => 'cm-edit-action editing_delete', 'data-action' => 'deletesection'));
     }
 
     /**
@@ -364,10 +418,10 @@ class edit_renderer extends \plugin_renderer_base {
 
         // Page split/join icon.
         $joinhtml = '';
-        if ($structure->can_be_edited() && !$structure->is_last_slot_in_quiz($slot)) {
+        if ($structure->can_be_edited() && !$structure->is_last_slot_in_quiz($slot) &&
+                                            !$structure->is_last_slot_in_section($slot)) {
             $joinhtml = $this->page_split_join_button($structure, $slot);
         }
-
         // Question HTML.
         $questionhtml = $this->question($structure, $slot, $pageurl);
         $qtype = $structure->get_question_type_for_slot($slot);
@@ -395,9 +449,8 @@ class edit_renderer extends \plugin_renderer_base {
 
         $pagenumber = $structure->get_page_number_for_slot($slot);
 
-        // Put page in a span for easier styling.
-        $page = html_writer::tag('span', get_string('page') . ' ' . $pagenumber,
-                array('class' => 'text'));
+        // Put page in a heading for accessibility and styling.
+        $page = $this->heading(get_string('page') . ' ' . $pagenumber, 4);
 
         if ($structure->is_first_slot_on_page($slot)) {
             // Add the add-menu at the page level.
@@ -432,7 +485,7 @@ class edit_renderer extends \plugin_renderer_base {
             return '';
         }
         $menu = new \action_menu();
-        $menu->set_alignment(\action_menu::TR, \action_menu::BR);
+        $menu->set_alignment(\action_menu::TR, \action_menu::TR);
         $menu->set_constraint('.mod-quiz-edit-content');
         $trigger = html_writer::tag('span', get_string('add', 'quiz'), array('class' => 'add-menu'));
         $menu->set_menu_trigger($trigger);
@@ -472,13 +525,26 @@ class edit_renderer extends \plugin_renderer_base {
         $questioncategoryid = question_get_category_id_from_pagevars($pagevars);
         static $str;
         if (!isset($str)) {
-            $str = get_strings(array('addaquestion', 'addarandomquestion',
+            $str = get_strings(array('addasection', 'addaquestion', 'addarandomquestion',
                     'addarandomselectedquestion', 'questionbank'), 'quiz');
         }
 
         // Get section, page, slotnumber and maxmark.
         $actions = array();
 
+        // Add a new section to the add_menu if possible. This is always added to the HTML
+        // then hidden with CSS when no needed, so that as things are re-ordered, etc. with
+        // Ajax it can be relevaled again when necessary.
+        $returnurl = new \moodle_url($pageurl, array('addonpage' => $page));
+        $params = array('returnurl' => $returnurl, 'cmid' => $structure->get_cmid(),
+                'addonpage' => $page, 'appendqnumstring' => 'addasection', 'addsection' => '1');
+
+        $actions['addasection'] = new \action_menu_link_secondary(
+                new \moodle_url($pageurl, $params),
+                new \pix_icon('t/add', $str->addasection, 'moodle', array('class' => 'iconsmall', 'title' => '')),
+                $str->addasection, array('class' => 'cm-edit-action addasection', 'data-action' => 'addasection')
+        );
+
         // Add a new question to the quiz.
         $returnurl = new \moodle_url($pageurl, array('addonpage' => $page));
         $params = array('returnurl' => $returnurl->out_as_local_url(false),
@@ -981,10 +1047,13 @@ class edit_renderer extends \plugin_renderer_base {
 
         $this->page->requires->strings_for_js(array(
                 'addpagebreak',
+                'confirmremovesectionheading',
                 'confirmremovequestion',
                 'dragtoafter',
                 'dragtostart',
                 'numquestionsx',
+                'sectionheadingedit',
+                'sectionheadingremove',
                 'removepagebreak',
                 'questiondependencyadd',
                 'questiondependencyfree',
index 72600f1..477ffa2 100644 (file)
@@ -173,8 +173,11 @@ class structure {
      * @return bool can this question finish naturally during the attempt?
      */
     public function can_finish_during_the_attempt($slotnumber) {
-        if ($this->quizobj->get_quiz()->shufflequestions ||
-                $this->quizobj->get_navigation_method() == QUIZ_NAVMETHOD_SEQ) {
+        if ($this->quizobj->get_navigation_method() == QUIZ_NAVMETHOD_SEQ) {
+            return false;
+        }
+
+        if ($this->slotsinorder[$slotnumber]->section->shufflequestions) {
             return false;
         }
 
@@ -248,22 +251,13 @@ class structure {
         return $this->quizobj->get_quiz();
     }
 
-    /**
-     * Whether the question in the quiz are shuffled for each attempt.
-     * @return bool true if the questions are shuffled.
-     */
-    public function is_shuffled() {
-        return $this->quizobj->get_quiz()->shufflequestions;
-    }
-
     /**
      * Quizzes can only be repaginated if they have not been attempted, the
      * questions are not shuffled, and there are two or more questions.
      * @return bool whether this quiz can be repaginated.
      */
     public function can_be_repaginated() {
-        return !$this->is_shuffled() && $this->can_be_edited()
-                && $this->get_question_count() >= 2;
+        return $this->can_be_edited() && $this->get_question_count() >= 2;
     }
 
     /**
@@ -333,6 +327,25 @@ class structure {
         return $this->slotsinorder[$slotnumber]->page != $this->slotsinorder[$slotnumber + 1]->page;
     }
 
+    /**
+     * Is this slot the last one in its section?
+     * @param int $slotnumber the index of the slot in question.
+     * @return bool whether this slot the last one on its section.
+     */
+    public function is_last_slot_in_section($slotnumber) {
+        return $slotnumber == $this->slotsinorder[$slotnumber]->section->lastslot;
+    }
+
+    /**
+     * Is this slot the only one in its section?
+     * @param int $slotnumber the index of the slot in question.
+     * @return bool whether this slot the only one on its section.
+     */
+    public function is_only_slot_in_section($slotnumber) {
+        return $this->slotsinorder[$slotnumber]->section->firstslot ==
+                $this->slotsinorder[$slotnumber]->section->lastslot;
+    }
+
     /**
      * Is this slot the last one in the quiz?
      * @param int $slotnumber the index of the slot in question.
@@ -343,6 +356,33 @@ class structure {
         return $slotnumber == key($this->slotsinorder);
     }
 
+    /**
+     * Is this the first section in the quiz?
+     * @param \stdClass $section the quiz_sections row.
+     * @return bool whether this is first section in the quiz.
+     */
+    public function is_first_section($section) {
+        return $section->firstslot == 1;
+    }
+
+    /**
+     * Is this the last section in the quiz?
+     * @param \stdClass $section the quiz_sections row.
+     * @return bool whether this is first section in the quiz.
+     */
+    public function is_last_section($section) {
+        return $section->id == end($this->sections)->id;
+    }
+
+    /**
+     * Does this section only contain one slot?
+     * @param \stdClass $section the quiz_sections row.
+     * @return bool whether this section contains only one slot.
+     */
+    public function is_only_one_slot_in_section($section) {
+        return $section->firstslot == $section->lastslot;
+    }
+
     /**
      * Get the final slot in the quiz.
      * @return \stdClass the quiz_slots for for the final slot in the quiz.
@@ -363,6 +403,36 @@ class structure {
         return $this->slots[$slotid];
     }
 
+    /**
+     * Check whether adding a section heading is possible
+     * @param int $pagenumber the number of the page.
+     * @return boolean
+     */
+    public function can_add_section_heading($pagenumber) {
+        // There is a default section heading on this page,
+        // do not show adding new section heading in the Add menu.
+        if ($pagenumber == 1) {
+            return false;
+        }
+        // Get an array of firstslots.
+        $firstslots = array();
+        foreach ($this->sections as $section) {
+            $firstslots[] = $section->firstslot;
+        }
+        foreach ($this->slotsinorder as $slot) {
+            if ($slot->page == $pagenumber) {
+                if (in_array($slot->slot, $firstslots)) {
+                    return false;
+                }
+            }
+        }
+        // Do not show the adding section heading on the last add menu.
+        if ($pagenumber == 0) {
+            return false;
+        }
+        return true;
+    }
+
     /**
      * Get all the slots in a section of the quiz.
      * @param int $sectionid the section id.
@@ -371,7 +441,7 @@ class structure {
     public function get_slots_in_section($sectionid) {
         $slots = array();
         foreach ($this->slotsinorder as $slot) {
-            if ($slot->sectionid == $sectionid) {
+            if ($slot->section->id == $sectionid) {
                 $slots[] = $slot->slot;
             }
         }
@@ -382,10 +452,18 @@ class structure {
      * Get all the sections of the quiz.
      * @return \stdClass[] the sections in this quiz.
      */
-    public function get_quiz_sections() {
+    public function get_sections() {
         return $this->sections;
     }
 
+    /**
+     * Get a particular section by id.
+     * @return \stdClass the section.
+     */
+    public function get_section_by_id($sectionid) {
+        return $this->sections[$sectionid];
+    }
+
     /**
      * Get the overall quiz grade formatted for display.
      * @return string the maximum grade for this quiz.
@@ -432,14 +510,6 @@ class structure {
             $warnings[] = get_string('cannoteditafterattempts', 'quiz', $reviewlink);
         }
 
-        if ($this->is_shuffled()) {
-            $updateurl = new \moodle_url('/course/mod.php',
-                    array('return' => 'true', 'update' => $this->quizobj->get_cmid(), 'sesskey' => sesskey()));
-            $updatelink = '<a href="'.$updateurl->out().'">' . get_string('updatethis', '',
-                    get_string('modulename', 'quiz')) . '</a>';
-            $warnings[] = get_string('shufflequestionsselected', 'quiz', $updatelink);
-        }
-
         return $warnings;
     }
 
@@ -526,15 +596,9 @@ class structure {
             $this->slotsinorder[$slot->slot] = $slot;
         }
 
-        $section = new \stdClass();
-        $section->id = 1;
-        $section->quizid = $quiz->id;
-        $section->heading = '';
-        $section->firstslot = 1;
-        $section->shuffle = false;
-        $this->sections = array(1 => $section);
-
-        $this->populate_slots_with_sectionids();
+        // Get quiz sections in ascending order of the firstslot.
+        $this->sections = $DB->get_records('quiz_sections', array('quizid' => $quiz->id), 'firstslot ASC');
+        $this->populate_slots_with_sections();
         $this->populate_question_numbers();
     }
 
@@ -570,19 +634,17 @@ class structure {
     /**
      * Fill in the section ids for each slot.
      */
-    public function populate_slots_with_sectionids() {
-        $nextsection = reset($this->sections);
-        foreach ($this->slotsinorder as $slot) {
-            if ($slot->slot == $nextsection->firstslot) {
-                $currentsectionid = $nextsection->id;
-                $nextsection = next($this->sections);
-                if (!$nextsection) {
-                    $nextsection = new \stdClass();
-                    $nextsection->firstslot = -1;
-                }
+    public function populate_slots_with_sections() {
+        $sections = array_values($this->sections);
+        foreach ($sections as $i => $section) {
+            if (isset($sections[$i + 1])) {
+                $section->lastslot = $sections[$i + 1]->firstslot - 1;
+            } else {
+                $section->lastslot = count($this->slotsinorder);
+            }
+            for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) {
+                $this->slotsinorder[$slot]->section = $section;
             }
-
-            $slot->sectionid = $currentsectionid;
         }
     }
 
@@ -608,40 +670,99 @@ class structure {
      * should be discarded if you want to manipulate the structure further.
      *
      * @param int $idmove id of slot to be moved
-     * @param int $idbefore id of slot to come before slot being moved
+     * @param int $idmoveafter id of slot to come before slot being moved
      * @param int $page new page number of slot being moved
+     * @param bool $insection if the question is moving to a place where a new
+     *      section starts, include it in that section.
      * @return void
      */
-    public function move_slot($idmove, $idbefore, $page) {
+    public function move_slot($idmove, $idmoveafter, $page) {
         global $DB;
 
         $this->check_can_be_edited();
 
         $movingslot = $this->slots[$idmove];
         if (empty($movingslot)) {
-            throw new moodle_exception('Bad slot ID ' . $idmove);
+            throw new \moodle_exception('Bad slot ID ' . $idmove);
         }
         $movingslotnumber = (int) $movingslot->slot;
 
         // Empty target slot means move slot to first.
-        if (empty($idbefore)) {
-            $targetslotnumber = 0;
+        if (empty($idmoveafter)) {
+            $moveafterslotnumber = 0;
         } else {
-            $targetslotnumber = (int) $this->slots[$idbefore]->slot;
+            $moveafterslotnumber = (int) $this->slots[$idmoveafter]->slot;
+        }
+
+        // Check the target page number is OK.
+        if ($page == 0) {
+            $page = 1;
+        }
+        if (($moveafterslotnumber > 0 && $page < $this->get_page_number_for_slot($moveafterslotnumber)) ||
+                $page < 1) {
+            throw new \coding_exception('The target page number is too small.');
+        } else if (!$this->is_last_slot_in_quiz($moveafterslotnumber) &&
+                $page > $this->get_page_number_for_slot($moveafterslotnumber + 1)) {
+            throw new \coding_exception('The target page number is too large.');
+        }
+
+        // If the action came in as moving a slot to itself, normalise this to
+        // moving the slot to after the previosu slot.
+        if ($moveafterslotnumber == $movingslotnumber) {
+            $moveafterslotnumber = $moveafterslotnumber - 1;
         }
 
         // Work out how things are being moved.
         $slotreorder = array();
-        if ($targetslotnumber > $movingslotnumber) {
-            $slotreorder[$movingslotnumber] = $targetslotnumber;
-            for ($i = $movingslotnumber; $i < $targetslotnumber; $i++) {
+        if ($moveafterslotnumber > $movingslotnumber) {
+            // Moving down.
+            $slotreorder[$movingslotnumber] = $moveafterslotnumber;
+            for ($i = $movingslotnumber; $i < $moveafterslotnumber; $i++) {
                 $slotreorder[$i + 1] = $i;
             }
-        } else if ($targetslotnumber < $movingslotnumber - 1) {
-            $slotreorder[$movingslotnumber] = $targetslotnumber + 1;
-            for ($i = $targetslotnumber + 1; $i < $movingslotnumber; $i++) {
+
+            $headingmoveafter = $movingslotnumber;
+            if ($this->is_last_slot_in_quiz($moveafterslotnumber) ||
+                    $page == $this->get_page_number_for_slot($moveafterslotnumber + 1)) {
+                // We are moving to the start of a section, so that heading needs
+                // to be included in the ones that move up.
+                $headingmovebefore = $moveafterslotnumber + 1;
+            } else {
+                $headingmovebefore = $moveafterslotnumber;
+            }
+            $headingmovedirection = -1;
+
+        } else if ($moveafterslotnumber < $movingslotnumber - 1) {
+            // Moving up.
+            $slotreorder[$movingslotnumber] = $moveafterslotnumber + 1;
+            for ($i = $moveafterslotnumber + 1; $i < $movingslotnumber; $i++) {
                 $slotreorder[$i] = $i + 1;
             }
+
+            if ($page == $this->get_page_number_for_slot($moveafterslotnumber + 1)) {
+                // Moving to the start of a section, don't move that section.
+                $headingmoveafter = $moveafterslotnumber + 1;
+            } else {
+                // Moving tot the end of the previous section, so move the heading down too.
+                $headingmoveafter = $moveafterslotnumber;
+            }
+            $headingmovebefore = $movingslotnumber + 1;
+            $headingmovedirection = 1;
+        } else {
+            // Staying in the same place, but possibly changing page/section.
+            if ($page > $movingslot->page) {
+                $headingmoveafter = $movingslotnumber;
+                $headingmovebefore = $movingslotnumber + 2;
+                $headingmovedirection = -1;
+            } else {
+                $headingmoveafter = $movingslotnumber - 1;
+                $headingmovebefore = $movingslotnumber + 1;
+                $headingmovedirection = 1;
+            }
+        }
+
+        if ($this->is_only_slot_in_section($movingslotnumber)) {
+            throw new \coding_exception('You cannot remove the last slot in a section.');
         }
 
         $trans = $DB->start_delegated_transaction();
@@ -653,14 +774,22 @@ class structure {
         }
 
         // Page has changed. Record it.
-        if (!$page) {
-            $page = 1;
-        }
         if ($movingslot->page != $page) {
             $DB->set_field('quiz_slots', 'page', $page,
                     array('id' => $movingslot->id));
         }
 
+        // Update section fist slots.
+        $DB->execute("
+                UPDATE {quiz_sections}
+                   SET firstslot = firstslot + ?
+                 WHERE quizid = ?
+                   AND firstslot > ?
+                   AND firstslot < ?
+                ", array($headingmovedirection, $this->get_quizid(),
+                        $headingmoveafter, $headingmovebefore));
+
+        // If any pages are now empty, remove them.
         $emptypages = $DB->get_fieldset_sql("
                 SELECT DISTINCT page - 1
                   FROM {quiz_slots} slot
@@ -684,15 +813,14 @@ class structure {
 
     /**
      * Refresh page numbering of quiz slots.
-     * @param \stdClass $quiz the quiz object.
      * @param \stdClass[] $slots (optional) array of slot objects.
      * @return \stdClass[] array of slot objects.
      */
-    public function refresh_page_numbers($quiz, $slots=array()) {
+    public function refresh_page_numbers($slots = array()) {
         global $DB;
         // Get slots ordered by page then slot.
         if (!count($slots)) {
-            $slots = $DB->get_records('quiz_slots', array('quizid' => $quiz->id), 'slot, page');
+            $slots = $DB->get_records('quiz_slots', array('quizid' => $this->get_quizid()), 'slot, page');
         }
 
         // Loop slots. Start Page number at 1 and increment as required.
@@ -718,11 +846,11 @@ class structure {
      * @param \stdClass $quiz the quiz object.
      * @return \stdClass[] array of slot objects.
      */
-    public function refresh_page_numbers_and_update_db($quiz) {
+    public function refresh_page_numbers_and_update_db() {
         global $DB;
         $this->check_can_be_edited();
 
-        $slots = $this->refresh_page_numbers($quiz);
+        $slots = $this->refresh_page_numbers();
 
         // Record new page order.
         foreach ($slots as $slot) {
@@ -735,25 +863,28 @@ class structure {
 
     /**
      * Remove a slot from a quiz
-     * @param \stdClass $quiz the quiz object.
      * @param int $slotnumber The number of the slot to be deleted.
      */
-    public function remove_slot($quiz, $slotnumber) {
+    public function remove_slot($slotnumber) {
         global $DB;
 
         $this->check_can_be_edited();
 
-        $slot = $DB->get_record('quiz_slots', array('quizid' => $quiz->id, 'slot' => $slotnumber));
-        $maxslot = $DB->get_field_sql('SELECT MAX(slot) FROM {quiz_slots} WHERE quizid = ?', array($quiz->id));
+        if ($this->is_only_slot_in_section($slotnumber)) {
+            throw new \coding_exception('You cannot remove the last slot in a section.');
+        }
+
+        $slot = $DB->get_record('quiz_slots', array('quizid' => $this->get_quizid(), 'slot' => $slotnumber));
         if (!$slot) {
             return;
         }
+        $maxslot = $DB->get_field_sql('SELECT MAX(slot) FROM {quiz_slots} WHERE quizid = ?', array($this->get_quizid()));
 
         $trans = $DB->start_delegated_transaction();
         $DB->delete_records('quiz_slots', array('id' => $slot->id));
         for ($i = $slot->slot + 1; $i <= $maxslot; $i++) {
             $DB->set_field('quiz_slots', 'slot', $i - 1,
-                    array('quizid' => $quiz->id, 'slot' => $i));
+                    array('quizid' => $this->get_quizid(), 'slot' => $i));
         }
 
         $qtype = $DB->get_field('question', 'qtype', array('id' => $slot->questionid));
@@ -762,9 +893,15 @@ class structure {
             question_delete_question($slot->questionid);
         }
 
+        $DB->execute("
+                UPDATE {quiz_sections}
+                   SET firstslot = firstslot - 1
+                 WHERE quizid = ?
+                   AND firstslot > ?
+                ", array($this->get_quizid(), $slotnumber));
         unset($this->questions[$slot->questionid]);
 
-        $this->refresh_page_numbers_and_update_db($quiz);
+        $this->refresh_page_numbers_and_update_db();
 
         $trans->allow_commit();
     }
@@ -814,21 +951,73 @@ class structure {
      * Saves changes to the slot page relationship in the quiz_slots table and reorders the paging
      * for subsequent slots.
      *
-     * @param \stdClass $quiz the quiz object.
      * @param int $slotid id of slot.
      * @param int $type repaginate::LINK or repaginate::UNLINK.
      * @return \stdClass[] array of slot objects.
      */
-    public function update_page_break($quiz, $slotid, $type) {
+    public function update_page_break($slotid, $type) {
         global $DB;
 
         $this->check_can_be_edited();
 
-        $quizslots = $DB->get_records('quiz_slots', array('quizid' => $quiz->id), 'slot');
-        $repaginate = new \mod_quiz\repaginate($quiz->id, $quizslots);
+        $quizslots = $DB->get_records('quiz_slots', array('quizid' => $this->get_quizid()), 'slot');
+        $repaginate = new \mod_quiz\repaginate($this->get_quizid(), $quizslots);
         $repaginate->repaginate_slots($quizslots[$slotid]->slot, $type);
-        $slots = $this->refresh_page_numbers_and_update_db($quiz);
+        $slots = $this->refresh_page_numbers_and_update_db();
 
         return $slots;
     }
+
+    /**
+     * Add a section heading on a given page and return the sectionid
+     * @param int $pagenumber the number of the page where the section heading begins.
+     * @param string $heading the heading to add.
+     */
+    public function add_section_heading($pagenumber, $heading = 'Section heading ...') {
+        global $DB;
+        $section = new \stdClass();
+        $section->heading = $heading;
+        $section->quizid = $this->get_quizid();
+        $slotsonpage = $DB->get_records('quiz_slots', array('quizid' => $this->get_quizid(), 'page' => $pagenumber), 'slot DESC');
+        $section->firstslot = end($slotsonpage)->slot;
+        $section->shufflequestions = 0;
+        return $DB->insert_record('quiz_sections', $section);
+    }
+
+    /**
+     * Change the heading for a section.
+     * @param int $id the id of the section to change.
+     * @param string $newheading the new heading for this section.
+     */
+    public function set_section_heading($id, $newheading) {
+        global $DB;
+        $section = $DB->get_record('quiz_sections', array('id' => $id), '*', MUST_EXIST);
+        $section->heading = $newheading;
+        $DB->update_record('quiz_sections', $section);
+    }
+
+    /**
+     * Change the shuffle setting for a section.
+     * @param int $id the id of the section to change.
+     * @param bool $shuffle whether this section should be shuffled.
+     */
+    public function set_section_shuffle($id, $shuffle) {
+        global $DB;
+        $section = $DB->get_record('quiz_sections', array('id' => $id), '*', MUST_EXIST);
+        $section->shufflequestions = $shuffle;
+        $DB->update_record('quiz_sections', $section);
+    }
+
+    /**
+     * Remove the section heading with the given id
+     * @param int $sectionid the section to remove.
+     */
+    public function remove_section_heading($sectionid) {
+        global $DB;
+        $section = $DB->get_record('quiz_sections', array('id' => $sectionid), '*', MUST_EXIST);
+        if ($section->firstslot == 1) {
+            throw new \coding_exception('Cannot remove the first section in a quiz.');
+        }
+        $DB->delete_records('quiz_sections', array('id' => $sectionid));
+    }
 }
index 21bd119..816cf26 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="mod/quiz/db" VERSION="20121006" COMMENT="XMLDB file for Moodle mod/quiz"
+<XMLDB PATH="mod/quiz/db" VERSION="20150323" COMMENT="XMLDB file for Moodle mod/quiz"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
 >
@@ -32,7 +32,6 @@
         <FIELD NAME="reviewoverallfeedback" TYPE="int" LENGTH="6" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Whether users are allowed to review their quiz attempts at various times. A bit field, like reviewattempt."/>
         <FIELD NAME="questionsperpage" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="How often to insert a page break when editing the quiz, or when shuffling the question order."/>
         <FIELD NAME="navmethod" TYPE="char" LENGTH="16" NOTNULL="true" DEFAULT="free" SEQUENCE="false" COMMENT="Any constraints on how the user is allowed to navigate around the quiz. Currently recognised values are 'free' and 'seq'."/>
-        <FIELD NAME="shufflequestions" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Whether the question order should be shuffled for each attempt."/>
         <FIELD NAME="shuffleanswers" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Whether the parts of the question should be shuffled, in those question types that support it."/>
         <FIELD NAME="sumgrades" TYPE="number" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" DECIMALS="5" COMMENT="The total of all the question instance maxmarks."/>
         <FIELD NAME="grade" TYPE="number" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" DECIMALS="5" COMMENT="The total that the quiz overall grade is scaled to be out of."/>
         <INDEX NAME="quizid-slot" UNIQUE="true" FIELDS="quizid, slot"/>
       </INDEXES>
     </TABLE>
+    <TABLE NAME="quiz_sections" COMMENT="Stores sections of a quiz with section name (heading), from slot-number N and whether the question order should be shuffled.">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="quizid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Foreign key references quiz.id."/>
+        <FIELD NAME="firstslot" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Number of the first slot in the section. The section runs from here to the start of the next section, or the end of the quiz."/>
+        <FIELD NAME="heading" TYPE="char" LENGTH="1333" NOTNULL="false" SEQUENCE="false" COMMENT="The text of the heading. May be an empty string/null. Multilang format."/>
+        <FIELD NAME="shufflequestions" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Whether the question order within this section should be shuffled for each attempt."/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="quizid" TYPE="foreign" FIELDS="quizid" REFTABLE="quiz" REFFIELDS="id"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="quizid-firstslot" UNIQUE="true" FIELDS="quizid, firstslot"/>
+      </INDEXES>
+    </TABLE>
     <TABLE NAME="quiz_feedback" COMMENT="Feedback given to students based on which grade band their overall score lies.">
       <FIELDS>
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
index c9b5035..0d6757f 100644 (file)
@@ -228,7 +228,7 @@ function xmldb_quiz_upgrade($oldversion) {
     }
 
     if ($oldversion < 2012040200) {
-        // Define index userid to be dropped form quiz_attempts
+        // Define index userid to be dropped form quiz_attempts.
         $table = new xmldb_table('quiz_attempts');
         $index = new xmldb_index('userid', XMLDB_INDEX_NOTUNIQUE, array('userid'));
 
@@ -327,7 +327,7 @@ function xmldb_quiz_upgrade($oldversion) {
     }
 
     // Moodle v2.3.0 release upgrade line
-    // Put any upgrade step following this
+    // Put any upgrade step following this.
 
     if ($oldversion < 2012061702) {
 
@@ -361,29 +361,29 @@ function xmldb_quiz_upgrade($oldversion) {
 
     if ($oldversion < 2012100801) {
 
-        // Define field timecheckstate to be added to quiz_attempts
+        // Define field timecheckstate to be added to quiz_attempts.
         $table = new xmldb_table('quiz_attempts');
         $field = new xmldb_field('timecheckstate', XMLDB_TYPE_INTEGER, '10', null, null, null, '0', 'timemodified');
 
-        // Conditionally launch add field timecheckstate
+        // Conditionally launch add field timecheckstate.
         if (!$dbman->field_exists($table, $field)) {
             $dbman->add_field($table, $field);
         }
 
-        // Define index state-timecheckstate (not unique) to be added to quiz_attempts
+        // Define index state-timecheckstate (not unique) to be added to quiz_attempts.
         $table = new xmldb_table('quiz_attempts');
         $index = new xmldb_index('state-timecheckstate', XMLDB_INDEX_NOTUNIQUE, array('state', 'timecheckstate'));
 
-        // Conditionally launch add index state-timecheckstate
+        // Conditionally launch add index state-timecheckstate.
         if (!$dbman->index_exists($table, $index)) {
             $dbman->add_index($table, $index);
         }
 
-        // Overdue cron no longer needs these
+        // Overdue cron no longer needs these.
         unset_config('overduelastrun', 'quiz');
         unset_config('overduedoneto', 'quiz');
 
-        // Update timecheckstate on all open attempts
+        // Update timecheckstate on all open attempts.
         require_once($CFG->dirroot . '/mod/quiz/locallib.php');
         quiz_update_open_attempts(array());
 
@@ -392,7 +392,7 @@ function xmldb_quiz_upgrade($oldversion) {
     }
 
     // Moodle v2.4.0 release upgrade line
-    // Put any upgrade step following this
+    // Put any upgrade step following this.
 
     if ($oldversion < 2013031900) {
         // Quiz manual grading UI should be controlled by mod/quiz:grade, not :viewreports.
@@ -405,7 +405,6 @@ function xmldb_quiz_upgrade($oldversion) {
     // Moodle v2.5.0 release upgrade line.
     // Put any upgrade step following this.
 
-
     // Moodle v2.6.0 release upgrade line.
     // Put any upgrade step following this.
 
@@ -835,5 +834,72 @@ function xmldb_quiz_upgrade($oldversion) {
         upgrade_mod_savepoint(true, 2015030900, 'quiz');
     }
 
+    if ($oldversion < 2015032300) {
+
+        // Define table quiz_sections to be created.
+        $table = new xmldb_table('quiz_sections');
+
+        // Adding fields to table quiz_sections.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('quizid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('firstslot', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('heading', XMLDB_TYPE_CHAR, '1333', null, null, null, null);
+        $table->add_field('shufflequestions', XMLDB_TYPE_INTEGER, '4', null, XMLDB_NOTNULL, null, '0');
+
+        // Adding keys to table quiz_sections.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
+        $table->add_key('quizid', XMLDB_KEY_FOREIGN, array('quizid'), 'quiz', array('id'));
+
+        // Adding indexes to table quiz_sections.
+        $table->add_index('quizid-firstslot', XMLDB_INDEX_UNIQUE, array('quizid', 'firstslot'));
+
+        // Conditionally launch create table for quiz_sections.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Quiz savepoint reached.
+        upgrade_mod_savepoint(true, 2015032300, 'quiz');
+    }
+
+    if ($oldversion < 2015032301) {
+
+        // Create a section for each quiz.
+        $DB->execute("
+                INSERT INTO {quiz_sections}
+                            (quizid, firstslot, heading, shufflequestions)
+                     SELECT  id,     1,         ?,       shufflequestions
+                       FROM {quiz}
+                ", array(''));
+
+        // Quiz savepoint reached.
+        upgrade_mod_savepoint(true, 2015032301, 'quiz');
+    }
+
+    if ($oldversion < 2015032302) {
+
+        // Define field shufflequestions to be dropped from quiz.
+        $table = new xmldb_table('quiz');
+        $field = new xmldb_field('shufflequestions');
+
+        // Conditionally launch drop field shufflequestions.
+        if ($dbman->field_exists($table, $field)) {
+            $dbman->drop_field($table, $field);
+        }
+
+        // Quiz savepoint reached.
+        upgrade_mod_savepoint(true, 2015032302, 'quiz');
+    }
+
+    if ($oldversion < 2015032303) {
+
+        // Drop corresponding admin settings.
+        unset_config('shufflequestions', 'quiz');
+        unset_config('shufflequestions_adv', 'quiz');
+
+        // Quiz savepoint reached.
+        upgrade_mod_savepoint(true, 2015032303, 'quiz');
+    }
+
     return true;
 }
index 79ec061..7e3495b 100644 (file)
@@ -134,6 +134,17 @@ if (optional_param('add', false, PARAM_BOOL) && confirm_sesskey()) {
     redirect($afteractionurl);
 }
 
+if (optional_param('addsection', false, PARAM_BOOL)) {
+    $structure->check_can_be_edited();
+
+    // Add a section to the quiz.
+    if ($addonpage = optional_param('addonpage', 0, PARAM_INT)) {
+        $structure->add_section_heading($addonpage);
+    }
+    quiz_delete_previews($quiz);
+    redirect($afteractionurl);
+}
+
 if ((optional_param('addrandom', false, PARAM_BOOL)) && confirm_sesskey()) {
     // Add random questions to the quiz.
     $structure->check_can_be_edited();
index 8ffe131..a1a1585 100644 (file)
@@ -44,6 +44,8 @@ $sequence   = optional_param('sequence', '', PARAM_SEQUENCE);
 $visible    = optional_param('visible', 0, PARAM_INT);
 $pageaction = optional_param('action', '', PARAM_ALPHA); // Used to simulate a DELETE command.
 $maxmark    = optional_param('maxmark', '', PARAM_FLOAT);
+$newheading = optional_param('newheading', '', PARAM_TEXT);
+$shuffle    = optional_param('newshuffle', 0, PARAM_INT);
 $page       = optional_param('page', '', PARAM_INT);
 $PAGE->set_url('/mod/quiz/edit-rest.php',
         array('quizid' => $quizid, 'class' => $class));
@@ -71,15 +73,39 @@ if ($pageaction == 'DELETE') {
 switch($requestmethod) {
     case 'POST':
     case 'GET': // For debugging.
-
         switch ($class) {
             case 'section':
+                $table = 'quiz_sections';
+                switch ($field) {
+                    case 'getsectiontitle':
+                        require_capability('mod/quiz:manage', $modcontext);
+                        $section = $structure->get_section_by_id($id);
+                        echo json_encode(array('instancesection' => $section->heading));
+                        break;
+                    case 'updatesectiontitle':
+                        require_capability('mod/quiz:manage', $modcontext);
+                        $structure->set_section_heading($id, $newheading);
+                        echo json_encode(array('instancesection' => format_string($newheading)));
+                        break;
+                    case 'updateshufflequestions':
+                        require_capability('mod/quiz:manage', $modcontext);
+                        $structure->set_section_shuffle($id, $shuffle);
+                        echo json_encode(array('instanceshuffle' => $section->shufflequestions));
+                        break;
+                }
                 break;
 
             case 'resource':
                 switch ($field) {
                     case 'move':
                         require_capability('mod/quiz:manage', $modcontext);
+                        if (!$previousid) {
+                            $section = $structure->get_section_by_id($sectionid);
+                            if ($section->firstslot > 1) {
+                                $previousid = $structure->get_slot_id_for_slot($section->firstslot - 1);
+                                $page = $structure->get_page_number_for_slot($section->firstslot);
+                            }
+                        }
                         $structure->move_slot($id, $previousid, $page);
                         quiz_delete_previews($quiz);
                         echo json_encode(array('visible' => true));
@@ -109,7 +135,7 @@ switch($requestmethod) {
 
                     case 'updatepagebreak':
                         require_capability('mod/quiz:manage', $modcontext);
-                        $slots = $structure->update_page_break($quiz, $id, $value);
+                        $slots = $structure->update_page_break($id, $value);
                         $json = array();
                         foreach ($slots as $slot) {
                             $json[$slot->slot] = array('id' => $slot->id, 'slot' => $slot->slot,
@@ -132,12 +158,18 @@ switch($requestmethod) {
 
     case 'DELETE':
         switch ($class) {
+            case 'section':
+                require_capability('mod/quiz:manage', $modcontext);
+                $structure->remove_section_heading($id);
+                echo json_encode(array('deleted' => true));
+                break;
+
             case 'resource':
                 require_capability('mod/quiz:manage', $modcontext);
                 if (!$slot = $DB->get_record('quiz_slots', array('quizid' => $quiz->id, 'id' => $id))) {
                     throw new moodle_exception('AJAX commands.php: Bad slot ID '.$id);
                 }
-                $structure->remove_slot($quiz, $slot->slot);
+                $structure->remove_slot($slot->slot);
                 quiz_delete_previews($quiz);
                 quiz_update_sumgrades($quiz);
                 echo json_encode(array('newsummarks' => quiz_format_grade($quiz, $quiz->sumgrades),
index 60575e3..df0c5ef 100644 (file)
@@ -29,6 +29,7 @@ $string['adaptive'] = 'Adaptive mode';
 $string['adaptive_help'] = 'If enabled, multiple responses to a question are allowed within the same attempt at the quiz. So for example if a response is marked as incorrect, the student will be allowed to try again immediately. However, depending on the "Apply penalties" setting, a penalty will usually be subtracted for each wrong attempt.';
 $string['add'] = 'Add';
 $string['addaquestion'] = 'a new question';
+$string['addasection'] = 'a new section heading';
 $string['addarandomquestion'] = 'a random question';
 $string['addarandomquestion_help'] = 'When a random question is added, it results in a randomly-chosen question from the category being inserted into the quiz. This means that different students are likely to get a different selection of questions, and when a quiz allows multiple attempts then each attempt is likely to contain a new selection of questions.';
 $string['addarandomselectedquestion'] = 'Add a random selected question ...';
@@ -191,13 +192,13 @@ $string['configrequiresubnet'] = 'Students can only attempt the quiz from these
 $string['configreviewoptions'] = 'These options control what information users can see when they review a quiz attempt or look at the quiz reports.';
 $string['configshowblocks'] = 'Show blocks during quiz attempts.';
 $string['configshowuserpicture'] = 'Show the user\'s picture on screen during attempts.';
-$string['configshufflequestions'] = 'If you enable this option, then the order of questions in  the quiz will be randomly shuffled each time a student attempts the quiz.';
 $string['configshufflewithin'] = 'If you enable this option, then the parts making up the individual questions will be randomly shuffled each time a student starts an attempt at this quiz, provided the option is also enabled in the question settings.';
 $string['configtimelimit'] = 'Default time limit for quizzes in minutes. 0 mean no time limit.';
 $string['configtimelimitsec'] = 'Default time limit for quizzes in seconds. 0 mean no time limit.';
 $string['configurerandomquestion'] = 'Configure question';
 $string['confirmclose'] = 'Once you submit, you will no longer be able to change your answers for this attempt.';
 $string['confirmremovequestion'] = 'Are you sure you want to remove this {$a} question?';
+$string['confirmremovesectionheading'] = 'Are you sure you want to remove the \'{$a}\' section heading?';
 $string['confirmserverdelete'] = 'Are you sure you want to remove the server <b>{$a}</b> from the list?';
 $string['confirmstartattemptlimit'] = 'Number of attempts allowed:  {$a}. You are about to start a new attempt.  Do you wish to proceed?';
 $string['confirmstartattempttimelimit'] = 'This quiz has a time limit and is limited to {$a} attempt(s). You are about to start a new attempt.  Do you wish to proceed?';
@@ -800,6 +801,8 @@ $string['savingnewgradeforquestion'] = 'Saving new grade for question id {$a}.';
 $string['savingnewmaximumgrade'] = 'Saving new maximum grade.';
 $string['score'] = 'Raw score';
 $string['scores'] = 'Scores';
+$string['sectionheadingedit'] = 'Edit heading \'{$a}\'';
+$string['sectionheadingremove'] = 'Remove heading \'{$a}\'';
 $string['seequestions'] = '(See questions)';
 $string['select'] = 'Select';
 $string['selectall'] = 'Select all';
@@ -837,8 +840,10 @@ $string['showuserpicture_help'] = 'If enabled, the student\'s name and picture w
 $string['shuffle'] = 'Shuffle';
 $string['shuffleanswers'] = 'Shuffle answers';
 $string['shuffledrandomly'] = 'Shuffled randomly';
-$string['shufflequestions'] = 'Shuffle questions';
-$string['shufflequestionsselected'] = 'Shuffle questions has been set, so some actions relating to pages are not available. To change the shuffle option, {$a}.';
+$string['shufflequestions'] = 'Shuffle';
+$string['shufflequestions_help'] = 'If you turn this on, then every time the quiz is attempted, the order of the questions in this section will be shuffled into a different random order.
+
+This can make it harder for students to share answers, but it also makes it harder for students discuss a particular question with you.';
 $string['shufflewithin'] = 'Shuffle within questions';
 $string['shufflewithin_help'] = 'If enabled, the parts making up each question will be randomly shuffled each time a student attempts the quiz, provided the option is also enabled in the question settings. This setting only applies to questions that have multiple parts, such as multiple choice or matching questions.';
 $string['singleanswer'] = 'Choose one answer.';
index de427cf..cd021e3 100644 (file)
@@ -88,6 +88,10 @@ function quiz_add_instance($quiz) {
     // Try to store it in the database.
     $quiz->id = $DB->insert_record('quiz', $quiz);
 
+    // Create the first section for this quiz.
+    $DB->insert_record('quiz_sections', array('quizid' => $quiz->id,
+            'firstslot' => 1, 'heading' => '', 'shufflequestions' => 0));
+
     // Do the processing required after an add or an update.
     quiz_after_add_or_update($quiz);
 
@@ -143,7 +147,7 @@ function quiz_update_instance($quiz, $mform) {
     quiz_delete_previews($quiz);
 
     // Repaginate, if asked to.
-    if (!$quiz->shufflequestions && !empty($quiz->repaginatenow)) {
+    if (!empty($quiz->repaginatenow)) {
         quiz_repaginate_questions($quiz->id, $quiz->questionsperpage);
     }
 
@@ -175,6 +179,7 @@ function quiz_delete_instance($id) {
 
     // We need to do this before we try and delete randoms, otherwise they would still be 'in use'.
     $DB->delete_records('quiz_slots', array('quizid' => $quiz->id));
+    $DB->delete_records('quiz_sections', array('quizid' => $quiz->id));
 
     foreach ($questionids as $questionid) {
         question_delete_question($questionid);
index 1cba6a7..85da20a 100644 (file)
@@ -172,9 +172,11 @@ function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $time
     $slot = 0;
     $questions = array();
     $maxmark = array();
+    $page = array();
     foreach ($quizobj->get_questions() as $questiondata) {
         $slot += 1;
         $maxmark[$slot] = $questiondata->maxmark;
+        $page[$slot] = $questiondata->page;
         if ($questiondata->qtype == 'random') {
             $randomfound = true;
             continue;
@@ -251,33 +253,47 @@ function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $time
     $quba->start_all_questions($variantstrategy, $timenow);
 
     // Work out the attempt layout.
+    $sections = $quizobj->get_sections();
+    foreach ($sections as $i => $section) {
+        if (isset($sections[$i + 1])) {
+            $sections[$i]->lastslot = $sections[$i + 1]->firstslot - 1;
+        } else {
+            $sections[$i]->lastslot = count($questions);
+        }
+    }
+
     $layout = array();
-    if ($quizobj->get_quiz()->shufflequestions) {
-        $slots = $quba->get_slots();
-        shuffle($slots);
-
-        $questionsonthispage = 0;
-        foreach ($slots as $slot) {
-            if ($questionsonthispage && $questionsonthispage == $quizobj->get_quiz()->questionsperpage) {
-                $layout[] = 0;
-                $questionsonthispage = 0;
+    foreach ($sections as $section) {
+        if ($section->shufflequestions) {
+            $questionsinthissection = array();
+            for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) {
+                $questionsinthissection[] = $slot;
+            }
+            shuffle($questionsinthissection);
+            $questionsonthispage = 0;
+            foreach ($questionsinthissection as $slot) {
+                if ($questionsonthispage && $questionsonthispage == $quizobj->get_quiz()->questionsperpage) {
+                    $layout[] = 0;
+                    $questionsonthispage = 0;
+                }
+                $layout[] = $slot;
+                $questionsonthispage += 1;
             }
-            $layout[] = $slot;
-            $questionsonthispage += 1;
-        }
 
-    } else {
-        $currentpage = null;
-        foreach ($quizobj->get_questions() as $slot) {
-            if ($currentpage !== null && $slot->page != $currentpage) {
-                $layout[] = 0;
+        } else {
+            $currentpage = $page[$section->firstslot];
+            for ($slot = $section->firstslot; $slot <= $section->lastslot; $slot += 1) {
+                if ($currentpage !== null && $page[$slot] != $currentpage) {
+                    $layout[] = 0;
+                }
+                $layout[] = $slot;
+                $currentpage = $page[$slot];
             }
-            $layout[] = $slot->slot;
-            $currentpage = $slot->page;
         }
-    }
 
-    $layout[] = 0;
+        // Each section ends with a page break.
+        $layout[] = 0;
+    }
     $attempt->layout = implode(',', $layout);
 
     return $attempt;
@@ -468,13 +484,22 @@ function quiz_repaginate_questions($quizid, $slotsperpage) {
     global $DB;
     $trans = $DB->start_delegated_transaction();
 
+    $sections = $DB->get_records('quiz_sections', array('quizid' => $quizid), 'firstslot ASC');
+    $firstslots = array();
+    foreach ($sections as $section) {
+        if ((int)$section->firstslot === 1) {
+            continue;
+        }
+        $firstslots[] = $section->firstslot;
+    }
+
     $slots = $DB->get_records('quiz_slots', array('quizid' => $quizid),
             'slot');
-
     $currentpage = 1;
     $slotsonthispage = 0;
     foreach ($slots as $slot) {
-        if ($slotsonthispage && $slotsonthispage == $slotsperpage) {
+        if (($firstslots && in_array($slot->slot, $firstslots)) ||
+            ($slotsonthispage && $slotsonthispage == $slotsperpage)) {
             $currentpage += 1;
             $slotsonthispage = 0;
         }
@@ -2007,6 +2032,13 @@ function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null)
         $slot->slot = $lastslotbefore + 1;
         $slot->page = min($page, $maxpage + 1);
 
+        $DB->execute("
+                UPDATE {quiz_sections}
+                   SET firstslot = firstslot + 1
+                 WHERE quizid = ?
+                   AND firstslot > ?
+                ", array($quiz->id, max($lastslotbefore, 1)));
+
     } else {
         $lastslot = end($slots);
         if ($lastslot) {
index 2cbd1f1..6360cdf 100644 (file)
@@ -151,16 +151,6 @@ class mod_quiz_mod_form extends moodleform_mod {
         // -------------------------------------------------------------------------------
         $mform->addElement('header', 'layouthdr', get_string('layout', 'quiz'));
 
-        // Shuffle questions.
-        $shuffleoptions = array(
-            0 => get_string('asshownoneditscreen', 'quiz'),
-            1 => get_string('shuffledrandomly', 'quiz')
-        );
-        $mform->addElement('select', 'shufflequestions', get_string('questionorder', 'quiz'),
-                $shuffleoptions, array('id' => 'id_shufflequestions'));
-        $mform->setAdvanced('shufflequestions', $quizconfig->shufflequestions_adv);
-        $mform->setDefault('shufflequestions', $quizconfig->shufflequestions);
-
         $pagegroup = array();
         $pagegroup[] = $mform->createElement('select', 'questionsperpage',
                 get_string('newpage', 'quiz'), quiz_questions_per_page_options(), array('id' => 'id_questionsperpage'));
@@ -169,7 +159,6 @@ class mod_quiz_mod_form extends moodleform_mod {
         if (!empty($this->_cm)) {
             $pagegroup[] = $mform->createElement('checkbox', 'repaginatenow', '',
                     get_string('repaginatenow', 'quiz'), array('id' => 'id_repaginatenow'));
-            $mform->disabledIf('repaginatenow', 'shufflequestions', 'eq', 1);
         }
 
         $mform->addGroup($pagegroup, 'questionsperpagegrp',
index 89832c5..01dfa5d 100644 (file)
@@ -313,7 +313,7 @@ class mod_quiz_renderer extends plugin_renderer_base {
         $output .= $panel->render_before_button_bits($this);
 
         $bcc = $panel->get_button_container_class();
-        $output .= html_writer::start_tag('div', array('class' => "qn_buttons $bcc"));
+        $output .= html_writer::start_tag('div', array('class' => "qn_buttons clearfix $bcc"));
         foreach ($panel->get_question_buttons() as $button) {
             $output .= $this->render($button);
         }
@@ -329,9 +329,10 @@ class mod_quiz_renderer extends plugin_renderer_base {
     }
 
     /**
-     * Returns the quizzes navigation button
+     * Display a quiz navigation button.
      *
      * @param quiz_nav_question_button $button
+     * @return string HTML fragment.
      */
     protected function render_quiz_nav_question_button(quiz_nav_question_button $button) {
         $classes = array('qnbutton', $button->stateclass, $button->navmethod);
@@ -373,6 +374,16 @@ class mod_quiz_renderer extends plugin_renderer_base {
         }
     }
 
+    /**
+     * Display a quiz navigation heading.
+     *
+     * @param quiz_nav_section_heading $heading the heading.
+     * @return string HTML fragment.
+     */
+    protected function render_quiz_nav_section_heading(quiz_nav_section_heading $heading) {
+        return $this->heading($heading->heading, 3, 'mod_quiz-section-heading');
+    }
+
     /**
      * outputs the link the other attempts.
      *
@@ -607,14 +618,27 @@ class mod_quiz_renderer extends plugin_renderer_base {
             $table->align[] = 'left';
             $table->size[] = '';
         }
+        $tablewidth = count($table->align);
         $table->data = array();
 
         // Get the summary info for each question.
         $slots = $attemptobj->get_slots();
         foreach ($slots as $slot) {
+            // Add a section headings if we need one here.
+            $heading = $attemptobj->get_heading_before_slot($slot);
+            if ($heading) {
+                $cell = new html_table_cell(format_string($heading));
+                $cell->header = true;
+                $cell->colspan = $tablewidth;
+                $table->data[] = array($cell);
+            }
+
+            // Don't display information items.
             if (!$attemptobj->is_real_question($slot)) {
                 continue;
             }
+
+            // Real question, show it.
             $flag = '';
             if ($attemptobj->is_question_flagged($slot)) {
                 $flag = html_writer::empty_tag('img', array('src' => $this->pix_url('i/flagged'),
index 58aef9a..08ce25f 100644 (file)
@@ -45,6 +45,6 @@ $repage = new \mod_quiz\repaginate($quizid);
 $repage->repaginate_slots($slotnumber, $repagtype);
 
 $structure = $quizobj->get_structure();
-$slots = $structure->refresh_page_numbers_and_update_db($structure->get_quiz());
+$slots = $structure->refresh_page_numbers_and_update_db();
 
 redirect(new moodle_url('edit.php', array('cmid' => $quizobj->get_cmid())));
index 6360217..d84652a 100644 (file)
@@ -97,11 +97,6 @@ if ($ADMIN->fulltree) {
     $quizsettings->add(new admin_setting_configtext('quiz/maximumgrade',
             get_string('maximumgrade'), get_string('configmaximumgrade', 'quiz'), 10, PARAM_INT));
 
-    // Shuffle questions.
-    $quizsettings->add(new admin_setting_configcheckbox_with_advanced('quiz/shufflequestions',
-            get_string('shufflequestions', 'quiz'), get_string('configshufflequestions', 'quiz'),
-            array('value' => 0, 'adv' => false)));
-
     // Questions per page.
     $perpage = array();
     $perpage[0] = get_string('never');
index b490d49..dd904a7 100644 (file)
@@ -96,6 +96,15 @@ body.jsenabled .questionflagcheckbox {
     vertical-align: bottom;
 }
 
+.path-mod-quiz #mod_quiz_navblock h3.mod_quiz-section-heading {
+    padding: 0.7em 0 0;
+    margin: 0;
+    clear: both;
+}
+.path-mod-quiz #mod_quiz_navblock h3.mod_quiz-section-heading:first-child {
+    padding-top: 0;
+}
+
 .path-mod-quiz .qnbutton {
     display: block;
     position: relative;
@@ -541,6 +550,11 @@ table.quizreviewsummary td.cell {
     position: relative;
 }
 
+#page-mod-quiz-edit ul.section li.pagenumber:first-child .add-menu-outer ul.menu li:first-child,
+#page-mod-quiz-edit .last-add-menu .add-menu-outer ul.menu li:first-child {
+    display: none;
+}
+
 #page-mod-quiz-edit .last-add-menu {
     position: relative;
     height: 1.5em;
@@ -548,7 +562,6 @@ table.quizreviewsummary td.cell {
 }
 #page-mod-quiz-edit .add-menu-outer {
     position: absolute;
-    top: 0;
     right: 0;
 }
 #page-mod-quiz-edit.dir-rtl .add-menu-outer {
@@ -564,55 +577,62 @@ table.quizreviewsummary td.cell {
     display: inline-block;
 }
 
+#page-mod-quiz-edit .section-heading {
+    font-size: 24px;
+    margin-left: 20px;
+    margin-bottom: 0;
+    height: 40px;
+}
+#page-mod-quiz-edit .section-heading .instancesectioncontainer {
+    display: inline;
+}
+#page-mod-quiz-edit .section-heading .instancesectioncontainer h3 {
+    display: inline;
+    color: #999;
+}
+#page-mod-quiz-edit .section-heading .editing_section,
+#page-mod-quiz-edit .section-heading .editing_delete {
+    margin-left: 10px;
+}
+#page-mod-quiz-edit .section-heading .sectioninstance {
+    position: relative;
+}
+#page-mod-quiz-edit .section-heading form {
+    display: inline;
+    position: relative;
+    top: 3px;
+    left: -7px;
+}
+#page-mod-quiz-edit .section-heading form input {
+    font-size: 24px;
+    font-weight: bold;
+    width: 50%;
+}
+
+#page-mod-quiz-edit .section-heading .instanceshufflequestions {
+    float: right;
+    margin: 0.3em 20px 0 0;
+}
+
 #page-mod-quiz-edit ul.section {
     margin: 0;
     padding: 0 20px;
 }
+#page-mod-quiz-edit ul.slots {
+    margin: 0;
+}
 #page-mod-quiz-edit ul.slots li.section {
     border: 0;
 }
 #page-mod-quiz-edit ul.slots li.section .content {
     background-color:#FAFAFA;
-    padding: 0;
-}
-#page-mod-quiz-edit ul.slots li.section .content h3 {
-    margin: 0;
-    color: #777;
-    font-weight: normal;
-}
-#page-mod-quiz-edit ul.slots li.section .left {
-    padding: 4px 0;
-}
-#page-mod-quiz-edit ul.slots li.section .right {
-    padding: 4px 0;
-}
-#page-mod-quiz-edit ul.slots {
-    margin: 0;
+    padding: 1px 0;
 }
 #page-mod-quiz-edit ul.slots li.section {
     list-style: none;
     margin: 0;
     padding: 0;
 }
-#page-mod-quiz-edit ul.slots li.section .left {
-    float: left;
-}
-#page-mod-quiz-edit ul.slots li.section .right {
-    float: right;
-}
-#page-mod-quiz-edit ul.slots li.section .left,
-#page-mod-quiz-edit ul.slots li.section .right {
-    width: 40px;
-    text-align: center;
-    padding: 6px 0;
-}
-#page-mod-quiz-edit ul.slots li.section .right img.icon {
-    padding: 0 0 4px 0;
-}
-#page-mod-quiz-edit ul.slots li.section .left .section-handle img.icon {
-    padding: 0;
-    vertical-align: baseline;
-}
 #page-mod-quiz-edit ul.slots li.section li.activity {
     background: #E6E6E6;
     margin: 3px 0;
@@ -621,6 +641,11 @@ table.quizreviewsummary td.cell {
 #page-mod-quiz-edit ul.slots li.section li.activity.page {
     background: transparent;
 }
+#page-mod-quiz-edit ul.slots li.section li.activity.page h4 {
+    display: inline;
+    font-weight: normal;
+    font-size: 1em;
+}
 
 #page-mod-quiz-edit ul.slots li.section li.activity .instancemaxmarkcontainer {
     background: white;
@@ -693,6 +718,10 @@ table.quizreviewsummary td.cell {
 #page-mod-quiz-edit ul.slots li.section li.activity .editing_maxmark {
     margin: 0 2px;
 }
+#page-mod-quiz-edit ul.slots li.section.only-has-one-slot li.activity .editing_move,
+#page-mod-quiz-edit ul.slots li.section.only-has-one-slot li.activity .editing_delete {
+    visibility: hidden;
+}
 
 #page-mod-quiz-edit ul.slots li.section li.activity .question_dependency_wrapper {
     position: absolute;
index 2098618..e4ffd37 100644 (file)
@@ -58,7 +58,25 @@ class mod_quiz_attempt_testable extends quiz_attempt {
 
         $attemptobj = new self($attempt, $quiz, $cm, $course, false);
 
+        $attemptobj->slots = array();
+        foreach (explode(',', $layout) as $slot) {
+            if ($slot == 0) {
+                continue;
+            }
+            $attemptobj->slots[$slot] = new stdClass();
+            $attemptobj->slots[$slot]->slot = $slot;
+            $attemptobj->slots[$slot]->requireprevious = 0;
+            $attemptobj->slots[$slot]->questionid = 0;
+        }
+
+        $attemptobj->sections = array();
+        $attemptobj->sections[0] = new stdClass();
+        $attemptobj->sections[0]->heading = '';
+        $attemptobj->sections[0]->firstslot = 1;
+        $attemptobj->sections[0]->shufflequestions = 0;
+
         $attemptobj->infos = $infos;
+        $attemptobj->link_sections_and_slots();
         $attemptobj->determine_layout();
         $attemptobj->number_questions();
 
index 77627c4..711c85d 100644 (file)
@@ -17,20 +17,20 @@ Feature: Attemp a quiz where some questions require that the previous question h
     And the following "question categories" exist:
       | contextlevel | reference | name           |
       | Course       | C1        | Test questions |
-    And the following "questions" exist:
-      | questioncategory | qtype       | name  | questiontext    |
-      | Test questions   | truefalse   | TF1   | First question  |
-      | Test questions   | truefalse   | TF2   | Second question |
     And the following "activities" exist:
       | activity   | name   | intro              | course | idnumber |
       | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    |
+
+  @javascript
+  Scenario: Attempt a quiz with a single unnamed section
+    Given the following "questions" exist:
+      | questioncategory | qtype       | name  | questiontext    |
+      | Test questions   | truefalse   | TF1   | First question  |
+      | Test questions   | truefalse   | TF2   | Second question |
     And quiz "Quiz 1" contains the following questions:
       | question | page | maxmark |
       | TF1      | 1    |         |
       | TF2      | 1    | 3.0     |
-
-  @javascript
-  Scenario: A student can attempt a quiz
     When I log in as "student"
     And I follow "Course 1"
     And I follow "Quiz 1"
@@ -41,3 +41,61 @@ Feature: Attemp a quiz where some questions require that the previous question h
     And I press "Submit all and finish"
     And I click on "Submit all and finish" "button" in the "Confirmation" "dialogue"
     Then I should see "25.00 out of 100.00"
+
+  @javascript
+  Scenario: Attempt a quiz with mulitple sections
+    Given the following "questions" exist:
+      | questioncategory | qtype       | name  | questiontext    |
+      | Test questions   | truefalse   | TF1   | First question  |
+      | Test questions   | truefalse   | TF2   | Second question |
+      | Test questions   | truefalse   | TF3   | Third question  |
+      | Test questions   | truefalse   | TF4   | Fourth question |
+      | Test questions   | truefalse   | TF5   | Fifth question  |
+      | Test questions   | truefalse   | TF6   | Sixth question  |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page |
+      | TF1      | 1    |
+      | TF2      | 1    |
+      | TF3      | 2    |
+      | TF4      | 3    |
+      | TF5      | 4    |
+      | TF6      | 4    |
+    And quiz "Quiz 1" contains the following sections:
+      | heading   | firstslot | shuffle |
+      | Section 1 | 1         | 1       |
+      | Section 2 | 3         | 0       |
+      |           | 4         | 1       |
+      | Section 3 | 5         | 0       |
+
+    When I log in as "student"
+    And I follow "Course 1"
+    And I follow "Quiz 1"
+    And I press "Attempt quiz now"
+
+    Then I should see "Section 1" in the "Quiz navigation" "block"
+    And I should see question "1" in section "Section 1" in the quiz navigation
+    And I should see question "2" in section "Section 1" in the quiz navigation
+    And I should see question "3" in section "Section 2" in the quiz navigation
+    And I should see question "4" in section "Section 2" in the quiz navigation
+    And I should see question "5" in section "Section 3" in the quiz navigation
+    And I should see question "6" in section "Section 3" in the quiz navigation
+
+    And I follow "Finish attempt ..."
+    And I should see question "1" in section "Section 1" in the quiz navigation
+    And I should see question "2" in section "Section 1" in the quiz navigation
+    And I should see question "3" in section "Section 2" in the quiz navigation
+    And I should see question "4" in section "Section 2" in the quiz navigation
+    And I should see question "5" in section "Section 3" in the quiz navigation
+    And I should see question "6" in section "Section 3" in the quiz navigation
+    And I should see "Section 1" in the "quizsummaryofattempt" "table"
+    And I should see "Section 2" in the "quizsummaryofattempt" "table"
+    And I should see "Section 3" in the "quizsummaryofattempt" "table"
+
+    And I press "Submit all and finish"
+    And I click on "Submit all and finish" "button" in the "Confirmation" "dialogue"
+    And I should see question "1" in section "Section 1" in the quiz navigation
+    And I should see question "2" in section "Section 1" in the quiz navigation
+    And I should see question "3" in section "Section 2" in the quiz navigation
+    And I should see question "4" in section "Section 2" in the quiz navigation
+    And I should see question "5" in section "Section 3" in the quiz navigation
+    And I should see question "6" in section "Section 3" in the quiz navigation
index 7903bfc..69c9646 100644 (file)
@@ -126,18 +126,21 @@ Feature: Attemp a quiz where some questions require that the previous question h
     And I should not see "This question cannot be attempted until the previous question has been completed."
 
   @javascript
-  Scenario: A questions cannot be blocked in a shuffled quiz (despite what is set in the DB).
+  Scenario: Questions cannot be blocked in a shuffled section (despite what is set in the DB).
     Given the following "questions" exist:
       | questioncategory | qtype       | name  | questiontext    |
       | Test questions   | truefalse   | TF1   | First question  |
       | Test questions   | truefalse   | TF2   | Second question |
     And the following "activities" exist:
-      | activity   | name   | intro              | course | idnumber | preferredbehaviour | shufflequestions | questionsperpage |
-      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    | immediatefeedback  | 1                | 2                |
+      | activity   | name   | intro              | course | idnumber | preferredbehaviour | questionsperpage |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    | immediatefeedback  | 2                |
     And quiz "Quiz 1" contains the following questions:
       | question | page | requireprevious |
       | TF1      | 1    | 1               |
-      | TF2      | 1    | 1               |
+      | TF2      | 2    | 1               |
+    And quiz "Quiz 1" contains the following sections:
+      | heading   | firstslot | shuffle |
+      | Section 1 | 1         | 1       |
 
     When I log in as "student"
     And I follow "Course 1"
@@ -148,6 +151,33 @@ Feature: Attemp a quiz where some questions require that the previous question h
     And I should see "Second question"
     And I should not see "This question cannot be attempted until the previous question has been completed."
 
+  @javascript
+  Scenario: Question dependency cannot apply to the first questions in section when the previous section is shuffled
+    Given the following "questions" exist:
+      | questioncategory | qtype       | name  | questiontext    |
+      | Test questions   | truefalse   | TF1   | First question  |
+      | Test questions   | truefalse   | TF2   | Second question |
+    And the following "activities" exist:
+      | activity   | name   | intro              | course | idnumber | preferredbehaviour | questionsperpage |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    | immediatefeedback  | 2                |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page | requireprevious |
+      | TF1      | 1    | 1               |
+      | TF2      | 2    | 1               |
+    And quiz "Quiz 1" contains the following sections:
+      | heading   | firstslot | shuffle |
+      | Section 1 | 1         | 1       |
+      | Section 2 | 2         | 0       |
+
+    When I log in as "student"
+    And I follow "Course 1"
+    And I follow "Quiz 1"
+    And I press "Attempt quiz now"
+    And I press "Next"
+
+    Then I should see "Second question"
+    And I should not see "This question cannot be attempted until the previous question has been completed."
+
   @javascript
   Scenario: A questions cannot be blocked in sequential quiz (despite what is set in the DB).
     Given the following "questions" exist:
diff --git a/mod/quiz/tests/behat/backup.feature b/mod/quiz/tests/behat/backup.feature
new file mode 100644 (file)
index 0000000..66ed6fb
--- /dev/null
@@ -0,0 +1,49 @@
+@mod @mod_quiz
+Feature: Backup and restore of quizzes
+  In order to reuse my quizzes
+  As a teacher
+  I need to be able to back them up and restore them.
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And the following "question categories" exist:
+      | contextlevel | reference | name           |
+      | Course       | C1        | Test questions |
+    And I log in as "admin"
+
+  @javascript
+  Scenario: Duplicate a quiz with two questions
+    Given the following "activities" exist:
+      | activity   | name   | intro              | course | idnumber |
+      | quiz       | Quiz 1 | For testing backup | C1     | quiz1    |
+    And the following "questions" exist:
+      | questioncategory | qtype       | name | questiontext    |
+      | Test questions   | truefalse   | TF1  | First question  |
+      | Test questions   | truefalse   | TF2  | Second question |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page |
+      | TF1      | 1    |
+      | TF2      | 2    |
+    When I follow "Course 1"
+    And I turn editing mode on
+    And I duplicate "Quiz 1" activity editing the new copy with:
+      | Name | Quiz 2 |
+    And I follow "Quiz 2"
+    And I follow "Edit quiz"
+    Then I should see "TF1"
+    And I should see "TF2"
+
+  @javascript @_file_upload
+  Scenario: Restore a Moodle 2.8 quiz backup
+    When I follow "Course 1"
+    And I navigate to "Restore" node in "Course administration"
+    And I press "Manage backup files"
+    And I upload "mod/quiz/tests/fixtures/moodle_28_quiz.mbz" file to "Files" filemanager
+    And I press "Save changes"
+    And I restore "moodle_28_quiz.mbz" backup into "Course 1" course using this options:
+    And I follow "Restored Moodle 2.8 quiz"
+    And I follow "Edit quiz"
+    Then I should see "TF1"
+    And I should see "TF2"
index 306e707..ac23865 100644 (file)
@@ -149,6 +149,88 @@ class behat_mod_quiz extends behat_question_base {
         quiz_update_sumgrades($quiz);
     }
 
+    /**
+     * Put the specified section headings to start at specified pages of a given quiz.
+     *
+     * The first row should be column names:
+     * | heading | firstslot | shufflequestions |
+     *
+     * heading   is the section heading text
+     * firstslot is the slot number where the section starts
+     * shuffle   whether this section is shuffled (0 or 1)
+     *
+     * Then there should be a number of rows of data, one for each section you want to add.
+     *
+     * @param string $quizname the name of the quiz to add sections to.
+     * @param TableNode $data information about the sections to add.
+     *
+     * @Given /^quiz "([^"]*)" contains the following sections:$/
+     */
+    public function quiz_contains_the_following_sections($quizname, TableNode $data) {
+        global $DB;
+
+        $quiz = $DB->get_record('quiz', array('name' => $quizname), '*', MUST_EXIST);
+
+        // Add the sections.
+        $previousfirstslot = 0;
+        foreach ($data->getHash() as $rownumber => $sectiondata) {
+            if (!array_key_exists('heading', $sectiondata)) {
+                throw new ExpectationException('When adding sections to a quiz, ' .
+                        'the heading name column is required.', $this->getSession());
+            }
+            if (!array_key_exists('firstslot', $sectiondata)) {
+                throw new ExpectationException('When adding sections to a quiz, ' .
+                        'the firstslot name column is required.', $this->getSession());
+            }
+            if (!array_key_exists('shuffle', $sectiondata)) {
+                throw new ExpectationException('When adding sections to a quiz, ' .
+                        'the shuffle name column is required.', $this->getSession());
+            }
+
+            if ($rownumber == 0) {
+                $section = $DB->get_record('quiz_sections', array('quizid' => $quiz->id), '*', MUST_EXIST);
+            } else {
+                $section = new stdClass();
+                $section->quizid = $quiz->id;
+            }
+
+            // Heading.
+            $section->heading = $sectiondata['heading'];
+
+            // First slot.
+            $section->firstslot = clean_param($sectiondata['firstslot'], PARAM_INT);
+            if ($section->firstslot <= $previousfirstslot ||
+                    (string) $section->firstslot !== $sectiondata['firstslot']) {
+                throw new ExpectationException('The firstslot number for section "' .
+                        $sectiondata['heading'] . '" must an integer greater than the previous section firstslot.',
+                        $this->getSession());
+            }
+            if ($rownumber == 0 && $section->firstslot != 1) {
+                throw new ExpectationException('The first section must have firstslot set to 1.',
+                        $this->getSession());
+            }
+
+            // Shuffle.
+            $section->shufflequestions = clean_param($sectiondata['shuffle'], PARAM_INT);
+            if ((string) $section->shufflequestions !== $sectiondata['shuffle']) {
+                throw new ExpectationException('The shuffle value for section "' .
+                        $sectiondata['heading'] . '" must be 0 or 1.',
+                        $this->getSession());
+            }
+
+            if ($rownumber == 0) {
+                $DB->update_record('quiz_sections', $section);
+            } else {
+                $DB->insert_record('quiz_sections', $section);
+            }
+        }
+
+        if ($section->firstslot > $DB->count_records('quiz_slots', array('quizid' => $quiz->id))) {
+            throw new ExpectationException('The section firstslot must be less than the total number of slots in the quiz.',
+                    $this->getSession());
+        }
+    }
+
     /**
      * Adds a question to the existing quiz with filling the form.
      *
@@ -356,6 +438,55 @@ class behat_mod_quiz extends behat_question_base {
         );
     }
 
+    /**
+     * Set Shuffle for shuffling questions within sections
+     *
+     * @param string $heading the heading of the section to change shuffle for.
+     *
+     * @Given /^I click on shuffle for section "([^"]*)" on the quiz edit page$/
+     */
+    public function i_click_on_shuffle_for_section($heading) {
+        $xpath = $this->get_xpath_for_shuffle_checkbox($heading);
+        $checkbox = $this->find('xpath', $xpath);
+        $this->ensure_node_is_visible($checkbox);
+        $checkbox->click();
+    }
+
+    /**
+     * Check the shuffle checkbox for a particular section.
+     *
+     * @param string $heading the heading of the section to check shuffle for
+     * @param int $value whether the shuffle checkbox should be on or off.
+     *
+     * @Given /^shuffle for section "([^"]*)" should be "(On|Off)" on the quiz edit page$/
+     */
+    public function shuffle_for_section_should_be($heading, $value) {
+        $xpath = $this->get_xpath_for_shuffle_checkbox($heading);
+        $checkbox = $this->find('xpath', $xpath);
+        $this->ensure_node_is_visible($checkbox);
+        if ($value == 'On' && !$checkbox->isChecked()) {
+            $msg = "Shuffle for section '$heading' is not checked, but you are expecting it to be checked ($value). " .
+                    "Check the line with: \nshuffle for section \"$heading\" should be \"$value\" on the quiz edit page" .
+                    "\nin your behat script";
+            throw new ExpectationException($msg, $this->getSession());
+        } else if ($value == 'Off' && $checkbox->isChecked()) {
+            $msg = "Shuffle for section '$heading' is checked, but you are expecting it not to be ($value). " .
+                    "Check the line with: \nshuffle for section \"$heading\" should be \"$value\" on the quiz edit page" .
+                    "\nin your behat script";
+            throw new ExpectationException($msg, $this->getSession());
+        }
+    }
+
+    /**
+     * Return the xpath for shuffle checkbox in section heading
+     * @param strung $heading
+     * @return string
+     */
+    protected function get_xpath_for_shuffle_checkbox($heading) {
+         return "//div[contains(@class, 'section-heading') and contains(., '" . $this->escape($heading) .
+                "')]//input[@type = 'checkbox']";
+    }
+
     /**
      * Move a question on the Edit quiz page by first clicking on the Move icon,
      * then clicking one of the "After ..." links.
@@ -408,4 +539,41 @@ class behat_mod_quiz extends behat_question_base {
             new Given('I click on "Yes" "button" in the "Confirm" "dialogue"'),
         );
     }
+
+    /**
+     * Set the section heading for a given section on the Edit quiz page
+     *
+     * @When /^I change quiz section heading "(?P<section_name_string>(?:[^"]|\\")*)" to "(?P<new_section_heading_string>(?:[^"]|\\")*)"$/
+     * @param string $sectionname the heading to change.
+     * @param string $sectionheading the new heading to set.
+     */
+    public function i_set_the_section_heading_for($sectionname, $sectionheading) {
+        return array(
+                new Given('I follow "' . $this->escape("Edit heading '{$sectionname}'") . '"'),
+                new Given('I should see "' . $this->escape(get_string('edittitleinstructions')) . '"'),
+                new Given('I set the field "section" to "' . $this->escape($sectionheading) . chr(10) . '"'),
+        );
+    }
+
+    /**
+     * Check that a given question comes after a given section heading in the
+     * quiz navigation block.
+     *
+     * @Then /^I should see question "(?P<questionnumber>\d+)" in section "(?P<section_heading_string>(?:[^"]|\\")*)" in the quiz navigation$/
+     * @param int $questionnumber the number of the question to check.
+     * @param string $sectionheading which section heading it should appear after.
+     */
+    public function i_should_see_question_in_section_in_the_quiz_navigation($questionnumber, $sectionheading) {
+
+        // Using xpath literal to avoid quotes problems.
+        $questionnumberliteral = $this->getSession()->getSelectorsHandler()->xpathLiteral('Question ' . $questionnumber);
+        $headingliteral = $this->getSession()->getSelectorsHandler()->xpathLiteral($sectionheading);
+
+        // Split in two checkings to give more feedback in case of exception.
+        $exception = new ExpectationException('Question "' . $questionnumber . '" is not in section "' .
+                $sectionheading . '" in the quiz navigation.', $this->getSession());
+        $xpath = "//div[@id = 'mod_quiz_navblock']//*[contains(concat(' ', normalize-space(@class), ' '), ' qnbutton ') and " .
+                "contains(., {$questionnumberliteral}) and contains(preceding-sibling::h3[1], {$headingliteral})]";
+        $this->find('xpath', $xpath);
+    }
 }
diff --git a/mod/quiz/tests/behat/editing_click_delete_icon.feature b/mod/quiz/tests/behat/editing_click_delete_icon.feature
deleted file mode 100644 (file)
index a2b47ae..0000000
+++ /dev/null
@@ -1,121 +0,0 @@
-@mod @mod_quiz
-Feature: Edit quiz page - delete
-  In order to change the layout of a quiz I built
-  As a teacher
-  I need to be able to delete questions.
-
-  Background:
-    Given the following "users" exist:
-      | username | firstname | lastname | email               |
-      | teacher1 | T1        | Teacher1 | teacher1@moodle.com |
-    And the following "courses" exist:
-      | fullname | shortname | category |
-      | Course 1 | C1        | 0        |
-    And the following "course enrolments" exist:
-      | user     | course | role           |
-      | teacher1 | C1     | editingteacher |
-    And the following "activities" exist:
-      | activity   | name   | course | idnumber |
-      | quiz       | Quiz 1 | C1     | quiz1    |
-    And I log in as "teacher1"
-    And I follow "Course 1"
-
-  @javascript
-  Scenario: Delete questions by clicking on the delete icon.
-    And I add a "True/False" question to the "Quiz 1" quiz with:
-      | Question name | Question A |
-      | Question text | Answer me  |
-    And I add a "True/False" question to the "Quiz 1" quiz with:
-      | Question name | Question B   |
-      | Question text | Answer again |
-    And I add a "True/False" question to the "Quiz 1" quiz with:
-      | Question name | Question C |
-      | Question text | And again  |
-    And I click on the "Add" page break icon after question "Question B"
-
-    # Confirm the starting point.
-    Then I should see "Question A" on quiz page "1"
-    And I should see "Question B" on quiz page "1"
-    And I should see "Question C" on quiz page "2"
-    And I should see "Total of marks: 3.00"
-    And I should see "Questions: 3"
-    And I should see "This quiz is open"
-
-    # Delete last question in last page. Page contains multiple questions
-    When I delete "Question C" in the quiz by clicking the delete icon
-    Then I should see "Question A" on quiz page "1"
-    And I should see "Question B" on quiz page "1"
-    And I should not see "Question C" on quiz page "2"
-    And I should see "Total of marks: 2.00"
-    And I should see "Questions: 2"
-
-    # Delete last question in last page. The page contains multiple questions and there are multiple pages.
-    When I click on the "Add" page break icon after question "Question A"
-    Then I should see "Question B" on quiz page "2"
-    And the "Remove" page break icon after question "Question A" should exist
-    And I delete "Question B" in the quiz by clicking the delete icon
-    Then I should see "Question A" on quiz page "1"
-    And I should not see "Page 2"
-    And I should not see "Question B" on quiz page "2"
-    And the "Remove" page break icon after question "Question A" should not exist
-    And I should see "Total of marks: 1.00"
-
-    # Delete last remaining question in the last remaining page.
-    And I delete "Question A" in the quiz by clicking the delete icon
-    Then I should not see "Question A" on quiz page "1"
-    And I should not see "Page 1"
-    And I should see "Total of marks: 0.00"
-
-  @javascript @edit_quiz_delete_start
-  Scenario: Delete questions from the start of the list.
-    # Add more questions.
-    When I add a "Description" question to the "Quiz 1" quiz with:
-    | Question name | Question A |
-    | Question text | Answer A   |
-
-    And I add a "True/False" question to the "Quiz 1" quiz with:
-    | Question name | Question B |
-    | Question text | Answer B   |
-
-    And I add a "Description" question to the "Quiz 1" quiz with:
-    | Question name | Question C |
-    | Question text | Answer C   |
-
-    And I add a "True/False" question to the "Quiz 1" quiz with:
-    | Question name | Question D |
-    | Question text | Answer D   |
-
-    And I add a "True/False" question to the "Quiz 1" quiz with:
-    | Question name | Question E |
-    | Question text | Answer E   |
-
-    Then "Question A" should have number "i" on the edit quiz page
-    And "Question B" should have number "1" on the edit quiz page
-    And "Question C" should have number "i" on the edit quiz page
-    And "Question D" should have number "2" on the edit quiz page
-    And "Question E" should have number "3" on the edit quiz page
-
-    # Delete from first question in the last remaining page. Are the page breaks updated?
-    When I delete "Question A" in the quiz by clicking the delete icon
-    Then "Question B" should have number "1" on the edit quiz page
-    And "Question C" should have number "i" on the edit quiz page
-    And "Question D" should have number "2" on the edit quiz page
-    And "Question E" should have number "3" on the edit quiz page
-
-    When I click on the "Add" page break icon after question "Question C"
-    Then I should see "Page 1"
-    And I should see "Question B" on quiz page "1"
-    And I should see "Question C" on quiz page "1"
-    Then I should see "Page 2"
-    And I should see "Question D" on quiz page "2"
-    And I should see "Question E" on quiz page "2"
-
-    # Test reorder of pages
-    When I click on the "Add" page break icon after question "Question B"
-    Then I should see "Page 1"
-    And I should see "Question B" on quiz page "1"
-    Then I should see "Page 2"
-    And I should see "Question C" on quiz page "2"
-    Then I should see "Page 3"
-    And I should see "Question D" on quiz page "3"
-    And I should see "Question E" on quiz page "3"
@@ -14,21 +14,26 @@ Feature: Edit quiz page - drag-and-drop
     And the following "course enrolments" exist:
       | user     | course | role           |
       | teacher1 | C1     | editingteacher |
+    And the following "question categories" exist:
+      | contextlevel | reference | name           |
+      | Course       | C1        | Test questions |
+    And the following "questions" exist:
+      | questioncategory | qtype     | name       | questiontext        |
+      | Test questions   | truefalse | Question A | This is question 01 |
+      | Test questions   | truefalse | Question B | This is question 02 |
+      | Test questions   | truefalse | Question C | This is question 03 |
     And the following "activities" exist:
       | activity   | name   | course | idnumber |
       | quiz       | Quiz 1 | C1     | quiz1    |
+    And quiz "Quiz 1" contains the following questions:
+      | question   | page |
+      | Question A | 1    |
+      | Question B | 1    |
+      | Question C | 2    |
     And I log in as "teacher1"
     And I follow "Course 1"
-    And I add a "True/False" question to the "Quiz 1" quiz with:
-      | Question name | Question A |
-      | Question text | Answer me  |
-    And I add a "True/False" question to the "Quiz 1" quiz with:
-      | Question name | Question B   |
-      | Question text | Answer again |
-    And I add a "True/False" question to the "Quiz 1" quiz with:
-      | Question name | Question C |
-      | Question text | And again  |
-    And I click on the "Add" page break icon after question "Question B"
+    And I follow "Quiz 1"
+    And I follow "Edit quiz"
 
   @javascript
   Scenario: Re-order questions by clicking on the move icon.
diff --git a/mod/quiz/tests/behat/editing_remove_question.feature b/mod/quiz/tests/behat/editing_remove_question.feature
new file mode 100644 (file)
index 0000000..41a8a7d
--- /dev/null
@@ -0,0 +1,66 @@
+@mod @mod_quiz
+Feature: Edit quiz page - remove questions
+  In order to change the layout of a quiz I built
+  As a teacher
+  I need to be able to delete questions.
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email               |
+      | teacher1 | T1        | Teacher1 | teacher1@moodle.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+    And the following "question categories" exist:
+      | contextlevel | reference | name           |
+      | Course       | C1        | Test questions |
+    And the following "activities" exist:
+      | activity   | name   | course | idnumber |
+      | quiz       | Quiz 1 | C1     | quiz1    |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I follow "Quiz 1"
+
+  @javascript
+  Scenario: Delete questions by clicking on the delete icon.
+    Given the following "questions" exist:
+      | questioncategory | qtype     | name       | questiontext        |
+      | Test questions   | truefalse | Question A | This is question 01 |
+      | Test questions   | truefalse | Question B | This is question 02 |
+      | Test questions   | truefalse | Question C | This is question 03 |
+    And quiz "Quiz 1" contains the following questions:
+      | question   | page |
+      | Question A | 1    |
+      | Question B | 1    |
+      | Question C | 2    |
+    And I follow "Edit quiz"
+
+    # Confirm the starting point.
+    Then I should see "Question A" on quiz page "1"
+    And I should see "Question B" on quiz page "1"
+    And I should see "Question C" on quiz page "2"
+    And I should see "Total of marks: 3.00"
+    And I should see "Questions: 3"
+    And I should see "This quiz is open"
+
+    # Delete last question in last page. Page contains multiple questions
+    When I delete "Question C" in the quiz by clicking the delete icon
+    Then I should see "Question A" on quiz page "1"
+    And I should see "Question B" on quiz page "1"
+    And I should not see "Question C" on quiz page "2"
+    And I should see "Total of marks: 2.00"
+    And I should see "Questions: 2"
+
+    # Delete last question in last page. The page contains multiple questions and there are multiple pages.
+    When I click on the "Add" page break icon after question "Question A"
+    Then I should see "Question B" on quiz page "2"
+    And the "Remove" page break icon after question "Question A" should exist
+    And I delete "Question A" in the quiz by clicking the delete icon
+    Then I should see "Question B" on quiz page "1"
+    And I should not see "Page 2"
+    And I should not see "Question A" on quiz page "2"
+    And the "Remove" page break icon after question "Question B" should not exist
+    And I should see "Total of marks: 1.00"
index fc0f2c9..88b1dfa 100644 (file)
@@ -117,10 +117,10 @@ Feature: Edit quizzes where some questions require the previous one to have been
     Then "be attempted" "link" in the "TF2" "list_item" should not be visible
 
   @javascript
-  Scenario: Question dependency cannot apply to quizzes where the questions are shuffled so UI is hidden
+  Scenario: Question dependency cannot apply to questions in a shuffled section so UI is hidden
     Given the following "activities" exist:
-      | activity   | name   | intro              | course | idnumber | preferredbehaviour | shufflequestions | questionsperpage |
-      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    | immediatefeedback  | 1                | 2                |
+      | activity   | name   | intro              | course | idnumber | preferredbehaviour | questionsperpage |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    | immediatefeedback  | 2                |
     And the following "questions" exist:
       | questioncategory | qtype       | name | questiontext    |
       | Test questions   | truefalse   | TF1  | First question  |
@@ -129,6 +129,31 @@ Feature: Edit quizzes where some questions require the previous one to have been
       | question | page | requireprevious |
       | TF1      | 1    | 1               |
       | TF2      | 1    | 1               |
+    And quiz "Quiz 1" contains the following sections:
+      | heading   | firstslot | shuffle |
+      | Section 1 | 1         | 1       |
+    And I follow "Course 1"
+    And I follow "Quiz 1"
+    And I follow "Edit quiz"
+    Then "be attempted" "link" in the "TF2" "list_item" should not be visible
+
+  @javascript
+  Scenario: Question dependency cannot apply to the first questions in section when the previous section is shuffled
+    Given the following "activities" exist:
+      | activity   | name   | intro              | course | idnumber | preferredbehaviour | questionsperpage |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    | immediatefeedback  | 2                |
+    And the following "questions" exist:
+      | questioncategory | qtype       | name | questiontext    |
+      | Test questions   | truefalse   | TF1  | First question  |
+      | Test questions   | truefalse   | TF2  | Second question |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page | requireprevious |
+      | TF1      | 1    | 1               |
+      | TF2      | 1    | 1               |
+    And quiz "Quiz 1" contains the following sections:
+      | heading   | firstslot | shuffle |
+      | Section 1 | 1         | 1       |
+      | Section 2 | 2         | 0       |
     And I follow "Course 1"
     And I follow "Quiz 1"
     And I follow "Edit quiz"
diff --git a/mod/quiz/tests/behat/editing_section_headings.feature b/mod/quiz/tests/behat/editing_section_headings.feature
new file mode 100644 (file)
index 0000000..77714ed
--- /dev/null
@@ -0,0 +1,350 @@
+@mod @mod_quiz
+Feature: Edit quiz page - section headings
+  In order to build a quiz laid out in sections the way I want
+  As a teacher
+  I need to be able to add, edit and remove section headings as well as shuffle
+  questions within a section.
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email               |
+      | teacher1 | T1        | Teacher1 | teacher1@moodle.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+    And the following "question categories" exist:
+      | contextlevel | reference | name           |
+      | Course       | C1        | Test questions |
+    And I log in as "teacher1"
+
+  @javascript
+  Scenario: We have a quiz with one default section
+    Given the following "activities" exist:
+      | activity   | name   | intro              | course | idnumber |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    |
+    And the following "questions" exist:
+      | questioncategory | qtype       | name | questiontext    |
+      | Test questions   | truefalse   | TF1  | This is question 01 |
+      | Test questions   | truefalse   | TF2  | This is question 02 |
+      | Test questions   | truefalse   | TF3  | This is question 03 |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page |
+      | TF1      | 1    |
+      | TF2      | 2    |
+      | TF3      | 3    |
+    When I follow "Course 1"
+    And I follow "Quiz 1"
+    And I follow "Edit quiz"
+    Then I should see "Shuffle"
+
+  @javascript
+  Scenario: Modify section headings
+    Given the following "activities" exist:
+      | activity   | name   | intro              | course | idnumber |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    |
+    And the following "questions" exist:
+      | questioncategory | qtype       | name | questiontext    |
+      | Test questions   | truefalse   | TF1  | This is question 01 |
+      | Test questions   | truefalse   | TF2  | This is question 02 |
+      | Test questions   | truefalse   | TF3  | This is question 03 |
+      | Test questions   | truefalse   | TF4  | This is question 04 |
+      | Test questions   | truefalse   | TF5  | This is question 05 |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page |
+      | TF1      | 1    |
+      | TF2      | 2    |
+      | TF3      | 3    |
+      | TF4      | 3    |
+    And quiz "Quiz 1" contains the following sections:
+      | heading   | firstslot | shuffle |
+      |           | 1         | 0       |
+      | Heading 2 | 2         | 0       |
+      | Heading 3 | 3         | 1       |
+    When I follow "Course 1"
+    And I follow "Quiz 1"
+    And I follow "Edit quiz"
+    And I change quiz section heading "" to "This is section one"
+    And I change quiz section heading "Heading 2" to "This is section two"
+    Then I should see "This is section one"
+    And I should see "This is section two"
+
+  @javascript
+  Scenario: Set section headings to blanks
+    Given the following "activities" exist:
+      | activity   | name   | intro              | course | idnumber |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    |
+    And the following "questions" exist:
+      | questioncategory | qtype       | name | questiontext    |
+      | Test questions   | truefalse   | TF1  | This is question 01 |
+      | Test questions   | truefalse   | TF2  | This is question 02 |
+      | Test questions   | truefalse   | TF3  | This is question 03 |
+      | Test questions   | truefalse   | TF4  | This is question 04 |
+      | Test questions   | truefalse   | TF5  | This is question 05 |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page |
+      | TF1      | 1    |
+      | TF2      | 2    |
+      | TF3      | 3    |
+      | TF4      | 3    |
+    And quiz "Quiz 1" contains the following sections:
+      | heading   | firstslot | shuffle |
+      | Heading 1 | 1         | 0       |
+      | Heading 2 | 2         | 0       |
+      | Heading 3 | 3         | 1       |
+    When I follow "Course 1"
+    And I follow "Quiz 1"
+    And I follow "Edit quiz"
+    When I change quiz section heading "Heading 1" to ""
+    Then I should not see "Heading 1"
+    And I should see "Heading 2"
+    And I should see "Heading 3"
+
+    And I change quiz section heading "Heading 2" to ""
+    And I should not see "Heading 1"
+    And I should not see "Heading 2"
+    And I should see "Heading 3"
+
+    And I change quiz section heading "Heading 3" to ""
+    And I should not see "Heading 1"
+    And I should not see "Heading 2"
+    And I should not see "Heading 3"
+
+  @javascript
+  Scenario: Remove a section
+    Given the following "activities" exist:
+      | activity   | name   | intro              | course | idnumber |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    |
+    And the following "questions" exist:
+      | questioncategory | qtype       | name | questiontext    |
+      | Test questions   | truefalse   | TF1  | This is question 01 |
+      | Test questions   | truefalse   | TF2  | This is question 02 |
+      | Test questions   | truefalse   | TF3  | This is question 03 |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page |
+      | TF1      | 1    |
+      | TF2      | 2    |
+      | TF3      | 3    |
+    And quiz "Quiz 1" contains the following sections:
+      | heading   | firstslot | shuffle |
+      | Heading 1 | 1         | 0       |
+      | Heading 2 | 2         | 0       |
+      | Heading 3 | 3         | 1       |
+    When I follow "Course 1"
+    And I follow "Quiz 1"
+    And I follow "Edit quiz"
+    And I follow "Remove heading 'Heading 2'"
+    And I should see "Are you sure you want to remove the 'Heading 2' section heading?"
+    And I click on "Yes" "button" in the "Confirm" "dialogue"
+    Then I should not see "Heading 2"
+    And I should see "Heading 1"
+    And I should see "Heading 3"
+
+  @javascript
+  Scenario: The edit-icon tool-tips are updated when a section is edited
+    Given the following "activities" exist:
+      | activity   | name   | intro              | course | idnumber |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    |
+    And the following "questions" exist:
+      | questioncategory | qtype       | name | questiontext    |
+      | Test questions   | truefalse   | TF1  | This is question 01 |
+      | Test questions   | truefalse   | TF2  | This is question 02 |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page |
+      | TF1      | 1    |
+      | TF2      | 2    |
+    And quiz "Quiz 1" contains the following sections:
+      | heading   | firstslot | shuffle |
+      | Heading 1 | 1         | 0       |
+      | Heading 2 | 2         | 0       |
+    When I follow "Course 1"
+    And I follow "Quiz 1"
+    And I follow "Edit quiz"
+    And I change quiz section heading "Heading 2" to "Edited heading"
+    Then I should see "Edited heading"
+    And "Edit heading 'Edited heading'" "link" should be visible
+    And "Remove heading 'Edited heading'" "link" should be visible
+
+  @javascript
+  Scenario: Moving a question up from section 3 to the first section.
+    Given the following "activities" exist:
+      | activity   | name   | intro              | course | idnumber |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    |
+    And the following "questions" exist:
+      | questioncategory | qtype       | name | questiontext    |
+      | Test questions   | truefalse   | TF1  | This is question 01 |
+      | Test questions   | truefalse   | TF2  | This is question 02 |
+      | Test questions   | truefalse   | TF3  | This is question 03 |
+      | Test questions   | truefalse   | TF4  | This is question 04 |
+      | Test questions   | truefalse   | TF5  | This is question 05 |
+      | Test questions   | truefalse   | TF6  | This is question 06 |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page |
+      | TF1      | 1    |
+      | TF2      | 2    |
+      | TF3      | 3    |
+      | TF4      | 4    |
+      | TF5      | 5    |
+      | TF6      | 6    |
+    And quiz "Quiz 1" contains the following sections:
+      | heading   | firstslot | shuffle |
+      | Heading 1 | 1         | 0       |
+      | Heading 2 | 3         | 0       |
+      | Heading 3 | 5         | 1       |
+    When I follow "Course 1"
+    And I follow "Quiz 1"
+    And I follow "Edit quiz"
+    And I move "TF5" to "After Question 2" in the quiz by clicking the move icon
+    Then I should see "TF5" on quiz page "2"
+
+  @javascript
+  Scenario: moving a question down from the first section to the second section.
+    Given the following "activities" exist:
+      | activity   | name   | intro              | course | idnumber |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    |
+    And the following "questions" exist:
+      | questioncategory | qtype       | name | questiontext    |
+      | Test questions   | truefalse   | TF1  | This is question 01 |
+      | Test questions   | truefalse   | TF2  | This is question 02 |
+      | Test questions   | truefalse   | TF3  | This is question 03 |
+      | Test questions   | truefalse   | TF4  | This is question 04 |
+      | Test questions   | truefalse   | TF5  | This is question 05 |
+      | Test questions   | truefalse   | TF6  | This is question 06 |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page |
+      | TF1      | 1    |
+      | TF2      | 2    |
+      | TF3      | 3    |
+      | TF4      | 4    |
+      | TF5      | 5    |
+      | TF6      | 6    |
+    And quiz "Quiz 1" contains the following sections:
+      | heading   | firstslot | shuffle |
+      | Heading 1 | 1         | 0       |
+      | Heading 2 | 3         | 0       |
+      | Heading 3 | 5         | 1       |
+    When I follow "Course 1"
+    And I follow "Quiz 1"
+    And I follow "Edit quiz"
+    And I move "TF1" to "After Question 3" in the quiz by clicking the move icon
+    Then I should see "TF1" on quiz page "2"
+
+  @javascript
+  Scenario: I should not see a delete icon for the first section in the quiz.
+    Given the following "activities" exist:
+      | activity   | name   | intro              | course | idnumber |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    |
+    And the following "questions" exist:
+      | questioncategory | qtype       | name | questiontext    |
+      | Test questions   | truefalse   | TF1  | This is question 01 |
+      | Test questions   | truefalse   | TF2  | This is question 02 |
+      | Test questions   | truefalse   | TF3  | This is question 03 |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page |
+      | TF1      | 1    |
+      | TF2      | 2    |
+      | TF3      | 3    |
+    And quiz "Quiz 1" contains the following sections:
+      | heading   | firstslot | shuffle |
+      | Heading 1 | 1         | 0       |
+      | Heading 2 | 2         | 0       |
+      | Heading 3 | 3         | 1       |
+    When I follow "Course 1"
+    And I follow "Quiz 1"
+    And I follow "Edit quiz"
+    Then "Remove heading 'Heading 1'" "link" should not exist
+    And "Remove heading 'Heading 2'" "link" should exist
+    And "Remove heading 'Heading 3'" "link" should exist
+
+  @javascript
+  Scenario: Turn shuffling on for a section
+    Given the following "activities" exist:
+      | activity   | name   | intro              | course | idnumber |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    |
+    And the following "questions" exist:
+      | questioncategory | qtype       | name | questiontext    |
+      | Test questions   | truefalse   | TF1  | This is question 01 |
+      | Test questions   | truefalse   | TF2  | This is question 02 |
+      | Test questions   | truefalse   | TF3  | This is question 03 |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page |
+      | TF1      | 1    |
+      | TF2      | 2    |
+      | TF3      | 3    |
+    And quiz "Quiz 1" contains the following sections:
+      | heading   | firstslot | shuffle |
+      | Heading 1 | 1         | 0       |
+      | Heading 2 | 2         | 0       |
+      | Heading 3 | 3         | 0       |
+    When I follow "Course 1"
+    And I follow "Quiz 1"
+    And I follow "Edit quiz"
+    And I click on shuffle for section "Heading 1" on the quiz edit page
+    And I click on shuffle for section "Heading 2" on the quiz edit page
+    Then shuffle for section "Heading 1" should be "On" on the quiz edit page
+    And shuffle for section "Heading 2" should be "On" on the quiz edit page
+
+  @javascript
+  Scenario: Turn shuffling off for a section
+    Given the following "activities" exist:
+      | activity   | name   | intro              | course | idnumber |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    |
+    And the following "questions" exist:
+      | questioncategory | qtype       | name | questiontext    |
+      | Test questions   | truefalse   | TF1  | This is question 01 |
+      | Test questions   | truefalse   | TF2  | This is question 02 |
+      | Test questions   | truefalse   | TF3  | This is question 03 |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page |
+      | TF1      | 1    |
+      | TF2      | 2    |
+      | TF3      | 3    |
+    And quiz "Quiz 1" contains the following sections:
+      | heading   | firstslot | shuffle |
+      | Heading 1 | 1         | 1       |
+      | Heading 2 | 2         | 1       |
+      | Heading 3 | 3         | 1       |
+    When I follow "Course 1"
+    And I follow "Quiz 1"
+    And I follow "Edit quiz"
+    And I click on shuffle for section "Heading 1" on the quiz edit page
+    And I click on shuffle for section "Heading 2" on the quiz edit page
+    Then shuffle for section "Heading 1" should be "Off" on the quiz edit page
+    And shuffle for section "Heading 2" should be "Off" on the quiz edit page
+    And I reload the page
+    And shuffle for section "Heading 1" should be "Off" on the quiz edit page
+    And shuffle for section "Heading 2" should be "Off" on the quiz edit page
+
+  @javascript
+  Scenario: Add section heading option only appears for pages that are not the first in their section.
+    Given the following "activities" exist:
+      | activity   | name   | intro              | course | idnumber |
+      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    |
+    And the following "questions" exist:
+      | questioncategory | qtype       | name | questiontext    |
+      | Test questions   | truefalse   | TF1  | This is question 01 |
+      | Test questions   | truefalse   | TF2  | This is question 02 |
+      | Test questions   | truefalse   | TF3  | This is question 03 |
+    And quiz "Quiz 1" contains the following questions:
+      | question | page |
+      | TF1      | 1    |
+      | TF2      | 1    |
+      | TF3      | 2    |
+    When I follow "Course 1"
+    And I follow "Quiz 1"
+    And I follow "Edit quiz"
+    And I click on the "Add" page break icon after question "TF1"
+    And I click on "Add" "link" in the "Page 1" "list_item"
+    Then "a new section heading" "list_item" in the "Page 1" "list_item" should not be visible
+    # Click away to close the menu.
+    And I click on ".numberofquestions" "css_element"
+    And I click on "Add" "link" in the "Page 2" "list_item"
+    And "a new section heading" "list_item" in the "Page 2" "list_item" should be visible
+    And I click on ".numberofquestions" "css_element"
+    And I click on "Add" "link" in the "Page 3" "list_item"
+    And "a new section heading" "list_item" in the "Page 3" "list_item" should be visible
+    And I click on ".numberofquestions" "css_element"
+    And I click on "Add" "link" in the ".last-add-menu" "css_element"
+    And "a new section heading" "list_item" in the ".last-add-menu" "css_element" should not be visible
diff --git a/mod/quiz/tests/fixtures/moodle_28_quiz.mbz b/mod/quiz/tests/fixtures/moodle_28_quiz.mbz
new file mode 100644 (file)
index 0000000..2a57eb4
Binary files /dev/null and b/mod/quiz/tests/fixtures/moodle_28_quiz.mbz differ
index d3448a9..db1703f 100644 (file)
@@ -69,7 +69,6 @@ class mod_quiz_generator extends testing_module_generator {
             'rightanswerclosed'      => 1,
             'overallfeedbackclosed'  => 1,
             'questionsperpage'       => 1,
-            'shufflequestions'       => 0,
             'shuffleanswers'         => 1,
             'sumgrades'              => 0,
             'grade'                  => 100,
index 7ea030b..7dfeb95 100644 (file)
@@ -37,7 +37,8 @@ require_once($CFG->dirroot . '/mod/quiz/attemptlib.php');
 class mod_quiz_structure_testcase extends advanced_testcase {
 
     /**
-     * Prepare the quiz object with standard data. Ready for testing.
+     * Create a course with an empty quiz.
+     * @return array with three elements quiz, cm and course.
      */
     protected function prepare_quiz_data() {
 
@@ -58,383 +59,673 @@ class mod_quiz_structure_testcase extends advanced_testcase {
     }
 
     /**
-     * Test getting the quiz slots.
+     * Creat a test quiz.
+     *
+     * $layout looks like this:
+     * $layout = array(
+     *     'Heading 1'
+     *     array('TF1', 1, 'truefalse'),
+     *     'Heading 2*'
+     *     array('TF2', 2, 'truefalse'),
+     * );
+     * That is, either a string, which represents a section heading,
+     * or an array that represents a question.
+     *
+     * If the section heading ends with *, that section is shuffled.
+     *
+     * The elements in the question array are name, page number, and question type.
+     *
+     * @param array $layout as above.
+     * @return quiz the created quiz.
      */
-    public function test_get_quiz_slots() {
-        // Get basic quiz.
+    protected function create_test_quiz($layout) {
         list($quiz, $cm, $course) = $this->prepare_quiz_data();
-        $quizobj = new quiz($quiz, $cm, $course);
-        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
-
-        // When no slots exist or slots propery is not set.
-        $slots = $structure->get_slots();
-        $this->assertInternalType('array', $slots);
-        $this->assertCount(0, $slots);
-
-        // Append slots to the quiz.
-        $this->add_eight_questions_to_the_quiz($quiz);
-        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+        $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $cat = $questiongenerator->create_question_category();
 
-        // Are the correct slots returned?
-        $slots = $structure->get_slots();
-        $this->assertCount(8, $slots);
-    }
+        $headings = array();
+        $slot = 1;
+        $lastpage = 0;
+        foreach ($layout as $item) {
+            if (is_string($item)) {
+                if (isset($headings[$lastpage + 1])) {
+                    throw new coding_exception('Sections cannot be empty.');
+                }
+                $headings[$lastpage + 1] = $item;
+
+            } else {
+                list($name, $page, $qtype) = $item;
+                if ($page < 1 || !($page == $lastpage + 1 ||
+                        (!isset($headings[$lastpage + 1]) && $page == $lastpage))) {
+                    throw new coding_exception('Page numbers wrong.');
+                }
+                $q = $questiongenerator->create_question($qtype, null,
+                        array('name' => $name, 'category' => $cat->id));
+
+                quiz_add_quiz_question($q->id, $quiz, $page);
+                $lastpage = $page;
+            }
+        }
 
-    /**
-     * Test getting the quiz sections.
-     */
-    public function test_get_quiz_sections() {
-        // Get basic quiz.
-        list($quiz, $cm, $course) = $this->prepare_quiz_data();
         $quizobj = new quiz($quiz, $cm, $course);
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+        if (isset($headings[1])) {
+            list($heading, $shuffle) = $this->parse_section_name($headings[1]);
+            $sections = $structure->get_sections();
+            $firstsection = reset($sections);
+            $structure->set_section_heading($firstsection->id, $heading);
+            $structure->set_section_shuffle($firstsection->id, $shuffle);
+            unset($headings[1]);
+        }
 
-        // Are the correct sections returned?
-        $sections = $structure->get_quiz_sections();
-        $this->assertCount(1, $sections);
+        foreach ($headings as $startpage => $heading) {
+            list($heading, $shuffle) = $this->parse_section_name($heading);
+            $id = $structure->add_section_heading($startpage, $heading);
+            $structure->set_section_shuffle($id, $shuffle);
+        }
+
+        return $quizobj;
     }
 
     /**
      * Verify that the given layout matches that expected.
-     * @param array $expectedlayout
-     * @param \mod_quiz\structure $structure
+     * @param array $expectedlayout as for $layout in {@link create_test_quiz()}.
+     * @param \mod_quiz\structure $structure the structure to test.
      */
     protected function assert_quiz_layout($expectedlayout, \mod_quiz\structure $structure) {
-        $slotnumber = 0;
-        foreach ($expectedlayout as $slotid => $page) {
-            $slotnumber += 1;
-            $this->assertEquals($slotid, $structure->get_question_in_slot($slotnumber)->slotid,
-                    'Wrong question in slot ' . $slotnumber);
-            $this->assertEquals($page, $structure->get_question_in_slot($slotnumber)->page,
-                    'Wrong page number for slot ' . $slotnumber);
+        $sections = $structure->get_sections();
+
+        $slot = 1;
+        foreach ($expectedlayout as $item) {
+            if (is_string($item)) {
+                list($heading, $shuffle) = $this->parse_section_name($item);
+                $section = array_shift($sections);
+
+                if ($slot > 1 && $section->heading == '' && $section->firstslot == 1) {
+                    // The array $expectedlayout did not contain default first quiz section, so skip over it.
+                    $section = array_shift($sections);
+                }
+
+                $this->assertEquals($slot, $section->firstslot);
+                $this->assertEquals($heading, $section->heading);
+                $this->assertEquals($shuffle, $section->shufflequestions);
+
+            } else {
+                list($name, $page, $qtype) = $item;
+                $question = $structure->get_question_in_slot($slot);
+                $this->assertEquals($slot,  $question->slot);
+                $this->assertEquals($name,  $question->name);
+                $this->assertEquals($qtype, $question->qtype);
+                $this->assertEquals($page,  $question->page);
+
+                $slot += 1;
+            }
+        }
+
+        if ($slot - 1 != count($structure->get_slots())) {
+            $this->fail('The quiz contains more slots than expected.');
+        }
+
+        if (!empty($sections)) {
+            $section = array_shift($sections);
+            if ($section->heading != '' || $section->firstslot != 1) {
+                $this->fail('Unexpected section (' . $section->heading .') found in the quiz.');
+            }
         }
     }
 
     /**
-     * Test moving slots in the quiz.
+     * Parse the section name, optionally followed by a * to mean shuffle, as
+     * used by create_test_quiz as assert_quiz_layout.
+     * @param string $heading the heading.
+     * @return array with two elements, the heading and the shuffle setting.
      */
-    public function test_move_slot() {
-        // Create a test quiz with 8 questions.
-        list($quiz, $cm, $course) = $this->prepare_quiz_data();
-        $this->add_eight_questions_to_the_quiz($quiz);
-        $quizobj = new quiz($quiz, $cm, $course);
+    protected function parse_section_name($heading) {
+        if (substr($heading, -1) == '*') {
+            return array(substr($heading, 0, -1), 1);
+        } else {
+            return array($heading, 0);
+        }
+    }
+
+    public function test_get_quiz_slots() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
+            ));
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 
-        // Store the original order of slots, so we can assert what has changed.
-        $originalslotids = array();
-        foreach ($structure->get_slots() as $slot) {
-            $originalslotids[$slot->slot] = $slot->id;
-        }
+        // Are the correct slots returned?
+        $slots = $structure->get_slots();
+        $this->assertCount(2, $structure->get_slots());
+    }
+
+    public function test_quiz_has_one_section_by_default() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+            ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 
-        // Don't actually move anything. Check the layout is unchanged.
-        $idmove = $structure->get_question_in_slot(2)->slotid;
-        $idbefore = $structure->get_question_in_slot(1)->slotid;
-        $structure->move_slot($idmove, $idbefore, 2);
+        $sections = $structure->get_sections();
+        $this->assertCount(1, $sections);
+
+        $section = array_shift($sections);
+        $this->assertEquals(1, $section->firstslot);
+        $this->assertEquals('', $section->heading);
+        $this->assertEquals(0, $section->shufflequestions);
+    }
+
+    public function test_get_sections() {
+        $quizobj = $this->create_test_quiz(array(
+                'Heading 1*',
+                array('TF1', 1, 'truefalse'),
+                'Heading 2*',
+                array('TF2', 2, 'truefalse'),
+        ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+
+        $sections = $structure->get_sections();
+        $this->assertCount(2, $sections);
+
+        $section = array_shift($sections);
+        $this->assertEquals(1, $section->firstslot);
+        $this->assertEquals('Heading 1', $section->heading);
+        $this->assertEquals(1, $section->shufflequestions);
+
+        $section = array_shift($sections);
+        $this->assertEquals(2, $section->firstslot);
+        $this->assertEquals('Heading 2', $section->heading);
+        $this->assertEquals(1, $section->shufflequestions);
+    }
+
+    public function test_remove_section_heading() {
+        $quizobj = $this->create_test_quiz(array(
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+                'Heading 2',
+                array('TF2', 2, 'truefalse'),
+            ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+
+        $sections = $structure->get_sections();
+        $section = end($sections);
+        $structure->remove_section_heading($section->id);
 
-        // Having called move, we need to reload $structure.
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
         $this->assert_quiz_layout(array(
-                    $originalslotids[1] => 1,
-                    $originalslotids[2] => 2,
-                    $originalslotids[3] => 2,
-                    $originalslotids[4] => 2,
-                    $originalslotids[5] => 2,
-                    $originalslotids[6] => 2,
-                    $originalslotids[7] => 3,
-                    $originalslotids[8] => 4,
-                ), $structure);
-
-        // Slots don't move. Page changed.
-        $idmove = $structure->get_question_in_slot(2)->slotid;
-        $idbefore = $structure->get_question_in_slot(1)->slotid;
-        $structure->move_slot($idmove, $idbefore, 1);
-
-        // Having called move, we need to reload $structure.
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
+            ), $structure);
+    }
+
+    public function test_cannot_remove_first_section() {
+        $quizobj = $this->create_test_quiz(array(
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+        ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+
+        $sections = $structure->get_sections();
+        $section = reset($sections);
+
+        $this->setExpectedException('coding_exception');
+        $structure->remove_section_heading($section->id);
+    }
+
+    public function test_move_slot_to_the_same_place_does_nothing() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
+                array('TF3', 2, 'truefalse'),
+            ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+
+        $idtomove = $structure->get_question_in_slot(2)->slotid;
+        $idmoveafter = $structure->get_question_in_slot(1)->slotid;
+        $structure->move_slot($idtomove, $idmoveafter, '1');
+
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
         $this->assert_quiz_layout(array(
-                    $originalslotids[1] => 1,
-                    $originalslotids[2] => 1,
-                    $originalslotids[3] => 2,
-                    $originalslotids[4] => 2,
-                    $originalslotids[5] => 2,
-                    $originalslotids[6] => 2,
-                    $originalslotids[7] => 3,
-                    $originalslotids[8] => 4,
-                ), $structure);
-
-        // Slots move 2 > 3. Page unchanged. Pages not reordered.
-        $idmove = $structure->get_question_in_slot(2)->slotid;
-        $idbefore = $structure->get_question_in_slot(3)->slotid;
-        $structure->move_slot($idmove, $idbefore, '2');
-
-        // Having called move, we need to reload $structure.
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
+                array('TF3', 2, 'truefalse'),
+            ), $structure);
+    }
+
+    public function test_move_slot_end_of_one_page_to_start_of_next() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
+                array('TF3', 2, 'truefalse'),
+            ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+
+        $idtomove = $structure->get_question_in_slot(2)->slotid;
+        $idmoveafter = $structure->get_question_in_slot(2)->slotid;
+        $structure->move_slot($idtomove, $idmoveafter, '2');
+
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
         $this->assert_quiz_layout(array(
-                $originalslotids[1] => 1,
-                $originalslotids[3] => 2,
-                $originalslotids[2] => 2,
-                $originalslotids[4] => 2,
-                $originalslotids[5] => 2,
-                $originalslotids[6] => 2,
-                $originalslotids[7] => 3,
-                $originalslotids[8] => 4,
-        ), $structure);
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
+                array('TF3', 2, 'truefalse'),
+            ), $structure);
+    }
+
+    public function test_end_of_one_section_to_start_of_next() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
+                'Heading',
+                array('TF3', 2, 'truefalse'),
+            ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 
-        // Slots move 6 > 7. Page changed. Pages not reordered.
-        $idmove = $structure->get_question_in_slot(6)->slotid;
-        $idbefore = $structure->get_question_in_slot(7)->slotid;
-        $structure->move_slot($idmove, $idbefore, '3');
+        $idtomove = $structure->get_question_in_slot(2)->slotid;
+        $idmoveafter = $structure->get_question_in_slot(2)->slotid;
+        $structure->move_slot($idtomove, $idmoveafter, '2');
 
-        // Having called move, we need to reload $structure.
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
         $this->assert_quiz_layout(array(
-                $originalslotids[1] => 1,
-                $originalslotids[3] => 2,
-                $originalslotids[2] => 2,
-                $originalslotids[4] => 2,
-                $originalslotids[5] => 2,
-                $originalslotids[7] => 3,
-                $originalslotids[6] => 3,
-                $originalslotids[8] => 4,
-        ), $structure);
+                array('TF1', 1, 'truefalse'),
+                'Heading',
+                array('TF2', 2, 'truefalse'),
+                array('TF3', 2, 'truefalse'),
+            ), $structure);
+    }
+
+    public function test_start_of_one_section_to_end_of_previous() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                'Heading',
+                array('TF2', 2, 'truefalse'),
+                array('TF3', 2, 'truefalse'),
+            ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 
-        // Page changed slot 6 . Pages not reordered.
-        $idmove = $structure->get_question_in_slot(6)->slotid;
-        $idbefore = $structure->get_question_in_slot(5)->slotid;
-        $structure->move_slot($idmove, $idbefore, 2);
+        $idtomove = $structure->get_question_in_slot(2)->slotid;
+        $idmoveafter = $structure->get_question_in_slot(1)->slotid;
+        $structure->move_slot($idtomove, $idmoveafter, '1');
 
-        // Having called move, we need to reload $structure.
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
         $this->assert_quiz_layout(array(
-                $originalslotids[1] => 1,
-                $originalslotids[3] => 2,
-                $originalslotids[2] => 2,
-                $originalslotids[4] => 2,
-                $originalslotids[5] => 2,
-                $originalslotids[7] => 2,
-                $originalslotids[6] => 3,
-                $originalslotids[8] => 4,
-        ), $structure);
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
+                'Heading',
+                array('TF3', 2, 'truefalse'),
+            ), $structure);
+    }
+    public function test_move_slot_on_same_page() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
+                array('TF3', 1, 'truefalse'),
+            ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 
-        // Slots move 1 > 2. Page changed. Page 2 becomes page 1. Pages reordered.
-        $idmove = $structure->get_question_in_slot(1)->slotid;
-        $idbefore = $structure->get_question_in_slot(2)->slotid;
-        $structure->move_slot($idmove, $idbefore, 2);
+        $idtomove = $structure->get_question_in_slot(2)->slotid;
+        $idmoveafter = $structure->get_question_in_slot(3)->slotid;
+        $structure->move_slot($idtomove, $idmoveafter, '1');
 
-        // Having called move, we need to reload $structure.
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
         $this->assert_quiz_layout(array(
-                $originalslotids[3] => 1,
-                $originalslotids[1] => 1,
-                $originalslotids[2] => 1,
-                $originalslotids[4] => 1,
-                $originalslotids[5] => 1,
-                $originalslotids[7] => 1,
-                $originalslotids[6] => 2,
-                $originalslotids[8] => 3,
+                array('TF1', 1, 'truefalse'),
+                array('TF3', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
         ), $structure);
+    }
+
+    public function test_move_slot_up_onto_previous_page() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
+                array('TF3', 2, 'truefalse'),
+        ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 
-        // Slots move 7 > 3. Page changed. Page 3 becomes page 2. Pages reordered.
-        $idmove = $structure->get_question_in_slot(7)->slotid;
-        $idbefore = $structure->get_question_in_slot(2)->slotid;
-        $structure->move_slot($idmove, $idbefore, 1);
+        $idtomove = $structure->get_question_in_slot(3)->slotid;
+        $idmoveafter = $structure->get_question_in_slot(1)->slotid;
+        $structure->move_slot($idtomove, $idmoveafter, '1');
 
-        // Having called move, we need to reload $structure.
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
         $this->assert_quiz_layout(array(
-                $originalslotids[3] => 1,
-                $originalslotids[1] => 1,
-                $originalslotids[6] => 1,
-                $originalslotids[2] => 1,
-                $originalslotids[4] => 1,
-                $originalslotids[5] => 1,
-                $originalslotids[7] => 1,
-                $originalslotids[8] => 2,
+                array('TF1', 1, 'truefalse'),
+                array('TF3', 1, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
         ), $structure);
+    }
 
-        // Slots move 2 > top. No page changes.
-        $idmove = $structure->get_question_in_slot(2)->slotid;
-        $structure->move_slot($idmove, 0, 1);
+    public function test_move_slot_emptying_a_page_renumbers_pages() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
+                array('TF3', 3, 'truefalse'),
+        ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+
+        $idtomove = $structure->get_question_in_slot(2)->slotid;
+        $idmoveafter = $structure->get_question_in_slot(3)->slotid;
+        $structure->move_slot($idtomove, $idmoveafter, '3');
 
-        // Having called move, we need to reload $structure.
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
         $this->assert_quiz_layout(array(
-                $originalslotids[1] => 1,
-                $originalslotids[3] => 1,
-                $originalslotids[6] => 1,
-                $originalslotids[2] => 1,
-                $originalslotids[4] => 1,
-                $originalslotids[5] => 1,
-                $originalslotids[7] => 1,
-                $originalslotids[8] => 2,
+                array('TF1', 1, 'truefalse'),
+                array('TF3', 2, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
         ), $structure);
     }
 
-    /**
-     * Test removing slots from a quiz.
-     */
-    public function test_quiz_remove_slot() {
-        global $SITE, $DB;
+    public function test_move_slot_too_small_page_number_detected() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
+                array('TF3', 3, 'truefalse'),
+        ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 
-        $this->resetAfterTest(true);
-        $this->setAdminUser();
+        $idtomove = $structure->get_question_in_slot(3)->slotid;
+        $idmoveafter = $structure->get_question_in_slot(2)->slotid;
+        $this->setExpectedException('coding_exception');
+        $structure->move_slot($idtomove, $idmoveafter, '1');
+    }
 
-        // Setup a quiz with 1 standard and 1 random question.
-        $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
-        $quiz = $quizgenerator->create_instance(array('course' => $SITE->id, 'questionsperpage' => 3, 'grade' => 100.0));
-        $cm = get_coursemodule_from_instance('quiz', $quiz->id, $SITE->id);
+    public function test_move_slot_too_large_page_number_detected() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
+                array('TF3', 3, 'truefalse'),
+        ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 
-        $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
-        $cat = $questiongenerator->create_question_category();
-        $standardq = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
+        $idtomove = $structure->get_question_in_slot(1)->slotid;
+        $idmoveafter = $structure->get_question_in_slot(2)->slotid;
+        $this->setExpectedException('coding_exception');
+        $structure->move_slot($idtomove, $idmoveafter, '4');
+    }
+
+    public function test_move_slot_within_section() {
+        $quizobj = $this->create_test_quiz(array(
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
+                'Heading 2',
+                array('TF3', 2, 'truefalse'),
+            ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 
-        quiz_add_quiz_question($standardq->id, $quiz);
-        quiz_add_random_questions($quiz, 0, $cat->id, 1, false);
+        $idtomove = $structure->get_question_in_slot(1)->slotid;
+        $idmoveafter = $structure->get_question_in_slot(2)->slotid;
+        $structure->move_slot($idtomove, $idmoveafter, '1');
 
-        // Get the random question.
-        $randomq = $DB->get_record('question', array('qtype' => 'random'));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+        $this->assert_quiz_layout(array(
+                'Heading 1',
+                array('TF2', 1, 'truefalse'),
+                array('TF1', 1, 'truefalse'),
+                'Heading 2',
+                array('TF3', 2, 'truefalse'),
+            ), $structure);
+    }
 
-        $quizobj = new quiz($quiz, $cm, $SITE);
+    public function test_move_slot_to_new_section() {
+        $quizobj = $this->create_test_quiz(array(
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
+                'Heading 2',
+                array('TF3', 2, 'truefalse'),
+            ));
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 
-        // Check that the setup looks right.
-        $this->assertEquals(2, $structure->get_question_count());
-        $this->assertEquals($standardq->id, $structure->get_question_in_slot(1)->questionid);
-        $this->assertEquals($randomq->id, $structure->get_question_in_slot(2)->questionid);
+        $idtomove = $structure->get_question_in_slot(2)->slotid;
+        $idmoveafter = $structure->get_question_in_slot(3)->slotid;
+        $structure->move_slot($idtomove, $idmoveafter, '2');
 
-        // Remove the standard question.
-        $structure->remove_slot($quiz, 1);
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+        $this->assert_quiz_layout(array(
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+                'Heading 2',
+                array('TF3', 2, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
+            ), $structure);
+    }
 
-        $alteredstructure = \mod_quiz\structure::create_for_quiz($quizobj);
+    public function test_move_slot_to_start() {
+        $quizobj = $this->create_test_quiz(array(
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+                'Heading 2',
+                array('TF2', 2, 'truefalse'),
+                array('TF3', 2, 'truefalse'),
+            ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 
-        // Check the new ordering, and that the slot number was updated.
-        $this->assertEquals(1, $alteredstructure->get_question_count());
-        $this->assertEquals($randomq->id, $alteredstructure->get_question_in_slot(1)->questionid);
+        $idtomove = $structure->get_question_in_slot(3)->slotid;
+        $structure->move_slot($idtomove, 0, '1');
 
-        // Check that the ordinary question was not deleted.
-        $this->assertTrue($DB->record_exists('question', array('id' => $standardq->id)));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+        $this->assert_quiz_layout(array(
+                'Heading 1',
+                array('TF3', 1, 'truefalse'),
+                array('TF1', 1, 'truefalse'),
+                'Heading 2',
+                array('TF2', 2, 'truefalse'),
+            ), $structure);
+    }
 
-        // Remove the random question.
-        $structure->remove_slot($quiz, 1);
-        $alteredstructure = \mod_quiz\structure::create_for_quiz($quizobj);
+    public function test_move_slot_to_down_start_of_second_section() {
+        $quizobj = $this->create_test_quiz(array(
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
+                'Heading 2',
+                array('TF3', 2, 'truefalse'),
+            ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 
-        // Check that new ordering.
-        $this->assertEquals(0, $alteredstructure->get_question_count());
+        $idtomove = $structure->get_question_in_slot(2)->slotid;
+        $idmoveafter = $structure->get_question_in_slot(2)->slotid;
+        $structure->move_slot($idtomove, $idmoveafter, '2');
 
-        // Check that the random question was deleted.
-        $this->assertFalse($DB->record_exists('question', array('id' => $randomq->id)));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+        $this->assert_quiz_layout(array(
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+                'Heading 2',
+                array('TF2', 2, 'truefalse'),
+                array('TF3', 2, 'truefalse'),
+            ), $structure);
     }
 
-    /**
-     * Test updating pagebreaks in the quiz.
-     */
-    public function test_update_page_break() {
-        // Create a test quiz with 8 questions.
-        list($quiz, $cm, $course) = $this->prepare_quiz_data();
-        $this->add_eight_questions_to_the_quiz($quiz);
-        $quizobj = new quiz($quiz, $cm, $course);
+    public function test_move_slot_up_to_start_of_second_section() {
+        $quizobj = $this->create_test_quiz(array(
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+                'Heading 2',
+                array('TF2', 2, 'truefalse'),
+                'Heading 3',
+                array('TF3', 3, 'truefalse'),
+                array('TF4', 3, 'truefalse'),
+            ));
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 
-        // Store the original order of slots, so we can assert what has changed.
-        $originalslotids = array();
-        foreach ($structure->get_slots() as $slot) {
-            $originalslotids[$slot->slot] = $slot->id;
-        }
+        $idtomove = $structure->get_question_in_slot(3)->slotid;
+        $idmoveafter = $structure->get_question_in_slot(1)->slotid;
+        $structure->move_slot($idtomove, $idmoveafter, '2');
 
-        // Test removing a page break.
-        $slotid = $structure->get_question_in_slot(2)->slotid;
-        $type = \mod_quiz\repaginate::LINK;
-        $slots = $structure->update_page_break($quiz, $slotid, $type);
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+        $this->assert_quiz_layout(array(
+                'Heading 1',
+                array('TF1', 1, 'truefalse'),
+                'Heading 2',
+                array('TF3', 2, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
+                'Heading 3',
+                array('TF4', 3, 'truefalse'),
+            ), $structure);
+    }
+
+    public function test_quiz_remove_slot() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
+                'Heading 2',
+                array('TF3', 2, 'truefalse'),
+            ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+
+        $structure->remove_slot(2);
 
-        // Having called update page break, we need to reload $structure.
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
         $this->assert_quiz_layout(array(
-                    $originalslotids[1] => 1,
-                    $originalslotids[2] => 1,
-                    $originalslotids[3] => 1,
-                    $originalslotids[4] => 1,
-                    $originalslotids[5] => 1,
-                    $originalslotids[6] => 1,
-                    $originalslotids[7] => 2,
-                    $originalslotids[8] => 3,
-                ), $structure);
-
-        // Test adding a page break.
-        $slotid = $structure->get_question_in_slot(2)->slotid;
-        $type = \mod_quiz\repaginate::UNLINK;
-        $slots = $structure->update_page_break($quiz, $slotid, $type);
+                array('TF1', 1, 'truefalse'),
+                'Heading 2',
+                array('TF3', 2, 'truefalse'),
+            ), $structure);
+    }
+
+    public function test_quiz_removing_a_random_question_deletes_the_question() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+            ));
+
+        $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $cat = $questiongenerator->create_question_category();
+        quiz_add_random_questions($quizobj->get_quiz(), 1, $cat->id, 1, false);
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+        $randomq = $DB->get_record('question', array('qtype' => 'random'));
+
+        $structure->remove_slot(2);
 
-        // Having called update page break, we need to reload $structure.
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
         $this->assert_quiz_layout(array(
-                    $originalslotids[1] => 1,
-                    $originalslotids[2] => 2,
-                    $originalslotids[3] => 2,
-                    $originalslotids[4] => 2,
-                    $originalslotids[5] => 2,
-                    $originalslotids[6] => 2,
-                    $originalslotids[7] => 3,
-                    $originalslotids[8] => 4,
-                ), $structure);
+                array('TF1', 1, 'truefalse'),
+            ), $structure);
+        $this->assertFalse($DB->record_exists('question', array('id' => $randomq->id)));
     }
 
-    /**
-     * Populate quiz with eight questions.
-     * @param stdClass $quiz the quiz to add to.
-     */
-    public function add_eight_questions_to_the_quiz($quiz) {
-        // We add 8 numerical questions with this layout:
-        // Slot 1 2 3 4 5 6 7 8
-        // Page 1 2 2 2 2 2 3 4.
+    public function test_cannot_remove_last_slot_in_a_section() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
+                'Heading 2',
+                array('TF3', 2, 'truefalse'),
+            ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+
+        $this->setExpectedException('coding_exception');
+        $structure->remove_slot(3);
+    }
 
-        // Create slots.
-        $pagenumber = 1;
-        $pagenumberdefaults = array(2, 7, 8);
+    public function test_add_question_updates_headings() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                'Heading 2',
+                array('TF2', 2, 'truefalse'),
+        ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 
-        // Create a couple of questions.
         $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $cat = $questiongenerator->create_question_category();
+        $q = $questiongenerator->create_question('truefalse', null,
+                array('name' => 'TF3', 'category' => $cat->id));
+
+        quiz_add_quiz_question($q->id, $quizobj->get_quiz(), 1);
 
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+        $this->assert_quiz_layout(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF3', 1, 'truefalse'),
+                'Heading 2',
+                array('TF2', 2, 'truefalse'),
+        ), $structure);
+    }
+
+    public function test_add_question_and_end_does_not_update_headings() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                'Heading 2',
+                array('TF2', 2, 'truefalse'),
+        ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+
+        $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
         $cat = $questiongenerator->create_question_category();
-        for ($i = 0; $i < 8; $i ++) {
-            $numq = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
+        $q = $questiongenerator->create_question('truefalse', null,
+                array('name' => 'TF3', 'category' => $cat->id));
 
-            if (in_array($i + 1, $pagenumberdefaults)) {
-                $pagenumber++;
-            }
-            // Add them to the quiz.
-            quiz_add_quiz_question($numq->id, $quiz, $pagenumber);
-        }
+        quiz_add_quiz_question($q->id, $quizobj->get_quiz(), 0);
+
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+        $this->assert_quiz_layout(array(
+                array('TF1', 1, 'truefalse'),
+                'Heading 2',
+                array('TF2', 2, 'truefalse'),
+                array('TF3', 2, 'truefalse'),
+        ), $structure);
     }
 
-    /**
-     * Test updating pagebreaks in the quiz.
-     */
-    public function test_update_question_dependency() {
-        // Create a test quiz with 8 questions.
-        list($quiz, $cm, $course) = $this->prepare_quiz_data();
-        $this->add_eight_questions_to_the_quiz($quiz);
-        $quizobj = new quiz($quiz, $cm, $course);
+    public function test_remove_page_break() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
+            ));
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 
-        // Store the original order of slots, so we can assert what has changed.
-        $originalslotids = array();
-        foreach ($structure->get_slots() as $slot) {
-            $originalslotids[$slot->slot] = $slot->id;
-        }
+        $slotid = $structure->get_question_in_slot(2)->slotid;
+        $slots = $structure->update_page_break($slotid, \mod_quiz\repaginate::LINK);
+
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+        $this->assert_quiz_layout(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
+            ), $structure);
+    }
+
+    public function test_add_page_break() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
+        ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+
+        $slotid = $structure->get_question_in_slot(2)->slotid;
+        $slots = $structure->update_page_break($slotid, \mod_quiz\repaginate::UNLINK);
+
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
+        $this->assert_quiz_layout(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 2, 'truefalse'),
+        ), $structure);
+    }
+
+    public function test_update_question_dependency() {
+        $quizobj = $this->create_test_quiz(array(
+                array('TF1', 1, 'truefalse'),
+                array('TF2', 1, 'truefalse'),
+        ));
+        $structure = \mod_quiz\structure::create_for_quiz($quizobj);
 
         // Test adding a dependency.
-        $slotid = $structure->get_slot_id_for_slot(3);
+        $slotid = $structure->get_slot_id_for_slot(2);
         $structure->update_question_dependency($slotid, true);
 
         // Having called update page break, we need to reload $structure.
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
-        $this->assertEquals(1, $structure->is_question_dependent_on_previous_slot(3));
+        $this->assertEquals(1, $structure->is_question_dependent_on_previous_slot(2));
 
         // Test removing a dependency.
         $structure->update_question_dependency($slotid, false);
 
         // Having called update page break, we need to reload $structure.
         $structure = \mod_quiz\structure::create_for_quiz($quizobj);
-        $this->assertEquals(0, $structure->is_question_dependent_on_previous_slot(3));
+        $this->assertEquals(0, $structure->is_question_dependent_on_previous_slot(2));
     }
 }
index 5784280..2c395e8 100644 (file)
@@ -22,6 +22,12 @@ This files describes API changes in the quiz code.
 * The array values in mod_quiz_links_to_other_attempts may now be either a moodle_url,
   or renderable (or null). Previously they could only be a moodle_url or null.
 
+* The contents of the navigation block is now not just quiz_nav_question_button-s.
+  It can also contain quiz_nav_section_heading-s. If you have overridden
+  mod_quiz_renderer::navigation_panel, then you may need to account for this.
+  This change also has implications for the Edit quiz page.
+
+
 === 2.8 ===
 
 * Classes that were defined in various lib files have been moved to the classes
index e0cd1fd..6e35268 100644 (file)
@@ -24,7 +24,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2015030900;
+$plugin->version   = 2015032700;
 $plugin->requires  = 2014110400;
 $plugin->component = 'mod_quiz';
 $plugin->cron      = 60;
index 7622f84..d733d38 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop-debug.js and b/mod/quiz/yui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop-debug.js differ
index 51cb233..141e45a 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop-min.js and b/mod/quiz/yui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop-min.js differ
index 357626c..825813e 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop.js and b/mod/quiz/yui/build/moodle-mod_quiz-dragdrop/moodle-mod_quiz-dragdrop.js differ
index df829aa..333cb44 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-modform/moodle-mod_quiz-modform-debug.js and b/mod/quiz/yui/build/moodle-mod_quiz-modform/moodle-mod_quiz-modform-debug.js differ
index fa5bb63..b50fc0e 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-modform/moodle-mod_quiz-modform-min.js and b/mod/quiz/yui/build/moodle-mod_quiz-modform/moodle-mod_quiz-modform-min.js differ
index df829aa..333cb44 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-modform/moodle-mod_quiz-modform.js and b/mod/quiz/yui/build/moodle-mod_quiz-modform/moodle-mod_quiz-modform.js differ
index 8e0c43c..cad151a 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-quizbase/moodle-mod_quiz-quizbase-debug.js and b/mod/quiz/yui/build/moodle-mod_quiz-quizbase/moodle-mod_quiz-quizbase-debug.js differ
index 39c0265..ffdd5de 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-quizbase/moodle-mod_quiz-quizbase-min.js and b/mod/quiz/yui/build/moodle-mod_quiz-quizbase/moodle-mod_quiz-quizbase-min.js differ
index bb24c94..cad151a 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-quizbase/moodle-mod_quiz-quizbase.js and b/mod/quiz/yui/build/moodle-mod_quiz-quizbase/moodle-mod_quiz-quizbase.js differ
index df0a583..cd6d140 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-toolboxes/moodle-mod_quiz-toolboxes-debug.js and b/mod/quiz/yui/build/moodle-mod_quiz-toolboxes/moodle-mod_quiz-toolboxes-debug.js differ
index 06dce00..7309b8e 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-toolboxes/moodle-mod_quiz-toolboxes-min.js and b/mod/quiz/yui/build/moodle-mod_quiz-toolboxes/moodle-mod_quiz-toolboxes-min.js differ
index df0a583..cd6d140 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-toolboxes/moodle-mod_quiz-toolboxes.js and b/mod/quiz/yui/build/moodle-mod_quiz-toolboxes/moodle-mod_quiz-toolboxes.js differ
index 5207d4a..9402899 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-util-page/moodle-mod_quiz-util-page-debug.js and b/mod/quiz/yui/build/moodle-mod_quiz-util-page/moodle-mod_quiz-util-page-debug.js differ
index 73043b6..2f3d100 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-util-page/moodle-mod_quiz-util-page-min.js and b/mod/quiz/yui/build/moodle-mod_quiz-util-page/moodle-mod_quiz-util-page-min.js differ
index 5207d4a..9402899 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-util-page/moodle-mod_quiz-util-page.js and b/mod/quiz/yui/build/moodle-mod_quiz-util-page/moodle-mod_quiz-util-page.js differ
index 2a35d50..9394866 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-util-slot/moodle-mod_quiz-util-slot-debug.js and b/mod/quiz/yui/build/moodle-mod_quiz-util-slot/moodle-mod_quiz-util-slot-debug.js differ
index c5936ed..b4d392c 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-util-slot/moodle-mod_quiz-util-slot-min.js and b/mod/quiz/yui/build/moodle-mod_quiz-util-slot/moodle-mod_quiz-util-slot-min.js differ
index 2a35d50..9394866 100644 (file)
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-util-slot/moodle-mod_quiz-util-slot.js and b/mod/quiz/yui/build/moodle-mod_quiz-util-slot/moodle-mod_quiz-util-slot.js differ
index bcd6683..39ed5de 100644 (file)
@@ -20,7 +20,7 @@ var CSS = {
     PAGE : 'page',
     PAGECONTENT: 'page-content',
     RIGHT: 'right',
-    SECTION: 'section',
+    SECTION: 'slots',
     SECTIONADDMENUS: 'section_add_menus',
     SECTIONHANDLE: 'section-handle',
     SLOTS: 'slots',
index d4682e5..b213807 100644 (file)
@@ -26,34 +26,30 @@ Y.extend(DRAGRESOURCE, M.core.dragdrop, {
         };
 
         // Go through all sections
-        var sectionlistselector = M.mod_quiz.edit.get_section_selector(Y);
-        if (sectionlistselector) {
-            sectionlistselector = '.' + CSS.COURSECONTENT + ' ' + sectionlistselector;
-            this.setup_for_section(sectionlistselector);
-
-            // Initialise drag & drop for all resources/activities
-            var nodeselector = sectionlistselector.slice(CSS.COURSECONTENT.length + 2) + ' li.' + CSS.ACTIVITY;
-            var del = new Y.DD.Delegate({
-                container: '.' + CSS.COURSECONTENT,
-                nodes: nodeselector,
-                target: true,
-                handles: ['.' + CSS.EDITINGMOVE],
-                dragConfig: {groups: this.groups}
-            });
-            del.dd.plug(Y.Plugin.DDProxy, {
-                // Don't move the node at the end of the drag
-                moveOnEnd: false,
-                cloneNode: true
-            });
-            del.dd.plug(Y.Plugin.DDConstrained, {
-                // Keep it inside the .mod-quiz-edit-content
-                constrain: '#' + CSS.SLOTS
-            });
-            del.dd.plug(Y.Plugin.DDWinScroll);
+        this.setup_for_section();
+
+        // Initialise drag & drop for all resources/activities
+        var nodeselector = 'li.' + CSS.ACTIVITY;
+        var del = new Y.DD.Delegate({
+            container: '.' + CSS.COURSECONTENT,
+            nodes: nodeselector,
+            target: true,
+            handles: ['.' + CSS.EDITINGMOVE],
+            dragConfig: {groups: this.groups}
+        });
+        del.dd.plug(Y.Plugin.DDProxy, {
+            // Don't move the node at the end of the drag
+            moveOnEnd: false,
+            cloneNode: true
+        });
+        del.dd.plug(Y.Plugin.DDConstrained, {
+            // Keep it inside the .mod-quiz-edit-content
+            constrain: '#' + CSS.SLOTS
+        });
+        del.dd.plug(Y.Plugin.DDWinScroll);
 
-            M.mod_quiz.quizbase.register_module(this);
-            M.mod_quiz.dragres = this;
-        }
+        M.mod_quiz.quizbase.register_module(this);
+        M.mod_quiz.dragres = this;
     },
 
     /**
@@ -62,15 +58,8 @@ Y.extend(DRAGRESOURCE, M.core.dragdrop, {
      * @method setup_for_section
      * @param {String} baseselector The CSS selector or node to limit scope to
      */
-    setup_for_section: function(baseselector) {
-        Y.Node.all(baseselector).each(function(sectionnode) {
-            var resources = sectionnode.one('.' + CSS.CONTENT + ' ul.' + CSS.SECTION);
-            // See if resources ul exists, if not create one.
-            if (!resources) {
-                resources = Y.Node.create('<ul></ul>');
-                resources.addClass(CSS.SECTION);
-                sectionnode.one('.' + CSS.CONTENT + ' div.' + CSS.SUMMARY).insert(resources, 'after');
-            }
+    setup_for_section: function() {
+        Y.Node.all('.mod-quiz-edit-content ul.slots ul.section').each(function(resources) {
             resources.setAttribute('data-draggroups', this.groups.join(' '));
             // Define empty ul as droptarget, so that item could be moved to empty list
             new Y.DD.Drop({
@@ -80,7 +69,7 @@ Y.extend(DRAGRESOURCE, M.core.dragdrop, {
             });
 
             // Initialise each resource/activity in this section
-            this.setup_for_resource('#' + sectionnode.get('id') + ' li.' + CSS.ACTIVITY);
+            this.setup_for_resource('li.activity');
         }, this);
     },
 
@@ -140,7 +129,7 @@ Y.extend(DRAGRESOURCE, M.core.dragdrop, {
         params['class'] = 'resource';
         params.field = 'move';
         params.id = Number(Y.Moodle.mod_quiz.util.slot.getId(dragnode));
-        params.sectionId = Y.Moodle.core_course.util.section.getId(dropnode.ancestor(M.mod_quiz.edit.get_section_wrapper(Y), true));
+        params.sectionId = Y.Moodle.core_course.util.section.getId(dropnode.ancestor('li.section', true));
 
         var previousslot = dragnode.previous(SELECTOR.SLOT);
         if (previousslot) {
index 4acd948..4648f00 100644 (file)
@@ -14,15 +14,15 @@ Y.extend(DRAGSECTION, M.core.dragdrop, {
     initializer: function() {
         // Set group for parent class
         this.groups = [ CSS.SECTIONDRAGGABLE ];
-        this.samenodeclass = M.mod_quiz.edit.get_sectionwrapperclass();
-        this.parentnodeclass = M.mod_quiz.edit.get_containerclass();
+        this.samenodeclass = 'section';
+        this.parentnodeclass = 'slots';
 
         // Check if we are in single section mode
         if (Y.Node.one('.' + CSS.JUMPMENU)) {
             return false;
         }
         // Initialise sections dragging
-        this.sectionlistselector = M.mod_quiz.edit.get_section_wrapper(Y);
+        this.sectionlistselector = 'li.section';
         if (this.sectionlistselector) {
             this.sectionlistselector = '.' + CSS.COURSECONTENT + ' ' + this.sectionlistselector;
 
@@ -95,12 +95,8 @@ Y.extend(DRAGSECTION, M.core.dragdrop, {
         // Get our drag object
         var drag = e.target;
         // Creat a dummy structure of the outer elemnents for clean styles application
-        var containernode = Y.Node.create('<' + M.mod_quiz.edit.get_containernode() + '>' +
-                                          '</' + M.mod_quiz.edit.get_containernode() + '>');
-        containernode.addClass(M.mod_quiz.edit.get_containerclass());
-        var sectionnode = Y.Node.create('<' + M.mod_quiz.edit.get_sectionwrappernode() + '>' +
-                                        '</' + M.mod_quiz.edit.get_sectionwrappernode() + '>');
-        sectionnode.addClass( M.mod_quiz.edit.get_sectionwrapperclass());
+        var containernode = Y.Node.create('<ul class="slots"></ul>');
+        var sectionnode = Y.Node.create('<ul class="section"></ul>');
         sectionnode.setStyle('margin', 0);
         sectionnode.setContent(drag.get('node').get('innerHTML'));
         containernode.appendChild(sectionnode);
@@ -116,7 +112,7 @@ Y.extend(DRAGSECTION, M.core.dragdrop, {
     },
 
     get_section_index: function(node) {
-        var sectionlistselector = '.' + CSS.COURSECONTENT + ' ' + M.mod_quiz.edit.get_section_selector(Y),
+        var sectionlistselector = '.' + CSS.COURSECONTENT + ' li.section',
             sectionList = Y.all(sectionlistselector),
             nodeIndex = sectionList.indexOf(node),
             zeroIndex = sectionList.indexOf(Y.one('#section-0'));
index 6a524cf..b6ee7aa 100644 (file)
@@ -30,7 +30,6 @@ Y.extend(MODFORM, Y.Base, {
         this.qppSelect = Y.one('#id_questionsperpage');
         this.qppInitialValue = this.qppSelect.get('value');
         this.qppSelect.on('change', this.qppChanged, this);
-        Y.one('#id_shufflequestions').on('change', this.qppChanged, this);
     },
 
     qppChanged: function() {
@@ -43,7 +42,7 @@ Y.extend(MODFORM, Y.Base, {
 
 });
 
-// Ensure that M.course exists and that coursebase is initialised correctly
+// Ensure that M.mod_quiz exists and that coursebase is initialised correctly
 M.mod_quiz = M.mod_quiz || {};
 M.mod_quiz.modform = M.mod_quiz.modform || new MODFORM();
 M.mod_quiz.modform.init = function() {
index 06a0f85..e93fabe 100644 (file)
@@ -77,7 +77,7 @@ M.mod_quiz.edit.swap_sections = function(Y, node1, node2) {
         SECTIONADDMENUS : 'section_add_menus'
     };
 
-    var sectionlist = Y.Node.all('.' + CSS.COURSECONTENT + ' ' + M.mod_quiz.edit.get_section_selector(Y));
+    var sectionlist = Y.Node.all('.' + CSS.COURSECONTENT + ' li.section');
     // Swap menus.
     sectionlist.item(node1).one('.' + CSS.SECTIONADDMENUS).swap(sectionlist.item(node2).one('.' + CSS.SECTIONADDMENUS));
 };
@@ -135,133 +135,3 @@ M.mod_quiz.edit.process_sections = function(Y, sectionlist, response, sectionfro
         }
     }
 };
-
-/**
-* Get sections config for this format, for examples see function definition
-* in the formats.
-*
-* @return {object} section list configuration
-*/
-M.mod_quiz.edit.get_config = function() {
-    return {
-        container_node : 'ul',
-        container_class : 'slots',
-        section_node : 'li',
-        section_class : 'section'
-    };
-};
-
-/**
- * Get section list for this format (usually items inside container_node.container_class selector)
- *
- * @param {YUI} Y YUI3 instance
- * @return {string} section selector
- */
-M.mod_quiz.edit.get_section_selector = function() {
-    var config = M.mod_quiz.edit.get_config();
-    if (config.section_node && config.section_class) {
-        return config.section_node + '.' + config.section_class;
-    }
-    Y.log('section_node and section_class are not defined in M.mod_quiz.edit.get_config', 'warn', 'moodle-mod_quiz-quizbase');
-    return null;
-};
-
-/**
- * Get section wraper for this format (only used in case when each
- * container_node.container_class node is wrapped in some other element).
- *
- * @param {YUI} Y YUI3 instance
- * @return {string} section wrapper selector or M.mod_quiz.format.get_section_selector
- * if section_wrapper_node and section_wrapper_class are not defined in the format config.
- */
-M.mod_quiz.edit.get_section_wrapper = function(Y) {
-    var config = M.mod_quiz.edit.get_config();
-    if (config.section_wrapper_node && config.section_wrapper_class) {
-        return config.section_wrapper_node + '.' + config.section_wrapper_class;
-    }
-    return M.mod_quiz.edit.get_section_selector(Y);
-};
-
-/**
- * Get the tag of container node
- *
- * @return {string} tag of container node.
- */
-M.mod_quiz.edit.get_containernode = function() {
-    var config = M.mod_quiz.edit.get_config();
-    if (config.container_node) {
-        return config.container_node;
-    } else {
-        Y.log('container_node is not defined in M.mod_quiz.edit.get_config', 'warn', 'moodle-mod_quiz-quizbase');
-    }
-};
-
-/**
- * Get the class of container node
- *
- * @return {string} class of the container node.
- */
-M.mod_quiz.edit.get_containerclass = function() {
-    var config = M.mod_quiz.edit.get_config();
-    if (config.container_class) {
-        return config.container_class;
-    } else {
-        Y.log('container_class is not defined in M.mod_quiz.edit.get_config', 'warn', 'moodle-mod_quiz-quizbase');
-    }
-};
-
-/**
- * Get the tag of draggable node (section wrapper if exists, otherwise section)
- *
- * @return {string} tag of the draggable node.
- */
-M.mod_quiz.edit.get_sectionwrappernode = function() {
-    var config = M.mod_quiz.edit.get_config();
-    if (config.section_wrapper_node) {
-        return config.section_wrapper_node;
-    } else {
-        return config.section_node;
-    }
-};
-
-/**
- * Get the class of draggable node (section wrapper if exists, otherwise section)
- *
- * @return {string} class of the draggable node.
- */
-M.mod_quiz.edit.get_sectionwrapperclass = function() {
-    var config = M.mod_quiz.edit.get_config();
-    if (config.section_wrapper_class) {
-        return config.section_wrapper_class;
-    } else {
-        return config.section_class;
-    }
-};
-
-/**
- * Get the tag of section node
- *
- * @return {string} tag of section node.
- */
-M.mod_quiz.edit.get_sectionnode = function() {
-    var config = M.mod_quiz.edit.get_config();
-    if (config.section_node) {
-        return config.section_node;
-    } else {
-        Y.log('section_node is not defined in M.mod_quiz.edit.get_config', 'warn', 'moodle-mod_quiz-quizbase');
-    }
-};
-
-/**
- * Get the class of section node
- *
- * @return {string} class of the section node.
- */
-M.mod_quiz.edit.get_sectionclass = function() {
-    var config = M.mod_quiz.edit.get_config();
-    if (config.section_class) {
-        return config.section_class;
-    } else {
-        Y.log('section_class is not defined in M.mod_quiz.edit.get_config', 'warn', 'moodle-mod_quiz-quizbase');
-    }
-};
index a455a4d..843ad5c 100644 (file)
@@ -437,6 +437,7 @@ Y.extend(RESOURCETOOLBOX, TOOLBOX, {
         Y.Moodle.mod_quiz.util.slot.reorderSlots();
         Y.Moodle.mod_quiz.util.slot.reorderPageBreaks();
         Y.Moodle.mod_quiz.util.page.reorderPages();
+        Y.Moodle.mod_quiz.util.slot.updateOneSlotSections();
         Y.Moodle.mod_quiz.util.slot.updateAllDependencyIcons();
     },
 
index 0d0bd5c..fff7d4b 100644 (file)
@@ -1,8 +1,8 @@
 /**
- * Resource and activity toolbox class.
+ * Section toolbox class.
  *
- * This class is responsible for managing AJAX interactions with activities and resources
- * when viewing a course in editing mode.
+ * This class is responsible for managing AJAX interactions with sections
+ * when adding, editing, removing section headings.
  *
  * @module moodle-mod_quiz-toolboxes
  * @namespace M.mod_quiz.toolboxes
@@ -12,7 +12,7 @@
  * Section toolbox class.
  *
  * This class is responsible for managing AJAX interactions with sections
- * when viewing a course in editing mode.
+ * when adding, editing, removing section headings when editing a quiz.
  *
  * @class section
  * @constructor
@@ -23,6 +23,17 @@ var SECTIONTOOLBOX = function() {
 };
 
 Y.extend(SECTIONTOOLBOX, TOOLBOX, {
+    /**
+     * An Array of events added when editing a max mark field.
+     * These should all be detached when editing is complete.
+     *
+     * @property editsectionevents
+     * @protected
+     * @type Array
+     * @protected
+     */
+    editsectionevents: [],
+
     /**
      * Initialize the section toolboxes module.
      *
@@ -31,17 +42,290 @@ Y.extend(SECTIONTOOLBOX, TOOLBOX, {
      * @method initializer
      * @protected
      */
-    initializer : function() {
+    initializer: function() {
         M.mod_quiz.quizbase.register_module(this);
+
+        BODY.delegate('key', this.handle_data_action, 'down:enter', SELECTOR.ACTIVITYACTION, this);
+        Y.delegate('click', this.handle_data_action, BODY, SELECTOR.ACTIVITYACTION, this);
+        Y.delegate('change', this.handle_data_action, BODY, SELECTOR.EDITSHUFFLEQUESTIONSACTION, this);
+    },
+
+    /**
+     * Handles the delegation event. When this is fired someone has triggered an action.
+     *
+     * Note not all actions will result in an AJAX enhancement.
+     *
+     * @protected
+     * @method handle_data_action
+     * @param {EventFacade} ev The event that was triggered.
+     * @returns {boolean}
+     */
+    handle_data_action: function(ev) {
+        // We need to get the anchor element that triggered this event.
+        var node = ev.target;
+        if (!node.test('a') && !node.test('input[data-action]')) {
+            node = node.ancestor(SELECTOR.ACTIVITYACTION);
+        }
+
+        // From the anchor we can get both the activity (added during initialisation) and the action being
+        // performed (added by the UI as a data attribute).
+        var action = node.getData('action'),
+            activity = node.ancestor(SELECTOR.ACTIVITYLI);
+
+        if ((!node.test('a') && !node.test('input[data-action]')) || !action || !activity) {
+            // It wasn't a valid action node.
+            return;
+        }
+
+        // Switch based upon the action and do the desired thing.
+        switch (action) {
+            case 'edit_section_title':
+                // The user wishes to edit the section headings.
+                this.edit_section_title(ev, node, activity, action);
+                break;
+            case 'shuffle_questions':
+                // The user wishes to edit the shuffle questions of the section (resource).
+                this.edit_shuffle_questions(ev, node, activity, action);
+                break;
+            case 'deletesection':
+                // The user is deleting the activity.
+                this.delete_section_with_confirmation(ev, node, activity, action);
+                break;
+            default:
+                // Nothing to do here!
+                break;
+        }
+    },
+
+    /**
+     * Deletes the given section heading after confirmation.
+     *
+     * @protected
+     * @method delete_section_with_confirmation
+     * @param {EventFacade} ev The event that was fired.
+     * @param {Node} button The button that triggered this action.
+     * @param {Node} activity The activity node that this action will be performed on.
+     * @chainable
+     */
+    delete_section_with_confirmation: function(ev, button, activity) {
+        // Prevent the default button action.
+        ev.preventDefault();
+
+        // Create the confirmation dialogue.
+        var confirm = new M.core.confirm({
+            question: M.util.get_string('confirmremovesectionheading', 'quiz', activity.get('aria-label')),
+            modal: true
+        });
+
+        // If it is confirmed.
+        confirm.on('complete-yes', function() {
+
+            var spinner = M.util.add_spinner(Y, activity.one(SELECTOR.ACTIONAREA));
+            var data = {
+                'class':  'section',
+                'action': 'DELETE',
+                'id':     activity.get('id').replace('section-', '')
+            };
+            this.send_request(data, spinner, function(response) {
+                if (response.deleted) {
+                    window.location.reload(true);
+                }
+            });
+
+        }, this);
+    },
+
+    /**
+     * Edit the edit section title for the section
+     *
+     * @protected
+     * @method edit_section_title
+     * @param {EventFacade} ev The event that was fired.
+     * @param {Node} button The button that triggered this action.
+     * @param {Node} activity The activity node that this action will be performed on.
+     * @param {String} action The action that has been requested.
+     * @return Boolean
+     */
+    edit_section_title: function(ev, button, activity) {
+        // Get the element we're working on
+        var activityid = activity.get('id').replace('section-', ''),
+            instancesection  = activity.one(SELECTOR.INSTANCESECTION),
+            thisevent,
+            anchor = instancesection, // Grab the anchor so that we can swap it with the edit form.
+            data = {
+                'class': 'section',
+                'field': 'getsectiontitle',
+                'id':    activityid
+            };
+
+        // Prevent the default actions.
+        ev.preventDefault();
+
+        this.send_request(data, null, function(response) {
+            // Try to retrieve the existing string from the server.
+            var oldtext = response.instancesection;
+
+            // Create the editor and submit button.
+            var editform = Y.Node.create('<form action="#" />');
+            var editinstructions = Y.Node.create('<span class="' + CSS.EDITINSTRUCTIONS + '" id="id_editinstructions" />')
+                .set('innerHTML', M.util.get_string('edittitleinstructions', 'moodle'));
+            var editor = Y.Node.create('<input name="section" type="text" />').setAttrs({
+                'value': oldtext,
+                'autocomplete': 'off',
+                'aria-describedby': 'id_editinstructions',
+                'maxLength': '255' // This is the maxlength in DB.
+            });
+
+            // Clear the existing content and put the editor in.
+            editform.appendChild(editor);
+            editform.setData('anchor', anchor);
+            instancesection.insert(editinstructions, 'before');
+            anchor.replace(editform);
+
+            // Focus and select the editor text.
+            editor.focus().select();
+            // Cancel the edit if we lose focus or the escape key is pressed.
+            thisevent = editor.on('blur', this.edit_section_title_cancel, this, activity, false);
+            this.editsectionevents.push(thisevent);
+            thisevent = editor.on('key', this.edit_section_title_cancel, 'esc', this, activity, true);
+            this.editsectionevents.push(thisevent);
+            // Handle form submission.
+            thisevent = editform.on('submit', this.edit_section_title_submit, this, activity, oldtext);
+            this.editsectionevents.push(thisevent);
+        });
+    },
+
+    /**
+     * Handles the submit event when editing section heading.
+     *
+     * @protected
+     * @method edit_section_title_submiy
+     * @param {EventFacade} ev The event that triggered this.
+     * @param {Node} activity The activity whose maxmark we are altering.
+     * @param {String} oldtext The original maxmark the activity or resource had.
+     */
+    edit_section_title_submit: function(ev, activity, oldtext) {
+         // We don't actually want to submit anything.
+        ev.preventDefault();
+        var newtext = Y.Lang.trim(activity.one(SELECTOR.SECTIONFORM + ' ' + SELECTOR.SECTIONINPUT).get('value'));
+        var spinner = M.util.add_spinner(Y, activity.one(SELECTOR.INSTANCESECTIONAREA));
+        this.edit_section_title_clear(activity);
+        if (newtext !== null && newtext !== oldtext) {
+            activity.one(SELECTOR.INSTANCESECTION).setContent(newtext);
+            var data = {
+                'class':      'section',
+                'field':      'updatesectiontitle',
+                'newheading': newtext,
+                'id':         activity.get('id').replace('section-', '')
+            };
+            this.send_request(data, spinner, function(response) {
+                if (response) {
+                    activity.one(SELECTOR.INSTANCESECTION).setContent(response.instancesection);
+                    activity.one(SELECTOR.EDITSECTIONICON).set('title',
+                            M.util.get_string('sectionheadingedit', 'quiz', response.instancesection));
+                    activity.one(SELECTOR.EDITSECTIONICON).set('alt',
+                            M.util.get_string('sectionheadingedit', 'quiz', response.instancesection));
+                    activity.one(SELECTOR.DELETESECTIONICON).set('title',
+                            M.util.get_string('sectionheadingremove', 'quiz', response.instancesection));
+                    activity.one(SELECTOR.DELETESECTIONICON).set('alt',
+                            M.util.get_string('sectionheadingremove', 'quiz', response.instancesection));
+                }
+            });
+        }
+    },
+
+    /**
+     * Handles the cancel event when editing the activity or resources maxmark.
+     *
+     * @protected
+     * @method edit_maxmark_cancel
+     * @param {EventFacade} ev The event that triggered this.
+     * @param {Node} activity The activity whose maxmark we are altering.
+     * @param {Boolean} preventdefault If true we should prevent the default action from occuring.
+     */
+    edit_section_title_cancel: function(ev, activity, preventdefault) {
+        if (preventdefault) {
+            ev.preventDefault();
+        }
+        this.edit_section_title_clear(activity);
+    },
+
+    /**
+     * Handles clearing the editing UI and returning things to the original state they were in.
+     *
+     * @protected
+     * @method edit_maxmark_clear
+     * @param {Node} activity  The activity whose maxmark we were altering.
+     */
+    edit_section_title_clear: function(activity) {
+        // Detach all listen events to prevent duplicate triggers
+        new Y.EventHandle(this.editsectionevents).detach();
+
+        var editform = activity.one(SELECTOR.SECTIONFORM),
+            instructions = activity.one('#id_editinstructions');
+        if (editform) {
+            editform.replace(editform.getData('anchor'));
+        }
+        if (instructions) {
+            instructions.remove();
+        }
+
+        // Refocus the link which was clicked originally so the user can continue using keyboard nav.
+        Y.later(100, this, function() {
+            activity.one(SELECTOR.EDITSECTION).focus();
+        });
+
+        // This hack is to keep Behat happy until they release a version of
+        // MinkSelenium2Driver that fixes
+        // https://github.com/Behat/MinkSelenium2Driver/issues/80.
+        if (!Y.one('input[name=section]')) {
+            Y.one('body').append('<input type="text" name="section" style="display: none">');
+        }
+    },
+
+    /**
+     * Edit the edit shuffle questions for the section
+     *
+     * @protected
+     * @method edit_shuffle_questions
+     * @param {EventFacade} ev The event that was fired.
+     * @param {Node} button The button that triggered this action.
+     * @param {Node} activity The activity node that this action will be performed on.
+     * @param {String} action The action that has been requested.
+     * @return Boolean
+     */
+    edit_shuffle_questions: function(ev, button, activity) {
+        var newvalue;
+        if (activity.one(SELECTOR.EDITSHUFFLEQUESTIONSACTION).get('checked')) {
+            newvalue = 1;
+        } else {
+            newvalue = 0;
+        }
+
+        // Get the element we're working on
+        var data = {
+            'class': 'section',
+            'field': 'updateshufflequestions',
+            'id': activity.get('id').replace('section-', ''),
+            'newshuffle': newvalue
+        };
+
+        // Prevent the default actions.
+        ev.preventDefault();
+
+        // Send request.
+        var spinner = M.util.add_spinner(Y, activity.one(SELECTOR.EDITSHUFFLEAREA));
+        this.send_request(data, spinner);
     }
+
 },  {
-    NAME : 'mod_quiz-section-toolbox',
-    ATTRS : {
-        courseid : {
-            'value' : 0
+    NAME: 'mod_quiz-section-toolbox',
+    ATTRS: {
+        courseid: {
+            'value': 0
         },
-        quizid : {
-            'value' : 0
+        quizid: {
+            'value': 0
         }
     }
 });
index be5eed1..4301ed2 100644 (file)
@@ -17,7 +17,7 @@ var CSS = {
         DIMCLASS : 'dimmed',
         DIMMEDTEXT : 'dimmed_text',
         EDITINSTRUCTIONS : 'editinstructions',
-        EDITINGMAXMARK: 'editor_displayed',
+        EDITINGMAXMARK : 'editor_displayed',
         HIDE : 'hide',
         JOIN: 'page_join',
         MODINDENTCOUNT : 'mod-indent-',
@@ -33,26 +33,35 @@ var CSS = {
     SELECTOR = {
         ACTIONAREA: '.actions',
         ACTIONLINKTEXT : '.actionlinktext',
-        ACTIVITYACTION : 'a.cm-edit-action[data-action], a.editing_maxmark',
+        ACTIVITYACTION : 'a.cm-edit-action[data-action], a.editing_maxmark, a.editing_section, input.shuffle_questions',
         ACTIVITYFORM : 'span.instancemaxmarkcontainer form',
         ACTIVITYINSTANCE : '.' + CSS.ACTIVITYINSTANCE,
-        ACTIVITYLINK: '.' + CSS.ACTIVITYINSTANCE + ' > a',
-        ACTIVITYLI : 'li.activity',
+        SECTIONINSTANCE : '.sectioninstance',
+        ACTIVITYLI : 'li.activity, li.section',
         ACTIVITYMAXMARK : 'input[name=maxmark]',
         COMMANDSPAN : '.commands',
         CONTENTAFTERLINK : 'div.contentafterlink',
         CONTENTWITHOUTLINK : 'div.contentwithoutlink',
+        DELETESECTIONICON: 'a.editing_delete img',
         EDITMAXMARK: 'a.editing_maxmark',
+        EDITSECTION: 'a.editing_section',
+        EDITSECTIONICON: 'a.editing_section img',
+        EDITSHUFFLEQUESTIONSACTION: 'input.cm-edit-action[data-action]',
+        EDITSHUFFLEAREA: '.instanceshufflequestions .shuffle-progress',
         HIDE : 'a.editing_hide',
         HIGHLIGHT : 'a.editing_highlight',
         INSTANCENAME : 'span.instancename',
         INSTANCEMAXMARK : 'span.instancemaxmark',
+        INSTANCESECTION : 'span.instancesection',
+        INSTANCESECTIONAREA : 'div.section-heading',
         MODINDENTDIV : '.mod-indent',
         MODINDENTOUTER : '.mod-indent-outer',
         NUMQUESTIONS : '.numberofquestions',
         PAGECONTENT : 'div#page-content',
         PAGELI : 'li.page',
         SECTIONUL : 'ul.section',
+        SECTIONFORM : '.instancesectioncontainer form',
+        SECTIONINPUT : 'input[name=section]',
         SHOW : 'a.' + CSS.SHOW,
         SLOTLI : 'li.slot',
         SUMMARKS : '.mod_quiz_summarks'
index 14576b4..85079e3 100644 (file)
@@ -30,7 +30,7 @@ Y.Moodle.mod_quiz.util.page = {
         ACTIONMENUMENU: 'ul.menu',
         PAGE: 'li.page',
         INSTANCENAME: '.instancename',
-        NUMBER: 'span.text'
+        NUMBER: 'h4'
     },
 
     /**
index 70a5790..9f01ed7 100644 (file)
@@ -160,7 +160,20 @@ Y.Moodle.mod_quiz.util.slot = {
      * @return {node|false} The previous slot node or false.
      */
     getPreviousNumbered: function(slot) {
-        return slot.previous(this.SELECTORS.SLOT + ':not(' + this.SELECTORS.QUESTIONTYPEDESCRIPTION + ')');
+        var previous = slot.previous(this.SELECTORS.SLOT + ':not(' + this.SELECTORS.QUESTIONTYPEDESCRIPTION + ')');
+        if (previous) {
+            return previous;
+        }
+
+        var section = slot.ancestor('li.section').previous('li.section');
+        while (section) {
+            var questions = section.all(this.SELECTORS.SLOT + ':not(' + this.SELECTORS.QUESTIONTYPEDESCRIPTION + ')');
+            if (questions.size() > 0) {
+                return questions.item(questions.size() - 1);
+            }
+            section = section.previous('li.section');
+        }
+        return false;
     },
 
     /**
@@ -196,6 +209,22 @@ Y.Moodle.mod_quiz.util.slot = {
         }, this);
     },
 
+    /**
+     * Add class only-has-one-slot to those sections that need it.
+     *
+     * @method updateOneSlotSections
+     * @return void
+     */
+    updateOneSlotSections: function() {
+        Y.all('.mod-quiz-edit-content ul.slots li.section').each(function(section) {
+            if (section.all(this.SELECTORS.SLOT).size() > 1) {
+                section.removeClass('only-has-one-slot');
+            } else {
+                section.addClass('only-has-one-slot');
+            }
+        }, this);
+    },
+
     /**
      * Remove a slot and related elements from the list of slots.
      *
@@ -278,16 +307,17 @@ Y.Moodle.mod_quiz.util.slot = {
         // Get list of slot nodes.
         var slots = this.getSlots(), slotnumber = 0;
         // Loop through slots incrementing the number each time.
-        slots.each (function(slot, key) {
+        slots.each(function(slot, key) {
             slotnumber++;
             var pagebreak = this.getPageBreak(slot);
-            // Last slot won't have a page break.
-            if (!pagebreak && key === slots.size() - 1) {
+            var nextitem = slot.next('li.activity');
+            if (!nextitem) {
+                // Last slot in a section. Should not have an icon.
                 return;
             }
 
             // No pagebreak and not last slot. Add one.
-            if (!pagebreak && key !== slots.size() - 1) {
+            if (!pagebreak) {
                 pagebreak = this.addPageBreak(slot);
             }
 
@@ -301,7 +331,7 @@ Y.Moodle.mod_quiz.util.slot = {
 
             // Get the correct title.
             var action = '', iconname = '';
-            if (Y.Moodle.mod_quiz.util.page.isPage(slot.next('li.activity'))) {
+            if (Y.Moodle.mod_quiz.util.page.isPage(nextitem)) {
                 action = 'removepagebreak';
                 iconname = 'e/remove_page_break';
             } else {