MDL-51802 course: allow to edit section names on the course page

This commit is contained in:
Marina Glancy 2016-02-03 17:28:56 +08:00
parent cdc5f9785b
commit f26481c2dc
17 changed files with 442 additions and 54 deletions

View File

@ -114,28 +114,7 @@ if ($mform->is_cancelled()){
$data->availability = null;
}
}
$DB->update_record('course_sections', $data);
rebuild_course_cache($course->id, true);
if (isset($data->section)) {
// Usually edit form does not change relative section number but just in case.
$sectionnum = $data->section;
}
course_get_format($course->id)->update_section_format_options($data);
// Set section info, as this might not be present in form_data.
if (!isset($data->section)) {
$data->section = $sectionnum;
}
// Trigger an event for course section update.
$event = \core\event\course_section_updated::create(
array(
'objectid' => $data->id,
'courseid' => $course->id,
'context' => $context,
'other' => array('sectionnum' => $data->section)
)
);
$event->trigger();
course_update_section($course, $section, $data);
$PAGE->navigation->clear_cache();
redirect(course_get_url($course, $section, array('sr' => $sectionreturn)));

View File

@ -1030,6 +1030,76 @@ abstract class format_base {
return true;
}
/**
* Prepares the templateable object to display section name
*
* @param \section_info|\stdClass $section
* @param bool $linkifneeded
* @param bool $editable
* @param null|lang_string|string $edithint
* @param null|lang_string|string $editlabel
* @return \core\output\inplace_editable
*/
public function inplace_editable_render_section_name($section, $linkifneeded = true,
$editable = null, $edithint = null, $editlabel = null) {
global $USER, $CFG;
require_once($CFG->dirroot.'/course/lib.php');
if ($editable === null) {
$editable = !empty($USER->editing) && has_capability('moodle/course:update',
context_course::instance($section->course));
}
$displayvalue = $title = get_section_name($section->course, $section);
if ($linkifneeded) {
// Display link under the section name if the course format setting is to display one section per page.
$url = course_get_url($section->course, $section->section, array('navigation' => true));
if ($url) {
$displayvalue = html_writer::link($url, $title);
}
$itemtype = 'sectionname';
} else {
// If $linkifneeded==false, we never display the link (this is used when rendering the section header).
// Itemtype 'sectionnamenl' (nl=no link) will tell the callback that link should not be rendered -
// there is no other way callback can know where we display the section name.
$itemtype = 'sectionnamenl';
}
if (empty($edithint)) {
$edithint = new lang_string('editsectionname');
}
if (empty($editlabel)) {
$editlabel = new lang_string('newsectionname', '', $title);
}
return new \core\output\inplace_editable('format_' . $this->format, $itemtype, $section->id, $editable,
$displayvalue, $section->name, $edithint, $editlabel);
}
/**
* Updates the value in the database and modifies this object respectively.
*
* ALWAYS check user permissions before performing an update! Throw exceptions if permissions are not sufficient
* or value is not legit.
*
* @param stdClass $section
* @param string $itemtype
* @param mixed $newvalue
* @return \core\output\inplace_editable
*/
public function inplace_editable_update_section_name($section, $itemtype, $newvalue) {
if ($itemtype === 'sectionname' || $itemtype === 'sectionnamenl') {
require_login($section->course, false, null, true, true);
$context = context_course::instance($section->course);
require_capability('moodle/course:update', $context);
$newtitle = clean_param($newvalue, PARAM_TEXT);
if (strval($section->name) !== strval($newtitle)) {
course_update_section($section->course, $section, array('name' => $newtitle));
}
return $this->inplace_editable_render_section_name($section, ($itemtype === 'sectionname'), true);
}
}
}
/**

View File

@ -71,7 +71,7 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
abstract protected function page_title();
/**
* Generate the section title
* Generate the section title, wraps it in a link to the section page if page is to be displayed on a separate page
*
* @param stdClass $section The course_section entry from DB
* @param stdClass $course The course entry from DB
@ -86,6 +86,17 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
return $title;
}
/**
* Generate the section title to be displayed on the section page, without a link
*
* @param stdClass $section The course_section entry from DB
* @param stdClass $course The course entry from DB
* @return string HTML to output.
*/
public function section_title_without_link($section, $course) {
return get_section_name($course, $section);
}
/**
* Generate the edit control action menu
*
@ -193,7 +204,7 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
'aria-label'=> get_section_name($course, $section)));
// Create a span that contains the section title to be used to create the keyboard section move menu.
$o .= html_writer::tag('span', $this->section_title($section, $course), array('class' => 'hidden sectionname'));
$o .= html_writer::tag('span', get_section_name($course, $section), array('class' => 'hidden sectionname'));
$leftcontent = $this->section_left_content($section, $course, $onsectionpage);
$o.= html_writer::tag('div', $leftcontent, array('class' => 'left side'));
@ -788,7 +799,7 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
if (!$thissection->visible) {
$classes .= ' dimmed_text';
}
$sectionname = html_writer::tag('span', get_section_name($course, $displaysection));
$sectionname = html_writer::tag('span', $this->section_title_without_link($thissection, $course));
$sectiontitle .= $this->output->heading($sectionname, 3, $classes);
$sectiontitle .= html_writer::end_tag('div');

View File

@ -25,7 +25,9 @@
$string['currentsection'] = 'This topic';
$string['editsection'] = 'Edit topic';
$string['editsectionname'] = 'Edit topic name';
$string['deletesection'] = 'Delete topic';
$string['newsectionname'] = 'New name for topic {$a}';
$string['sectionname'] = 'Topic';
$string['pluginname'] = 'Topics format';
$string['section0name'] = 'General';

View File

@ -382,4 +382,45 @@ class format_topics extends format_base {
public function can_delete_section($section) {
return true;
}
/**
* Prepares the templateable object to display section name
*
* @param \section_info|\stdClass $section
* @param bool $linkifneeded
* @param bool $editable
* @param null|lang_string|string $edithint
* @param null|lang_string|string $editlabel
* @return \core\output\inplace_editable
*/
public function inplace_editable_render_section_name($section, $linkifneeded = true,
$editable = null, $edithint = null, $editlabel = null) {
if (empty($edithint)) {
$edithint = new lang_string('editsectionname', 'format_topics');
}
if (empty($editlabel)) {
$title = get_section_name($section->course, $section);
$editlabel = new lang_string('newsectionname', 'format_topics', $title);
}
return parent::inplace_editable_render_section_name($section, $linkifneeded, $editable, $edithint, $editlabel);
}
}
/**
* Implements callback inplace_editable() allowing to edit values in-place
*
* @param string $itemtype
* @param int $itemid
* @param mixed $newvalue
* @return \core\output\inplace_editable
*/
function format_topics_inplace_editable($itemtype, $itemid, $newvalue) {
global $DB, $CFG;
require_once($CFG->dirroot . '/course/lib.php');
if ($itemtype === 'sectionname' || $itemtype === 'sectionnamenl') {
$section = $DB->get_record_sql(
'SELECT s.* FROM {course_sections} s JOIN {course} c ON s.course = c.id WHERE s.id = ? AND c.format = ?',
array($itemid, 'topics'), MUST_EXIST);
return course_get_format($section->course)->inplace_editable_update_section_name($section, $itemtype, $newvalue);
}
}

View File

@ -73,6 +73,28 @@ class format_topics_renderer extends format_section_renderer_base {
return get_string('topicoutline');
}
/**
* Generate the section title, wraps it in a link to the section page if page is to be displayed on a separate page
*
* @param stdClass $section The course_section entry from DB
* @param stdClass $course The course entry from DB
* @return string HTML to output.
*/
public function section_title($section, $course) {
return $this->render(course_get_format($course)->inplace_editable_render_section_name($section));
}
/**
* Generate the section title to be displayed on the section page, without a link
*
* @param stdClass $section The course_section entry from DB
* @param stdClass $course The course entry from DB
* @return string HTML to output.
*/
public function section_title_without_link($section, $course) {
return $this->render(course_get_format($course)->inplace_editable_render_section_name($section, false));
}
/**
* Generate the edit control items of a section
*

View File

@ -56,6 +56,18 @@ Feature: Sections can be edited and deleted in topics format
Then I should see "This is the second topic" in the "li#section-2" "css_element"
And I should not see "Topic 2" in the "li#section-2" "css_element"
@javascript
Scenario: Inline edit section name in topics format
When I click on "Edit topic name" "link" in the "li#section-1" "css_element"
And I set the field "New name for topic Topic 1" to "Midterm evaluation"
And I press key "13" in the field "New name for topic Topic 1"
Then I should not see "Topic 1" in the "#region-main" "css_element"
And "New name for topic" "field" should not exist
And I should see "Midterm evaluation" in the "li#section-1" "css_element"
And I follow "Course 1"
And I should not see "Topic 1" in the "#region-main" "css_element"
And I should see "Midterm evaluation" in the "li#section-1" "css_element"
Scenario: Deleting the last section in topics format
When I delete section "5"
Then I should see "Are you absolutely sure you want to completely delete \"Topic 5\" and all the activities it contains?"

View File

@ -146,4 +146,69 @@ class format_topics_testcase extends advanced_testcase {
}
}
}
/**
* Test web service updating section name
*/
public function test_update_inplace_editable() {
global $CFG, $DB, $PAGE;
require_once($CFG->dirroot . '/lib/external/externallib.php');
$this->resetAfterTest();
$user = $this->getDataGenerator()->create_user();
$this->setUser($user);
$course = $this->getDataGenerator()->create_course(array('numsections' => 5, 'format' => 'topics'),
array('createsections' => true));
$section = $DB->get_record('course_sections', array('course' => $course->id, 'section' => 2));
// Call webservice without necessary permissions.
try {
core_external::update_inplace_editable('format_topics', 'sectionname', $section->id, 'New section name');
$this->fail('Exception expected');
} catch (moodle_exception $e) {
$this->assertEquals('Course or activity not accessible. (Not enrolled)',
$e->getMessage());
}
// Change to teacher and make sure that section name can be updated using web service update_inplace_editable().
$teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
$this->getDataGenerator()->enrol_user($user->id, $course->id, $teacherrole->id);
$res = core_external::update_inplace_editable('format_topics', 'sectionname', $section->id, 'New section name');
$res = external_api::clean_returnvalue(core_external::update_inplace_editable_returns(), $res);
$this->assertEquals('New section name', $res['value']);
$this->assertEquals('New section name', $DB->get_field('course_sections', 'name', array('id' => $section->id)));
}
/**
* Test callback updating section name
*/
public function test_inplace_editable() {
global $DB, $PAGE;
$this->resetAfterTest();
$user = $this->getDataGenerator()->create_user();
$course = $this->getDataGenerator()->create_course(array('numsections' => 5, 'format' => 'topics'),
array('createsections' => true));
$teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
$this->getDataGenerator()->enrol_user($user->id, $course->id, $teacherrole->id);
$this->setUser($user);
$section = $DB->get_record('course_sections', array('course' => $course->id, 'section' => 2));
// Call callback format_topics_inplace_editable() directly.
$tmpl = component_callback('format_topics', 'inplace_editable', array('sectionname', $section->id, 'Rename me again'));
$this->assertInstanceOf('core\output\inplace_editable', $tmpl);
$res = $tmpl->export_for_template($PAGE->get_renderer('core'));
$this->assertEquals('Rename me again', $res['value']);
$this->assertEquals('Rename me again', $DB->get_field('course_sections', 'name', array('id' => $section->id)));
// Try updating using callback from mismatching course format.
try {
$tmpl = component_callback('format_weeks', 'inplace_editable', array('sectionname', $section->id, 'New name'));
$this->fail('Exception expected');
} catch (moodle_exception $e) {
$this->assertEquals(1, preg_match('/^Can not find data record in database/', $e->getMessage()));
}
}
}

View File

@ -2,6 +2,10 @@ This files describes API changes for course formats
Overview of this plugin type at http://docs.moodle.org/dev/Course_formats
=== 3.1 ===
* Course format may use the inplace_editable template to allow quick editing of section names, see
https://docs.moodle.org/dev/Inplace_editable and MDL-51802 for example implementation.
=== 3.0 ===
* Course formats should now use section_edit_control_items and use the returned array of controls items and their attributes to create a
renderable menu or array of links. Plugin calls to section_edit_controls will now include the section edit control in the returned array.

View File

@ -25,7 +25,9 @@
$string['currentsection'] = 'This week';
$string['editsection'] = 'Edit week';
$string['editsectionname'] = 'Edit week name';
$string['deletesection'] = 'Delete week';
$string['newsectionname'] = 'New name for week {$a}';
$string['sectionname'] = 'Week';
$string['pluginname'] = 'Weekly format';
$string['section0name'] = 'General';

View File

@ -432,4 +432,45 @@ class format_weeks extends format_base {
public function can_delete_section($section) {
return true;
}
/**
* Prepares the templateable object to display section name
*
* @param \section_info|\stdClass $section
* @param bool $linkifneeded
* @param bool $editable
* @param null|lang_string|string $edithint
* @param null|lang_string|string $editlabel
* @return \core\output\inplace_editable
*/
public function inplace_editable_render_section_name($section, $linkifneeded = true,
$editable = null, $edithint = null, $editlabel = null) {
if (empty($edithint)) {
$edithint = new lang_string('editsectionname', 'format_weeks');
}
if (empty($editlabel)) {
$title = get_section_name($section->course, $section);
$editlabel = new lang_string('newsectionname', 'format_weeks', $title);
}
return parent::inplace_editable_render_section_name($section, $linkifneeded, $editable, $edithint, $editlabel);
}
}
/**
* Implements callback inplace_editable() allowing to edit values in-place
*
* @param string $itemtype
* @param int $itemid
* @param mixed $newvalue
* @return \core\output\inplace_editable
*/
function format_weeks_inplace_editable($itemtype, $itemid, $newvalue) {
global $DB, $CFG;
require_once($CFG->dirroot . '/course/lib.php');
if ($itemtype === 'sectionname' || $itemtype === 'sectionnamenl') {
$section = $DB->get_record_sql(
'SELECT s.* FROM {course_sections} s JOIN {course} c ON s.course = c.id WHERE s.id = ? AND c.format = ?',
array($itemid, 'weeks'), MUST_EXIST);
return course_get_format($section->course)->inplace_editable_update_section_name($section, $itemtype, $newvalue);
}
}

View File

@ -59,4 +59,26 @@ class format_weeks_renderer extends format_section_renderer_base {
protected function page_title() {
return get_string('weeklyoutline');
}
/**
* Generate the section title, wraps it in a link to the section page if page is to be displayed on a separate page
*
* @param stdClass $section The course_section entry from DB
* @param stdClass $course The course entry from DB
* @return string HTML to output.
*/
public function section_title($section, $course) {
return $this->render(course_get_format($course)->inplace_editable_render_section_name($section));
}
/**
* Generate the section title to be displayed on the section page, without a link
*
* @param stdClass $section The course_section entry from DB
* @param stdClass $course The course entry from DB
* @return string HTML to output.
*/
public function section_title_without_link($section, $course) {
return $this->render(course_get_format($course)->inplace_editable_render_section_name($section, false));
}
}

View File

@ -57,6 +57,18 @@ Feature: Sections can be edited and deleted in weeks format
Then I should see "This is the second week" in the "li#section-2" "css_element"
And I should not see "8 May - 14 May" in the "li#section-2" "css_element"
@javascript
Scenario: Inline edit section name in weeks format
When I click on "Edit week name" "link" in the "li#section-1" "css_element"
And I set the field "New name for week 1 May - 7 May" to "Midterm evaluation"
And I press key "13" in the field "New name for week 1 May - 7 May"
Then I should not see "1 May - 7 May" in the "#region-main" "css_element"
And "New name for week" "field" should not exist
And I should see "Midterm evaluation" in the "li#section-1" "css_element"
And I follow "Course 1"
And I should not see "1 May - 7 May" in the "#region-main" "css_element"
And I should see "Midterm evaluation" in the "li#section-1" "css_element"
Scenario: Deleting the last section in weeks format
Given I should see "29 May - 4 June" in the "li#section-5" "css_element"
When I delete section "5"

View File

@ -152,4 +152,69 @@ class format_weeks_testcase extends advanced_testcase {
}
}
}
/**
* Test web service updating section name
*/
public function test_update_inplace_editable() {
global $CFG, $DB, $PAGE;
require_once($CFG->dirroot . '/lib/external/externallib.php');
$this->resetAfterTest();
$user = $this->getDataGenerator()->create_user();
$this->setUser($user);
$course = $this->getDataGenerator()->create_course(array('numsections' => 5, 'format' => 'weeks'),
array('createsections' => true));
$section = $DB->get_record('course_sections', array('course' => $course->id, 'section' => 2));
// Call webservice without necessary permissions.
try {
core_external::update_inplace_editable('format_weeks', 'sectionname', $section->id, 'New section name');
$this->fail('Exception expected');
} catch (moodle_exception $e) {
$this->assertEquals('Course or activity not accessible. (Not enrolled)',
$e->getMessage());
}
// Change to teacher and make sure that section name can be updated using web service update_inplace_editable().
$teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
$this->getDataGenerator()->enrol_user($user->id, $course->id, $teacherrole->id);
$res = core_external::update_inplace_editable('format_weeks', 'sectionname', $section->id, 'New section name');
$res = external_api::clean_returnvalue(core_external::update_inplace_editable_returns(), $res);
$this->assertEquals('New section name', $res['value']);
$this->assertEquals('New section name', $DB->get_field('course_sections', 'name', array('id' => $section->id)));
}
/**
* Test callback updating section name
*/
public function test_inplace_editable() {
global $CFG, $DB, $PAGE;
$this->resetAfterTest();
$user = $this->getDataGenerator()->create_user();
$course = $this->getDataGenerator()->create_course(array('numsections' => 5, 'format' => 'weeks'),
array('createsections' => true));
$teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
$this->getDataGenerator()->enrol_user($user->id, $course->id, $teacherrole->id);
$this->setUser($user);
$section = $DB->get_record('course_sections', array('course' => $course->id, 'section' => 2));
// Call callback format_weeks_inplace_editable() directly.
$tmpl = component_callback('format_weeks', 'inplace_editable', array('sectionname', $section->id, 'Rename me again'));
$this->assertInstanceOf('core\output\inplace_editable', $tmpl);
$res = $tmpl->export_for_template($PAGE->get_renderer('core'));
$this->assertEquals('Rename me again', $res['value']);
$this->assertEquals('Rename me again', $DB->get_field('course_sections', 'name', array('id' => $section->id)));
// Try updating using callback from mismatching course format.
try {
$tmpl = component_callback('format_topics', 'inplace_editable', array('sectionname', $section->id, 'New name'));
$this->fail('Exception expected');
} catch (moodle_exception $e) {
$this->assertEquals(1, preg_match('/^Can not find data record in database/', $e->getMessage()));
}
}
}

View File

@ -1198,37 +1198,10 @@ function set_section_visible($courseid, $sectionnumber, $visibility) {
$resourcestotoggle = array();
if ($section = $DB->get_record("course_sections", array("course"=>$courseid, "section"=>$sectionnumber))) {
$DB->set_field("course_sections", "visible", "$visibility", array("id"=>$section->id));
$event = \core\event\course_section_updated::create(array(
'context' => context_course::instance($courseid),
'objectid' => $section->id,
'other' => array(
'sectionnum' => $sectionnumber
)
));
$event->add_record_snapshot('course_sections', $section);
$event->trigger();
if (!empty($section->sequence)) {
$modules = explode(",", $section->sequence);
foreach ($modules as $moduleid) {
if ($cm = get_coursemodule_from_id(null, $moduleid, $courseid)) {
if ($visibility) {
// As we unhide the section, we use the previously saved visibility stored in visibleold.
set_coursemodule_visible($moduleid, $cm->visibleold);
} else {
// We hide the section, so we hide the module but we store the original state in visibleold.
set_coursemodule_visible($moduleid, 0);
$DB->set_field('course_modules', 'visibleold', $cm->visible, array('id' => $moduleid));
}
\core\event\course_module_updated::create_from_cm($cm)->trigger();
}
}
}
rebuild_course_cache($courseid, true);
course_update_section($courseid, $section, array('visible' => $visibility));
// Determine which modules are visible for AJAX update
$modules = !empty($section->sequence) ? explode(',', $section->sequence) : array();
if (!empty($modules)) {
list($insql, $params) = $DB->get_in_or_equal($modules);
$select = 'id ' . $insql . ' AND visible = ?';
@ -1877,6 +1850,70 @@ function course_delete_section($course, $section, $forcedeleteifnotempty = true)
return $result;
}
/**
* Updates the course section
*
* This function does not check permissions or clean values - this has to be done prior to calling it.
*
* @param int|stdClass $course
* @param stdClass $section record from course_sections table - it will be updated with the new values
* @param array|stdClass $data
*/
function course_update_section($course, $section, $data) {
global $DB;
$courseid = (is_object($course)) ? $course->id : (int)$course;
// Some fields can not be updated using this method.
$data = array_diff_key((array)$data, array('id', 'course', 'section', 'sequence'));
$changevisibility = (array_key_exists('visible', $data) && (bool)$data['visible'] != (bool)$section->visible);
if (array_key_exists('name', $data) && \core_text::strlen($data['name']) > 255) {
throw new moodle_exception('maximumchars', 'moodle', '', 255);
}
// Update record in the DB and course format options.
$data['id'] = $section->id;
$DB->update_record('course_sections', $data);
rebuild_course_cache($courseid, true);
course_get_format($courseid)->update_section_format_options($data);
// Update fields of the $section object.
foreach ($data as $key => $value) {
if (property_exists($section, $key)) {
$section->$key = $value;
}
}
// Trigger an event for course section update.
$event = \core\event\course_section_updated::create(
array(
'objectid' => $section->id,
'courseid' => $courseid,
'context' => context_course::instance($courseid),
'other' => array('sectionnum' => $section->section)
)
);
$event->trigger();
// If section visibility was changed, hide the modules in this section too.
if ($changevisibility && !empty($section->sequence)) {
$modules = explode(',', $section->sequence);
foreach ($modules as $moduleid) {
if ($cm = get_coursemodule_from_id(null, $moduleid, $courseid)) {
if ($data['visible']) {
// As we unhide the section, we use the previously saved visibility stored in visibleold.
set_coursemodule_visible($moduleid, $cm->visibleold);
} else {
// We hide the section, so we hide the module but we store the original state in visibleold.
set_coursemodule_visible($moduleid, 0);
$DB->set_field('course_modules', 'visibleold', $cm->visible, array('id' => $moduleid));
}
\core\event\course_module_updated::create_from_cm($cm)->trigger();
}
}
}
}
/**
* Checks if the current user can delete a section (if course format allows it and user has proper permissions).
*

View File

@ -302,6 +302,7 @@ $string['invalidcontext'] = 'Invalid context';
$string['invalidcourse'] = 'Invalid course';
$string['invalidcourseid'] = 'You are trying to use an invalid course ID';
$string['invalidcourselevel'] = 'Incorrect context level';
$string['invalidcourseformat'] = 'Invalid course format';
$string['invalidcoursemodule'] = 'Invalid course module ID';
$string['invalidcoursenameshort'] = 'Invalid short course name';
$string['invaliddata'] = 'Data submitted is invalid';

View File

@ -557,6 +557,7 @@ $string['editorresettodefaults'] = 'Reset to default values';
$string['editorsettings'] = 'Editor settings';
$string['editorshortcutkeys'] = 'Editor shortcut keys';
$string['editsection'] = 'Edit section';
$string['editsectionname'] = 'Edit section name';
$string['editsummary'] = 'Edit summary';
$string['edittitle'] = 'Edit title';
$string['edittitleinstructions'] = 'Escape to cancel, Enter when finished';
@ -1261,6 +1262,7 @@ Cheers from the \'{$a->sitename}\' administrator,
{$a->signoff}';
$string['newpicture'] = 'New picture';
$string['newpicture_help'] = 'To add a new picture, browse and select an image (in JPG or PNG format) then click "Update profile". The image will be cropped to a square and resized to 100x100 pixels.';
$string['newsectionname'] = 'New name for section {$a}';
$string['newsitem'] = 'news item';
$string['newsitems'] = 'news items';
$string['newsitemsnumber'] = 'News items to show';