diff --git a/admin/classes/reportbuilder/local/systemreports/users.php b/admin/classes/reportbuilder/local/systemreports/users.php index b3ae4c5643468..28fde601b0c5c 100644 --- a/admin/classes/reportbuilder/local/systemreports/users.php +++ b/admin/classes/reportbuilder/local/systemreports/users.php @@ -113,7 +113,7 @@ protected function initialise(): void { $this->add_actions(); // Set if report can be downloaded. - $this->set_downloadable(false); + $this->set_downloadable(true); } /** @@ -135,17 +135,15 @@ public function add_columns(): void { $entityuser = $this->get_entity('user'); $entityuseralias = $entityuser->get_table_alias('user'); - $this->add_column($entityuser->get_column('fullnamewithlink')); + $this->add_column($entityuser->get_column('fullnamewithpicturelink')); // Include identity field columns. - $identitycolumns = $entityuser->get_identity_columns($this->get_context(), ['city', 'country', 'lastaccesstime']); + $identitycolumns = $entityuser->get_identity_columns($this->get_context()); foreach ($identitycolumns as $identitycolumn) { $this->add_column($identitycolumn); } - // These columns are always shown in the users list. - $this->add_column($entityuser->get_column('city')); - $this->add_column($entityuser->get_column('country')); + // Add "Last access" column. $this->add_column(($entityuser->get_column('lastaccess')) ->set_callback(static function ($value, \stdClass $row): string { if ($row->lastaccess) { @@ -155,7 +153,7 @@ public function add_columns(): void { }) ); - if ($column = $this->get_column('user:fullnamewithlink')) { + if ($column = $this->get_column('user:fullnamewithpicturelink')) { $column ->add_fields("{$entityuseralias}.suspended, {$entityuseralias}.confirmed") ->add_callback(static function(string $fullname, \stdClass $row): string { @@ -171,7 +169,7 @@ public function add_columns(): void { }); } - $this->set_initial_sort_column('user:fullnamewithlink', SORT_ASC); + $this->set_initial_sort_column('user:fullnamewithpicturelink', SORT_ASC); $this->set_default_no_results_notice(new lang_string('nousersfound', 'moodle')); } diff --git a/admin/tool/componentlibrary/content/moodle/components/moodle-icons.md b/admin/tool/componentlibrary/content/moodle/components/moodle-icons.md index 56d0d365972b0..269be9340febf 100644 --- a/admin/tool/componentlibrary/content/moodle/components/moodle-icons.md +++ b/admin/tool/componentlibrary/content/moodle/components/moodle-icons.md @@ -11,7 +11,7 @@ tags: ## Description -Most Moodle icons are rendered using the 6.4 versions of [Fontawesome](https://fontawesome.com/v6/search). Iconnames are mapped from the Moodle icon name to the Font Awesome icon names in `/lib/classes/output/icon_system_fontawesome.php` +Most Moodle icons are rendered using the 6.5.1 versions of [Fontawesome](https://fontawesome.com/v6/search). Iconnames are mapped from the Moodle icon name to the Font Awesome icon names in `/lib/classes/output/icon_system_fontawesome.php` If needed a theme can override this map and provide its own mapping. diff --git a/admin/tool/task/lib.php b/admin/tool/task/lib.php index e6c45e6ed1a5f..655855367c373 100644 --- a/admin/tool/task/lib.php +++ b/admin/tool/task/lib.php @@ -46,5 +46,20 @@ function tool_task_status_checks(): array { */ function tool_task_mtrace_wrapper(string $message, string $eol): void { $message = s($message); + + // We autolink urls and emails here but can't use format_text as it does + // more than we need and has side effects which are not useful in this context. + $urlpattern = '/(http|https|ftp|ftps)\:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(\/\S*)?/'; + $message = preg_replace_callback($urlpattern, function($matches) { + $url = $matches[0]; + return html_writer::link($url, $url, ['target' => '_blank']); + }, $message); + + $emailpattern = '/[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}/'; + $message = preg_replace_callback($emailpattern, function($matches) { + $email = $matches[0]; + return html_writer::link('mailto:' . $email, $email); + }, $message); + echo $message . $eol; } diff --git a/admin/tool/task/run_adhoctasks.php b/admin/tool/task/run_adhoctasks.php index b1b831c3368cf..7912f4b1beb35 100644 --- a/admin/tool/task/run_adhoctasks.php +++ b/admin/tool/task/run_adhoctasks.php @@ -115,6 +115,8 @@ function ($t) use ($now) { require_sesskey(); \core\session\manager::write_close(); +echo $OUTPUT->footer(); +echo $OUTPUT->select_element_for_append(); // Prepare to handle output via mtrace. require_once("{$CFG->dirroot}/{$CFG->admin}/tool/task/lib.php"); @@ -124,7 +126,7 @@ function ($t) use ($now) { if ($taskid) { $repeat = $DB->get_record('task_adhoc', ['id' => $taskid]); - echo html_writer::start_tag('pre'); + echo html_writer::start_tag('pre', ['class' => 'task-output']); \core\task\manager::run_adhoc_from_cli($taskid); echo html_writer::end_tag('pre'); } else { @@ -133,13 +135,13 @@ function ($t) use ($now) { // Run failed first (if any). We have to run them separately anyway, // because faildelay is observed if failed flag is not true. echo html_writer::tag('p', get_string('runningfailedtasks', 'tool_task'), ['class' => 'lead']); - echo html_writer::start_tag('pre'); + echo html_writer::start_tag('pre', ['class' => 'task-output']); \core\task\manager::run_all_adhoc_from_cli(true, $classname); echo html_writer::end_tag('pre'); if (!$failedonly) { echo html_writer::tag('p', get_string('runningalltasks', 'tool_task'), ['class' => 'lead']); - echo html_writer::start_tag('pre'); + echo html_writer::start_tag('pre', ['class' => 'task-output']); \core\task\manager::run_all_adhoc_from_cli(false, $classname); echo html_writer::end_tag('pre'); } @@ -161,4 +163,3 @@ function ($t) use ($now) { ) ); -echo $OUTPUT->footer(); diff --git a/admin/tool/task/schedule_task.php b/admin/tool/task/schedule_task.php index 654a411f5a04c..e753c145f092e 100644 --- a/admin/tool/task/schedule_task.php +++ b/admin/tool/task/schedule_task.php @@ -88,9 +88,8 @@ echo $OUTPUT->select_element_for_append(); // Prepare to handle output via mtrace. -echo html_writer::start_tag('pre', ['style' => 'color: #fff; background: #333; padding: 1em; min-height: 24lh']); - require_once("{$CFG->dirroot}/{$CFG->admin}/tool/task/lib.php"); +echo html_writer::start_tag('pre', ['class' => 'task-output', 'style' => 'min-height: 24lh']); $CFG->mtrace_wrapper = 'tool_task_mtrace_wrapper'; // Run the specified task (this will output an error if it doesn't exist). diff --git a/admin/tool/task/styles.css b/admin/tool/task/styles.css index f297fd6afaf11..6b118e831ddaa 100644 --- a/admin/tool/task/styles.css +++ b/admin/tool/task/styles.css @@ -15,3 +15,14 @@ #page-admin-tool-task-scheduledtasks .task-clearfaildelay { font-size: 0.75em; } + +.path-admin-tool-task .task-output { + color: #fff; + background: #333; + padding: 1em; + + a { + color: #fff; + text-decoration: underline; + } +} diff --git a/admin/tool/usertours/lang/en/deprecated.txt b/admin/tool/usertours/lang/en/deprecated.txt deleted file mode 100644 index fc9dfbd183293..0000000000000 --- a/admin/tool/usertours/lang/en/deprecated.txt +++ /dev/null @@ -1 +0,0 @@ -previousstep,tool_usertours diff --git a/admin/tool/usertours/lang/en/tool_usertours.php b/admin/tool/usertours/lang/en/tool_usertours.php index 815806de4a9af..12e15dbac65d5 100644 --- a/admin/tool/usertours/lang/en/tool_usertours.php +++ b/admin/tool/usertours/lang/en/tool_usertours.php @@ -303,6 +303,3 @@ $string['tour_gradebook_tour_name'] = 'Gradebook Grader Report'; $string['tour_final_step_title'] = 'End of tour'; $string['tour_final_step_content'] = 'This is the end of your user tour. It won\'t show again unless you reset it using the link in the footer.'; - -// Deprecated since Moodle 4.0. -$string['previousstep'] = 'Previous'; diff --git a/admin/user.php b/admin/user.php index 63e16a229c19a..fe29620cb4b64 100644 --- a/admin/user.php +++ b/admin/user.php @@ -176,7 +176,7 @@ echo html_writer::start_div('', ['data-region' => 'report-user-list-wrapper']); $bulkactions = new user_bulk_action_form(new moodle_url('/admin/user/user_bulk.php'), - ['excludeactions' => ['displayonpage'], 'passuserids' => true, 'hidesubmit' => true], + ['excludeactions' => ['displayonpage', 'download'], 'passuserids' => true, 'hidesubmit' => true], 'post', '', ['id' => 'user-bulk-action-form']); $bulkactions->set_data(['returnurl' => $PAGE->url->out_as_local_url(false)]); diff --git a/auth/lti/auth.php b/auth/lti/auth.php index 0b60dfc24a9bf..c5e13fd5b459d 100644 --- a/auth/lti/auth.php +++ b/auth/lti/auth.php @@ -115,7 +115,7 @@ public function complete_login(array $launchdata, moodle_url $returnurl, int $pr if (isloggedin()) { // If a different user is currently logged in, authenticate the linked user instead. global $USER; - if ((int) $USER->id !== $user->id) { + if ($USER->id !== $user->id) { complete_user_login($user); } // If the linked user is already logged in, skip the call to complete_user_login() because this affects deep linking diff --git a/auth/lti/classes/local/ltiadvantage/event/event_handler.php b/auth/lti/classes/local/ltiadvantage/event/event_handler.php new file mode 100644 index 0000000000000..a3817f7e46e24 --- /dev/null +++ b/auth/lti/classes/local/ltiadvantage/event/event_handler.php @@ -0,0 +1,50 @@ +. + +namespace auth_lti\local\ltiadvantage\event; + +use auth_lti\local\ltiadvantage\utility\cookie_helper; +use core\event\user_loggedin; + +/** + * Event handler for auth_lti. + * + * @package auth_lti + * @copyright 2024 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class event_handler { + + /** + * Allows the plugin to augment Set-Cookie headers when the user_loggedin event is fired as part of complete_user_login() calls. + * + * @param user_loggedin $event the event + * @return void + */ + public static function handle_user_loggedin(user_loggedin $event): void { + // The event data isn't important here. The intent of this listener is to ensure that the MoodleSession cookie gets the + // 'Partitioned' attribute, when required - an opt-in flag needed to use Chrome's partitioning mechanism, CHIPS. During LTI + // auth, the auth class (auth/lti/auth.php) calls complete_user_login(), which generates a new session cookie as part of its + // login process. This handler makes sure that this new cookie is intercepted and partitioned, if needed. + if (cookie_helper::cookies_supported()) { + if (cookie_helper::get_cookies_supported_method() == cookie_helper::COOKIE_METHOD_EXPLICIT_PARTITIONING) { + global $CFG; + cookie_helper::add_attributes_to_cookie_response_header('MoodleSession' . $CFG->sessioncookie, + ['Partitioned', 'Secure']); + } + } + } +} diff --git a/auth/lti/classes/local/ltiadvantage/utility/cookie_helper.php b/auth/lti/classes/local/ltiadvantage/utility/cookie_helper.php new file mode 100644 index 0000000000000..4a1d6189b48c3 --- /dev/null +++ b/auth/lti/classes/local/ltiadvantage/utility/cookie_helper.php @@ -0,0 +1,295 @@ +. + +namespace auth_lti\local\ltiadvantage\utility; + +/** + * Helper class providing utils dealing with cookies in LTI, particularly 3rd party cookies. + * + * This class primarily provides a means to augment outbound cookie headers, in order to satisfy browser-specific + * requirements for setting 3rd party cookies. + * + * @package auth_lti + * @copyright 2024 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class cookie_helper { + + /** @var int Cookies are not supported. */ + public const COOKIE_METHOD_NOT_SUPPORTED = 0; + + /** @var int Cookies are supported without explicit partitioning. */ + public const COOKIE_METHOD_NO_PARTITIONING = 1; + + /** @var int Cookies are supported via explicit partitioning. */ + public const COOKIE_METHOD_EXPLICIT_PARTITIONING = 2; + + /** + * Make sure the given attributes are set on the Set-Cookie response header identified by name=$cookiename. + * + * This function only affects Set-Cookie headers and modifies the headers directly with the required changes, if any. + * + * @param string $cookiename the cookie name. + * @param array $attributes the attributes to set/ensure are set. + * @return void + */ + public static function add_attributes_to_cookie_response_header(string $cookiename, array $attributes): void { + + $setcookieheaders = array_filter(headers_list(), function($val) { + return preg_match("/Set-Cookie:/i", $val); + }); + if (empty($setcookieheaders)) { + return; + } + + $updatedheaders = self::cookie_response_headers_add_attributes($setcookieheaders, [$cookiename], $attributes); + + // Note: The header_remove() method is quite crude and removes all headers of that header name. + header_remove('Set-Cookie'); + foreach ($updatedheaders as $header) { + header($header, false); + } + } + + /** + * Given a list of HTTP header strings, return a list of HTTP header strings where the matched 'Set-Cookie' headers + * have been updated with the attributes defined in $attribute - an array of strings. + * + * This method does not verify whether a given attribute is valid or not. It blindly sets it and returns the header + * strings. It's up to calling code to determine whether an attribute makes sense or not. + * + * @param array $headerstrings the array of header strings. + * @param array $cookiestomatch the array of cookie names to match. + * @param array $attributes the attributes to set on each matched 'Set-Cookie' header. + * @param bool $casesensitive whether to match the attribute in a case-sensitive way. + * @return array the updated array of header strings. + */ + public static function cookie_response_headers_add_attributes(array $headerstrings, array $cookiestomatch, array $attributes, + bool $casesensitive = false): array { + + return array_map(function($headerstring) use ($attributes, $casesensitive, $cookiestomatch) { + if (!self::cookie_response_header_matches_names($headerstring, $cookiestomatch)) { + return $headerstring; + } + foreach ($attributes as $attribute) { + if (!self::cookie_response_header_contains_attribute($headerstring, $attribute, $casesensitive)) { + $headerstring = self::cookie_response_header_append_attribute($headerstring, $attribute); + } + } + return $headerstring; + }, $headerstrings); + } + + /** + * Check whether cookies can be used with the current user agent and, if so, via what method they are set. + * + * Currently, this tries 2 modes of setting a test cookie: + * 1. Setting a SameSite=None, Secure cookie. This will work in any first party context, and in 3rd party contexts for + * any browsers supporting automatic partitioning of 3rd party cookies (E.g. Firefox, Brave). + * 2. If 1 fails, setting a cookie with the Chrome 'Partitioned' attribute included, opting that cookie into CHIPS. This will + * work for Chrome. + * + * Upon completion of the cookie check, the check sets a SESSION flag indicating the method used to set the cookie, and upgrades + * the session cookie ('MoodleSession') using the respective method. This ensure the session cookie will continue to be sent. + * + * Then, the following methods can be used by client code to query whether the UA supports cookies, and how: + * @see self::cookies_supported() - whether it could be set at all. + * @see self::get_cookies_supported_mode() - if a cookie could be set, what mode was used to set it. + * + * This permits client code to make sure it's setting its cookies appropriately (via the advertised method), and allows it to + * present notices - such as in the case where a given UA is found to be lacking the requisite cookie support. + * E.g. + * cookie_helper::do_cookie_check($mypageurl); + * if (!cookie_helper::cookies_supported()) { + * // Print a notice stating that cookie support is required. + * } + * // Elsewhere in other client code... + * if (cookie_helper::get_cookies_supported_mode() === cookie_helper::COOKIE_METHOD_EXPLICIT_PARTITIONING) { + * // Set a cookie, making sure to use the helper to also opt-in to partitioning. + * setcookie('myauthcookie', 'myauthcookievalue', ['samesite' => 'None', 'secure' => true]); + * cookie_helper::add_partitioning_to_cookie('myauthcookie'); + * } + * + * @param \moodle_url $pageurl the URL of the page making the check, used to redirect back to after setting test cookies. + * @return void + */ + public static function do_cookie_check(\moodle_url $pageurl): void { + global $_COOKIE, $SESSION, $CFG; + $cookiecheck1 = optional_param('cookiecheck1', null, PARAM_INT); + $cookiecheck2 = optional_param('cookiecheck2', null, PARAM_INT); + + if (empty($cookiecheck1)) { + // Start the cookie check. Set two test cookies - one samesite none, and one partitioned - and redirect. + // Set cookiecheck to show the check has started. + self::set_test_cookie('cookiecheck1', self::COOKIE_METHOD_NO_PARTITIONING); + self::set_test_cookie('cookiecheck2', self::COOKIE_METHOD_EXPLICIT_PARTITIONING, true); + $pageurl->params([ + 'cookiecheck1' => self::COOKIE_METHOD_NO_PARTITIONING, + 'cookiecheck2' => self::COOKIE_METHOD_EXPLICIT_PARTITIONING, + ]); + + // LTI needs to guarantee the 'SameSite=None', 'Secure' (and sometimes 'Partitioned') attributes are set on the + // MoodleSession cookie. This is done via manipulation of the outgoing headers after the cookie check redirect. To + // guarantee these outgoing Set-Cookie headers will be created after the redirect, expire the current cookie. + self::expire_moodlesession(); + + redirect($pageurl); + } else { + // Have already started a cookie check, so check the result. + $cookie1received = isset($_COOKIE['cookiecheck1']) && $_COOKIE['cookiecheck1'] == $cookiecheck1; + $cookie2received = isset($_COOKIE['cookiecheck2']) && $_COOKIE['cookiecheck2'] == $cookiecheck2; + + if ($cookie1received || $cookie2received) { + // The test cookie could be set and received. + // Set a session flag storing the method used to set it, and make sure the session cookie uses this method. + $cookiemethod = $cookie1received ? self::COOKIE_METHOD_NO_PARTITIONING : self::COOKIE_METHOD_EXPLICIT_PARTITIONING; + $SESSION->auth_lti_cookie_method = $cookiemethod; + if ($cookiemethod === self::COOKIE_METHOD_EXPLICIT_PARTITIONING) { + // This assumes secure is set, since that's the only way a paritioned test cookie have been set. + self::add_attributes_to_cookie_response_header('MoodleSession'.$CFG->sessioncookie, ['Partitioned', 'Secure']); + } + } + } + } + + /** + * If a cookie check has been made, returns whether cookies could be set or not. + * + * @return bool whether cookies are supported or not. + */ + public static function cookies_supported(): bool { + return self::get_cookies_supported_method() !== self::COOKIE_METHOD_NOT_SUPPORTED; + } + + /** + * If a cookie check has been made, gets the method used to set a cookie, or self::COOKIE_METHOD_NOT_SUPPORTED if not supported. + * + * For cookie methods: + * @see self::COOKIE_METHOD_NOT_SUPPORTED + * @see self::COOKIE_METHOD_NO_PARTITIONING + * @see self::COOKIE_METHOD_EXPLICIT_PARTITIONING + * + * @return int the constant representing the method by which the cookie was set, or not. + */ + public static function get_cookies_supported_method(): int { + global $SESSION; + return $SESSION->auth_lti_cookie_method ?? self::COOKIE_METHOD_NOT_SUPPORTED; + } + + /** + * Forces the expiry of the MoodleSession cookie. + * + * This is useful to force a new Set-Cookie header on the next redirect. + * + * @return void + */ + private static function expire_moodlesession(): void { + global $CFG; + + $setcookieheader = array_filter(headers_list(), function($val) use ($CFG) { + return self::cookie_response_header_matches_name($val, 'MoodleSession'.$CFG->sessioncookie); + }); + if (!empty($setcookieheader)) { + $expirestr = 'Expires='.gmdate(DATE_RFC7231, time() - 60); + self::add_attributes_to_cookie_response_header('MoodleSession'.$CFG->sessioncookie, [$expirestr]); + } else { + setcookie('MoodleSession'.$CFG->sessioncookie, '', time() - 60); + } + } + + /** + * Set a test cookie, using SameSite=None; Secure; attributes if possible, and with or without partitioning opt-in. + * + * @param string $name cookie name + * @param string $value cookie value + * @param bool $partitioned whether to try to add partitioning opt-in, which requires secure cookies (https sites). + * @return void + */ + private static function set_test_cookie(string $name, string $value, bool $partitioned = false): void { + global $CFG; + require_once($CFG->libdir . '/sessionlib.php'); + + $atts = ['expires' => time() + 30]; + if (is_moodle_cookie_secure()) { + $atts['samesite'] = 'none'; + $atts['secure'] = true; + } + setcookie($name, $value, $atts); + + if (is_moodle_cookie_secure() && $partitioned) { + self::add_attributes_to_cookie_response_header($name, ['Partitioned']); + } + } + + /** + * Check whether the header string is a 'Set-Cookie' header for the cookie identified by $cookiename. + * + * @param string $headerstring the header string to check. + * @param string $cookiename the name of the cookie to match. + * @return bool true if the header string is a Set-Cookie header for the named cookie, false otherwise. + */ + private static function cookie_response_header_matches_name(string $headerstring, string $cookiename): bool { + // Generally match the format, but in a case-insensitive way so that 'set-cookie' and "SET-COOKIE" are both valid. + return preg_match("/Set-Cookie: *$cookiename=/i", $headerstring) + // Case-sensitive match on cookiename, which is case-sensitive. + && preg_match("/: *$cookiename=/", $headerstring); + } + + /** + * Check whether the header string is a 'Set-Cookie' header for the cookies identified in the $cookienames array. + * + * @param string $headerstring the header string to check. + * @param array $cookienames the array of cookie names to match. + * @return bool true if the header string is a Set-Cookie header for one of the named cookies, false otherwise. + */ + private static function cookie_response_header_matches_names(string $headerstring, array $cookienames): bool { + foreach ($cookienames as $cookiename) { + if (self::cookie_response_header_matches_name($headerstring, $cookiename)) { + return true; + } + } + return false; + } + + /** + * Check whether the header string contains the given attribute. + * + * @param string $headerstring the header string to check. + * @param string $attribute the attribute to check for. + * @param bool $casesensitive whether to perform a case-sensitive check. + * @return bool true if the header contains the attribute, false otherwise. + */ + private static function cookie_response_header_contains_attribute(string $headerstring, string $attribute, + bool $casesensitive): bool { + + if ($casesensitive) { + return str_contains($headerstring, $attribute); + } + return str_contains(strtolower($headerstring), strtolower($attribute)); + } + + /** + * Append the given attribute to the header string. + * + * @param string $headerstring the header string to append to. + * @param string $attribute the attribute to append. + * @return string the updated header string. + */ + private static function cookie_response_header_append_attribute(string $headerstring, string $attribute): string { + $headerstring = rtrim($headerstring, ';'); // Sometimes included. + return "$headerstring; $attribute;"; + } +} diff --git a/auth/lti/db/events.php b/auth/lti/db/events.php new file mode 100644 index 0000000000000..19b4df8e3448f --- /dev/null +++ b/auth/lti/db/events.php @@ -0,0 +1,33 @@ +. + +/** + * LTI Auth plugin event handler definition. + * + * @package auth_lti + * @category event + * @copyright 2024 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$observers = [ + [ + 'eventname' => '\core\event\user_loggedin', + 'callback' => '\auth_lti\local\ltiadvantage\event\event_handler::handle_user_loggedin', + ], +]; diff --git a/auth/lti/lang/en/auth_lti.php b/auth/lti/lang/en/auth_lti.php index a20574fbcada4..3c4033d584cc4 100644 --- a/auth/lti/lang/en/auth_lti.php +++ b/auth/lti/lang/en/auth_lti.php @@ -52,8 +52,5 @@ $string['useexistingaccount'] = 'Use existing account'; $string['welcome'] = 'Welcome!'; -// Deprecated since Moodle 4.0. -$string['privacy:metadata'] = 'The LTI authentication plugin does not store any personal data.'; - // Deprecated since Moodle 4.4. $string['firstlaunchnoauthnotice'] = 'To link your existing account you must be logged in to the site. Please log in to the site in a new tab/window and then relaunch the tool here. For further information, see the documentation Publish as LTI tool.'; diff --git a/auth/lti/lang/en/deprecated.txt b/auth/lti/lang/en/deprecated.txt index f893c28f5dfb9..47d590ccfc685 100644 --- a/auth/lti/lang/en/deprecated.txt +++ b/auth/lti/lang/en/deprecated.txt @@ -1,2 +1 @@ -privacy:metadata,auth_lti firstlaunchnoauthnotice,auth_lti diff --git a/auth/lti/tests/local/ltiadvantage/utility/cookie_helper_test.php b/auth/lti/tests/local/ltiadvantage/utility/cookie_helper_test.php new file mode 100644 index 0000000000000..b7d79d79b3961 --- /dev/null +++ b/auth/lti/tests/local/ltiadvantage/utility/cookie_helper_test.php @@ -0,0 +1,215 @@ +. + +namespace auth_lti\local\ltiadvantage\utility; + +/** + * Tests for the cookie_helper utility class. + * + * @package auth_lti + * @copyright 2024 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \auth_lti\local\ltiadvantage\utility\cookie_helper + */ +class cookie_helper_test extends \advanced_testcase { + + /** + * Testing cookie_response_headers_add_attributes(). + * + * @dataProvider cookie_response_headers_provider + * + * @param array $headers the headers to search + * @param array $cookienames the cookienames to match + * @param array $attributes the attributes to add + * @param bool $casesensitive whether to do a case-sensitive lookup for the attribute + * @param array $expectedheaders the expected, updated headers + * @return void + */ + public function test_cookie_response_headers_add_attributes(array $headers, array $cookienames, array $attributes, + bool $casesensitive, array $expectedheaders): void { + + $updated = cookie_helper::cookie_response_headers_add_attributes($headers, $cookienames, $attributes, $casesensitive); + $this->assertEquals($expectedheaders, $updated); + } + + /** + * Data provider for testing cookie_response_headers_add_attributes(). + * + * @return array the inputs and expected outputs. + */ + public static function cookie_response_headers_provider(): array { + return [ + 'Only one matching cookie header, without any of the attributes' => [ + 'headers' => [ + 'Set-Cookie: testcookie=value; path=/test/; HttpOnly;', + ], + 'cookienames' => [ + 'testcookie', + ], + 'attributes' => [ + 'Partitioned', + 'SameSite=None', + 'Secure', + ], + 'casesensitive' => false, + 'output' => [ + 'Set-Cookie: testcookie=value; path=/test/; HttpOnly; Partitioned; SameSite=None; Secure;', + ], + ], + 'Several matching cookie headers, without attributes' => [ + 'headers' => [ + 'Set-Cookie: testcookie=value; path=/test/; HttpOnly;', + 'Set-Cookie: mytestcookie=value; path=/test/; HttpOnly;', + ], + 'cookienames' => [ + 'testcookie', + 'mytestcookie', + ], + 'attributes' => [ + 'Partitioned', + 'SameSite=None', + 'Secure', + ], + 'casesensitive' => false, + 'output' => [ + 'Set-Cookie: testcookie=value; path=/test/; HttpOnly; Partitioned; SameSite=None; Secure;', + 'Set-Cookie: mytestcookie=value; path=/test/; HttpOnly; Partitioned; SameSite=None; Secure;', + ], + ], + 'Several matching cookie headers, several non-matching, all missing all attributes' => [ + 'headers' => [ + 'Set-Cookie: testcookie=value; path=/test/; HttpOnly;', + 'Set-Cookie: mytestcookie=value; path=/test/; HttpOnly;', + 'Set-Cookie: anothertestcookie=value; path=/test/; HttpOnly;', + ], + 'cookienames' => [ + 'testcookie', + 'mytestcookie', + 'blah', + 'etc', + ], + 'attributes' => [ + 'Partitioned', + 'SameSite=None', + 'Secure', + ], + 'casesensitive' => false, + 'output' => [ + 'Set-Cookie: testcookie=value; path=/test/; HttpOnly; Partitioned; SameSite=None; Secure;', + 'Set-Cookie: mytestcookie=value; path=/test/; HttpOnly; Partitioned; SameSite=None; Secure;', + 'Set-Cookie: anothertestcookie=value; path=/test/; HttpOnly;', + ], + ], + 'Matching cookie headers, some with existing attributes' => [ + 'headers' => [ + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; Partitioned; SameSite=None', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None', + ], + 'cookienames' => [ + 'testcookie', + 'mytestcookie', + 'etc', + ], + 'attributes' => [ + 'Partitioned', + 'SameSite=None', + 'Secure', + ], + 'casesensitive' => false, + 'output' => [ + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; Partitioned; SameSite=None', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None; Partitioned;', + ], + ], + 'Matching headers, some with existing attributes, case sensitive' => [ + 'headers' => [ + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None', + ], + 'cookienames' => [ + 'testcookie', + 'mytestcookie', + 'etc', + ], + 'attributes' => [ + 'Partitioned', + 'SameSite=None', + 'Secure', + ], + 'casesensitive' => true, + 'output' => [ + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned; Partitioned; Secure;', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None; Partitioned; Secure;', + ], + ], + 'Empty list of cookie names to match, so unmodified inputs' => [ + 'headers' => [ + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None', + ], + 'cookienames' => [], + 'attributes' => [ + 'Partitioned', + 'SameSite=None', + 'Secure', + ], + 'casesensitive' => false, + 'output' => [ + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None', + ], + ], + 'Empty list of attributes to set, so unmodified inputs' => [ + 'headers' => [ + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None', + ], + 'cookienames' => [ + 'testcookie', + 'mycookie', + ], + 'attributes' => [], + 'casesensitive' => false, + 'output' => [ + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None', + ], + ], + 'Other HTTP headers, some matching Set-Cookie, some not' => [ + 'headers' => [ + 'Authorization: blah', + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; Partitioned', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None', + ], + 'cookienames' => [ + 'testcookie', + 'mytestcookie', + ], + 'attributes' => [ + 'Partitioned', + 'SameSite=None', + 'Secure', + ], + 'casesensitive' => false, + 'output' => [ + 'Authorization: blah', + 'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; Partitioned', + 'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None; Partitioned;', + ], + ], + ]; + } +} diff --git a/auth/lti/version.php b/auth/lti/version.php index a36052b1299f9..a1ba6bc7189b3 100644 --- a/auth/lti/version.php +++ b/auth/lti/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2023100900; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2024020700; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2023100400; // Requires this Moodle version. $plugin->component = 'auth_lti'; // Full name of the plugin (used for diagnostics). diff --git a/blocks/calendar_month/tests/behat/block_calendar_month_dashboard.feature b/blocks/calendar_month/tests/behat/block_calendar_month_dashboard.feature index 92e76a04cbf31..5ce66709b7cb7 100644 --- a/blocks/calendar_month/tests/behat/block_calendar_month_dashboard.feature +++ b/blocks/calendar_month/tests/behat/block_calendar_month_dashboard.feature @@ -10,8 +10,8 @@ Feature: View a site event on the dashboard | student1 | Student | 1 | student1@example.com | S1 | And I log in as "admin" And I create a calendar event with form data: - | id_eventtype | Site | - | id_name | Site Event | + | id_eventtype | Site | + | id_name | Site Event | And I log out @javascript @@ -29,5 +29,5 @@ Feature: View a site event on the dashboard # We need to give the browser a couple seconds to re-render the page after the screen has been resized. And I wait "1" seconds And I should not see "Site Event" - And I hover over today in the mini-calendar block + When I hover over today in the mini-calendar block responsive view And I should see "Site Event" diff --git a/blocks/myoverview/lang/en/block_myoverview.php b/blocks/myoverview/lang/en/block_myoverview.php index 8536a60c8afe4..08be768497b6b 100644 --- a/blocks/myoverview/lang/en/block_myoverview.php +++ b/blocks/myoverview/lang/en/block_myoverview.php @@ -94,9 +94,3 @@ $string['zero_request_intro'] = 'Need help getting started? Check out the Moodle documentation or take your first steps with our Quickstart guide.'; $string['zero_nocourses_title'] = 'Create your first course'; $string['zero_nocourses_intro'] = 'Need help getting started? Check out the Moodle documentation or take your first steps with our Quickstart guide.'; - -// Deprecated since Moodle 4.0. -$string['clearsearch'] = "Clear search"; -$string['aria:lastaccessed'] = 'Sort courses by last accessed date'; -$string['aria:shortname'] = 'Sort courses by course short name'; -$string['aria:title'] = 'Sort courses by course name'; diff --git a/blocks/myoverview/lang/en/deprecated.txt b/blocks/myoverview/lang/en/deprecated.txt deleted file mode 100644 index 864e2de8f17a0..0000000000000 --- a/blocks/myoverview/lang/en/deprecated.txt +++ /dev/null @@ -1,4 +0,0 @@ -clearsearch,block_myoverview -aria:shortname,block_myoverview -aria:lastaccessed,block_myoverview -aria:title,block_myoverview diff --git a/blocks/timeline/lang/en/block_timeline.php b/blocks/timeline/lang/en/block_timeline.php index 8abe6f9423081..e10486d364053 100644 --- a/blocks/timeline/lang/en/block_timeline.php +++ b/blocks/timeline/lang/en/block_timeline.php @@ -48,8 +48,3 @@ $string['privacy:metadata:timelinesortpreference'] = 'The user sort preference for the timeline block.'; $string['privacy:metadata:timelinefilterpreference'] = 'The user day filter preference for the timeline block.'; $string['privacy:metadata:timelinelimitpreference'] = 'The user page limit preference for the timeline block.'; - -// Deprecated since Moodle 4.0. -$string['ariaeventlistpagelimit'] = 'Show {$a} activities per page'; -$string['ariaeventlistpaginationnavdates'] = 'Timeline activities pagination'; -$string['ariaeventlistpaginationnavcourses'] = 'Timeline activities for course {$a} pagination'; diff --git a/blocks/timeline/lang/en/deprecated.txt b/blocks/timeline/lang/en/deprecated.txt deleted file mode 100644 index 6c46d756af667..0000000000000 --- a/blocks/timeline/lang/en/deprecated.txt +++ /dev/null @@ -1,3 +0,0 @@ -ariaeventlistpagelimit,block_timeline -ariaeventlistpaginationnavdates,block_timeline -ariaeventlistpaginationnavcourses,block_timeline diff --git a/calendar/amd/build/calendar.min.js b/calendar/amd/build/calendar.min.js index db12a2ea05112..117ff46a1e4bf 100644 --- a/calendar/amd/build/calendar.min.js +++ b/calendar/amd/build/calendar.min.js @@ -9,6 +9,6 @@ * @copyright 2017 Simey Lameze * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -define("core_calendar/calendar",["jquery","core/templates","core/notification","core_calendar/repository","core_calendar/events","core_calendar/view_manager","core_calendar/crud","core_calendar/selectors","core/config","core/url"],(function($,Templates,Notification,CalendarRepository,CalendarEvents,CalendarViewManager,CalendarCrud,CalendarSelectors,Config,Url){var SELECTORS_DAY="[data-region='day']",SELECTORS_DAY_CONTENT="[data-region='day-content']",SELECTORS_LOADING_ICON=".loading-icon",SELECTORS_VIEW_DAY_LINK="[data-action='view-day-link']",SELECTORS_CALENDAR_MONTH_WRAPPER=".calendarwrapper",handleMoveEvent=function(e,eventId,originElement,destinationElement){var originTimestamp=null,destinationTimestamp=destinationElement.attr("data-day-timestamp");originElement&&(originTimestamp=originElement.attr("data-day-timestamp")),originElement&&originTimestamp==destinationTimestamp||Templates.render("core/loading",{}).then((function(html,js){destinationElement.find(SELECTORS_DAY_CONTENT).addClass("hidden"),Templates.appendNodeContents(destinationElement,html,js),originElement&&(originElement.find(SELECTORS_DAY_CONTENT).addClass("hidden"),Templates.appendNodeContents(originElement,html,js))})).then((function(){return CalendarRepository.updateEventStartDay(eventId,destinationTimestamp)})).then((function(){$("body").trigger(CalendarEvents.eventMoved,[eventId,originElement,destinationElement])})).always((function(){var destinationLoadingElement=destinationElement.find(SELECTORS_LOADING_ICON);if(destinationElement.find(SELECTORS_DAY_CONTENT).removeClass("hidden"),Templates.replaceNode(destinationLoadingElement,"",""),originElement){var originLoadingElement=originElement.find(SELECTORS_LOADING_ICON);originElement.find(SELECTORS_DAY_CONTENT).removeClass("hidden"),Templates.replaceNode(originLoadingElement,"","")}})).catch(Notification.exception)},registerEventListeners=function(root){const viewingFullCalendar=document.getElementById(CalendarSelectors.fullCalendarView);root.on("click",SELECTORS_VIEW_DAY_LINK,(function(e){var dayLink=$(e.target).closest(SELECTORS_VIEW_DAY_LINK),year=dayLink.data("year"),month=dayLink.data("month"),day=dayLink.data("day"),courseId=dayLink.data("courseid"),categoryId=dayLink.data("categoryid");const urlParams={view:"day",time:dayLink.data("timestamp"),course:courseId};if(viewingFullCalendar){const urlParamString=Object.entries(urlParams).map((_ref=>{let[key,value]=_ref;return"".concat(encodeURIComponent(key),"=").concat(encodeURIComponent(value))})).join("&");CalendarViewManager.refreshDayContent(root,year,month,day,courseId,categoryId,root,"core_calendar/calendar_day").then((function(){return e.preventDefault(),CalendarViewManager.updateUrl(urlParamString)})).catch(Notification.exception)}else window.location.assign(Url.relativeUrl("calendar/view.php",urlParams))})),root.on("change",CalendarSelectors.elements.courseSelector,(function(){var selectElement=$(this),courseId=selectElement.val();const courseName=$("option:selected",selectElement).text();CalendarViewManager.reloadCurrentMonth(root,courseId,null).then((function(){return root.find(CalendarSelectors.elements.courseSelector).val(courseId)})).then((function(){CalendarViewManager.updateUrl("?view=month&course="+courseId),CalendarViewManager.handleCourseChange(Number(courseId),courseName)})).catch(Notification.exception)}));var eventFormPromise=CalendarCrud.registerEventFormModal(root),contextId=$(SELECTORS_CALENDAR_MONTH_WRAPPER).data("context-id");!function(root,eventFormModalPromise){var body=$("body");body.on(CalendarEvents.created,(function(){CalendarViewManager.reloadCurrentMonth(root)})),body.on(CalendarEvents.deleted,(function(){CalendarViewManager.reloadCurrentMonth(root)})),body.on(CalendarEvents.updated,(function(){CalendarViewManager.reloadCurrentMonth(root)})),body.on(CalendarEvents.editActionEvent,(function(e,url){window.location.assign(url)})),body.on(CalendarEvents.moveEvent,handleMoveEvent),body.on(CalendarEvents.eventMoved,(function(){CalendarViewManager.reloadCurrentMonth(root)})),CalendarCrud.registerEditListeners(root,eventFormModalPromise)}(root,eventFormPromise),contextId&&root.on("click",SELECTORS_DAY,(function(e){var target=$(e.target);const displayingSmallBlockCalendar="side-pre"===root.parents("aside").data("blockregion");if(!viewingFullCalendar&&displayingSmallBlockCalendar){const dateContainer=target.closest(SELECTORS_DAY),courseId=target.closest(CalendarSelectors.wrapper).data("courseid"),params={view:"day",time:dateContainer.data("day-timestamp"),course:courseId};window.location.assign(Url.relativeUrl("calendar/view.php",params))}else{if(!target.closest(SELECTORS_VIEW_DAY_LINK).length){var startTime=$(this).attr("data-new-event-timestamp");eventFormPromise.then((function(modal){var wrapper=target.closest(CalendarSelectors.wrapper);modal.setCourseId(wrapper.data("courseid"));var categoryId=wrapper.data("categoryid");void 0!==categoryId&&modal.setCategoryId(categoryId),modal.setContextId(wrapper.data("contextId")),modal.setStartTime(startTime),modal.show()})).catch(Notification.exception)}}e.preventDefault()}))};return{init:function(root){root=$(root),CalendarViewManager.init(root),registerEventListeners(root)}}})); +define("core_calendar/calendar",["jquery","core/templates","core/notification","core_calendar/repository","core_calendar/events","core_calendar/view_manager","core_calendar/crud","core_calendar/selectors","core/config","core/url","core/str"],(function($,Templates,Notification,CalendarRepository,CalendarEvents,CalendarViewManager,CalendarCrud,CalendarSelectors,Config,Url,Str){var SELECTORS_DAY="[data-region='day']",SELECTORS_DAY_CONTENT="[data-region='day-content']",SELECTORS_LOADING_ICON=".loading-icon",SELECTORS_VIEW_DAY_LINK="[data-action='view-day-link']",SELECTORS_CALENDAR_MONTH_WRAPPER=".calendarwrapper",SELECTORS_SCREEN_READER_ANNOUNCEMENTS=".calendar-announcements",SELECTORS_CURRENT_MONTH=".calendar-controls .current",handleMoveEvent=function(e,eventId,originElement,destinationElement){var originTimestamp=null,destinationTimestamp=destinationElement.attr("data-day-timestamp");originElement&&(originTimestamp=originElement.attr("data-day-timestamp")),originElement&&originTimestamp==destinationTimestamp||Templates.render("core/loading",{}).then((function(html,js){destinationElement.find(SELECTORS_DAY_CONTENT).addClass("hidden"),Templates.appendNodeContents(destinationElement,html,js),originElement&&(originElement.find(SELECTORS_DAY_CONTENT).addClass("hidden"),Templates.appendNodeContents(originElement,html,js))})).then((function(){return CalendarRepository.updateEventStartDay(eventId,destinationTimestamp)})).then((function(){$("body").trigger(CalendarEvents.eventMoved,[eventId,originElement,destinationElement])})).always((function(){var destinationLoadingElement=destinationElement.find(SELECTORS_LOADING_ICON);if(destinationElement.find(SELECTORS_DAY_CONTENT).removeClass("hidden"),Templates.replaceNode(destinationLoadingElement,"",""),originElement){var originLoadingElement=originElement.find(SELECTORS_LOADING_ICON);originElement.find(SELECTORS_DAY_CONTENT).removeClass("hidden"),Templates.replaceNode(originLoadingElement,"","")}})).catch(Notification.exception)},registerEventListeners=function(root){const viewingFullCalendar=document.getElementById(CalendarSelectors.fullCalendarView);root.on("click",SELECTORS_VIEW_DAY_LINK,(function(e){var dayLink=$(e.target).closest(SELECTORS_VIEW_DAY_LINK),year=dayLink.data("year"),month=dayLink.data("month"),day=dayLink.data("day"),courseId=dayLink.data("courseid"),categoryId=dayLink.data("categoryid");const urlParams={view:"day",time:dayLink.data("timestamp"),course:courseId};if(viewingFullCalendar){const urlParamString=Object.entries(urlParams).map((_ref=>{let[key,value]=_ref;return"".concat(encodeURIComponent(key),"=").concat(encodeURIComponent(value))})).join("&");CalendarViewManager.refreshDayContent(root,year,month,day,courseId,categoryId,root,"core_calendar/calendar_day").then((function(){return e.preventDefault(),CalendarViewManager.updateUrl(urlParamString)})).catch(Notification.exception)}else window.location.assign(Url.relativeUrl("calendar/view.php",urlParams))})),root.on("change",CalendarSelectors.elements.courseSelector,(function(){var selectElement=$(this),courseId=selectElement.val();const courseName=$("option:selected",selectElement).text();CalendarViewManager.reloadCurrentMonth(root,courseId,null).then((function(){return root.find(CalendarSelectors.elements.courseSelector).val(courseId)})).then((function(){CalendarViewManager.updateUrl("?view=month&course="+courseId),CalendarViewManager.handleCourseChange(Number(courseId),courseName)})).catch(Notification.exception)}));var eventFormPromise=CalendarCrud.registerEventFormModal(root),contextId=$(SELECTORS_CALENDAR_MONTH_WRAPPER).data("context-id");!function(root,eventFormModalPromise){var body=$("body");body.on(CalendarEvents.created,(function(){CalendarViewManager.reloadCurrentMonth(root)})),body.on(CalendarEvents.deleted,(function(){CalendarViewManager.reloadCurrentMonth(root)})),body.on(CalendarEvents.updated,(function(){CalendarViewManager.reloadCurrentMonth(root)})),body.on(CalendarEvents.editActionEvent,(function(e,url){window.location.assign(url)})),body.on(CalendarEvents.moveEvent,handleMoveEvent),body.on(CalendarEvents.eventMoved,(function(){CalendarViewManager.reloadCurrentMonth(root)})),body.on(CalendarEvents.monthChanged,root,(async function(){const monthName=body.find(SELECTORS_CURRENT_MONTH).text(),monthAnnoucement=await Str.get_string("newmonthannouncement","calendar",monthName);body.find(SELECTORS_SCREEN_READER_ANNOUNCEMENTS).html(monthAnnoucement)})),CalendarCrud.registerEditListeners(root,eventFormModalPromise)}(root,eventFormPromise),contextId&&root.on("click",SELECTORS_DAY,(function(e){var target=$(e.target);const displayingSmallBlockCalendar="side-pre"===root.parents("aside").data("blockregion");if(!viewingFullCalendar&&displayingSmallBlockCalendar){const dateContainer=target.closest(SELECTORS_DAY),courseId=target.closest(CalendarSelectors.wrapper).data("courseid"),params={view:"day",time:dateContainer.data("day-timestamp"),course:courseId};window.location.assign(Url.relativeUrl("calendar/view.php",params))}else{if(!target.closest(SELECTORS_VIEW_DAY_LINK).length){var startTime=$(this).attr("data-new-event-timestamp");eventFormPromise.then((function(modal){var wrapper=target.closest(CalendarSelectors.wrapper);modal.setCourseId(wrapper.data("courseid"));var categoryId=wrapper.data("categoryid");void 0!==categoryId&&modal.setCategoryId(categoryId),modal.setContextId(wrapper.data("contextId")),modal.setStartTime(startTime),modal.show()})).catch(Notification.exception)}}e.preventDefault()}))};return{init:function(root){root=$(root),CalendarViewManager.init(root),registerEventListeners(root)}}})); //# sourceMappingURL=calendar.min.js.map \ No newline at end of file diff --git a/calendar/amd/build/calendar.min.js.map b/calendar/amd/build/calendar.min.js.map index 88b20c4ed2641..ce0ac0f82e0e3 100644 --- a/calendar/amd/build/calendar.min.js.map +++ b/calendar/amd/build/calendar.min.js.map @@ -1 +1 @@ -{"version":3,"file":"calendar.min.js","sources":["../src/calendar.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * This module is the highest level module for the calendar. It is\n * responsible for initialising all of the components required for\n * the calendar to run. It also coordinates the interaction between\n * components by listening for and responding to different events\n * triggered within the calendar UI.\n *\n * @module core_calendar/calendar\n * @copyright 2017 Simey Lameze \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([\n 'jquery',\n 'core/templates',\n 'core/notification',\n 'core_calendar/repository',\n 'core_calendar/events',\n 'core_calendar/view_manager',\n 'core_calendar/crud',\n 'core_calendar/selectors',\n 'core/config',\n 'core/url',\n],\nfunction(\n $,\n Templates,\n Notification,\n CalendarRepository,\n CalendarEvents,\n CalendarViewManager,\n CalendarCrud,\n CalendarSelectors,\n Config,\n Url,\n) {\n\n var SELECTORS = {\n ROOT: \"[data-region='calendar']\",\n DAY: \"[data-region='day']\",\n NEW_EVENT_BUTTON: \"[data-action='new-event-button']\",\n DAY_CONTENT: \"[data-region='day-content']\",\n LOADING_ICON: '.loading-icon',\n VIEW_DAY_LINK: \"[data-action='view-day-link']\",\n CALENDAR_MONTH_WRAPPER: \".calendarwrapper\",\n TODAY: '.today',\n DAY_NUMBER_CIRCLE: '.day-number-circle',\n DAY_NUMBER: '.day-number'\n };\n\n /**\n * Handler for the drag and drop move event. Provides a loading indicator\n * while the request is sent to the server to update the event start date.\n *\n * Triggers a eventMoved calendar javascript event if the event was successfully\n * updated.\n *\n * @param {event} e The calendar move event\n * @param {int} eventId The event id being moved\n * @param {object|null} originElement The jQuery element for where the event is moving from\n * @param {object} destinationElement The jQuery element for where the event is moving to\n */\n var handleMoveEvent = function(e, eventId, originElement, destinationElement) {\n var originTimestamp = null;\n var destinationTimestamp = destinationElement.attr('data-day-timestamp');\n\n if (originElement) {\n originTimestamp = originElement.attr('data-day-timestamp');\n }\n\n // If the event has actually changed day.\n if (!originElement || originTimestamp != destinationTimestamp) {\n Templates.render('core/loading', {})\n .then(function(html, js) {\n // First we show some loading icons in each of the days being affected.\n destinationElement.find(SELECTORS.DAY_CONTENT).addClass('hidden');\n Templates.appendNodeContents(destinationElement, html, js);\n\n if (originElement) {\n originElement.find(SELECTORS.DAY_CONTENT).addClass('hidden');\n Templates.appendNodeContents(originElement, html, js);\n }\n return;\n })\n .then(function() {\n // Send a request to the server to make the change.\n return CalendarRepository.updateEventStartDay(eventId, destinationTimestamp);\n })\n .then(function() {\n // If the update was successful then broadcast an event letting the calendar\n // know that an event has been moved.\n $('body').trigger(CalendarEvents.eventMoved, [eventId, originElement, destinationElement]);\n return;\n })\n .always(function() {\n // Always remove the loading icons regardless of whether the update\n // request was successful or not.\n var destinationLoadingElement = destinationElement.find(SELECTORS.LOADING_ICON);\n destinationElement.find(SELECTORS.DAY_CONTENT).removeClass('hidden');\n Templates.replaceNode(destinationLoadingElement, '', '');\n\n if (originElement) {\n var originLoadingElement = originElement.find(SELECTORS.LOADING_ICON);\n originElement.find(SELECTORS.DAY_CONTENT).removeClass('hidden');\n Templates.replaceNode(originLoadingElement, '', '');\n }\n return;\n })\n .catch(Notification.exception);\n }\n };\n\n /**\n * Listen to and handle any calendar events fired by the calendar UI.\n *\n * @method registerCalendarEventListeners\n * @param {object} root The calendar root element\n * @param {object} eventFormModalPromise A promise reolved with the event form modal\n */\n var registerCalendarEventListeners = function(root, eventFormModalPromise) {\n var body = $('body');\n\n body.on(CalendarEvents.created, function() {\n CalendarViewManager.reloadCurrentMonth(root);\n });\n body.on(CalendarEvents.deleted, function() {\n CalendarViewManager.reloadCurrentMonth(root);\n });\n body.on(CalendarEvents.updated, function() {\n CalendarViewManager.reloadCurrentMonth(root);\n });\n body.on(CalendarEvents.editActionEvent, function(e, url) {\n // Action events needs to be edit directly on the course module.\n window.location.assign(url);\n });\n // Handle the event fired by the drag and drop code.\n body.on(CalendarEvents.moveEvent, handleMoveEvent);\n // When an event is successfully moved we should updated the UI.\n body.on(CalendarEvents.eventMoved, function() {\n CalendarViewManager.reloadCurrentMonth(root);\n });\n\n CalendarCrud.registerEditListeners(root, eventFormModalPromise);\n };\n\n /**\n * Register event listeners for the module.\n *\n * @param {object} root The calendar root element\n */\n var registerEventListeners = function(root) {\n const viewingFullCalendar = document.getElementById(CalendarSelectors.fullCalendarView);\n // Listen the click on the day link to render the day view.\n root.on('click', SELECTORS.VIEW_DAY_LINK, function(e) {\n var dayLink = $(e.target).closest(SELECTORS.VIEW_DAY_LINK);\n var year = dayLink.data('year'),\n month = dayLink.data('month'),\n day = dayLink.data('day'),\n courseId = dayLink.data('courseid'),\n categoryId = dayLink.data('categoryid');\n const urlParams = {\n view: 'day',\n time: dayLink.data('timestamp'),\n course: courseId,\n };\n if (viewingFullCalendar) {\n // Construct the URL parameter string from the urlParams object.\n const urlParamString = Object.entries(urlParams)\n .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)\n .join('&');\n CalendarViewManager.refreshDayContent(root, year, month, day, courseId, categoryId, root,\n 'core_calendar/calendar_day').then(function() {\n e.preventDefault();\n return CalendarViewManager.updateUrl(urlParamString);\n }).catch(Notification.exception);\n } else {\n window.location.assign(Url.relativeUrl('calendar/view.php', urlParams));\n }\n });\n\n root.on('change', CalendarSelectors.elements.courseSelector, function() {\n var selectElement = $(this);\n var courseId = selectElement.val();\n const courseName = $(\"option:selected\", selectElement).text();\n CalendarViewManager.reloadCurrentMonth(root, courseId, null)\n .then(function() {\n // We need to get the selector again because the content has changed.\n return root.find(CalendarSelectors.elements.courseSelector).val(courseId);\n })\n .then(function() {\n CalendarViewManager.updateUrl('?view=month&course=' + courseId);\n CalendarViewManager.handleCourseChange(Number(courseId), courseName);\n return;\n })\n .catch(Notification.exception);\n });\n\n var eventFormPromise = CalendarCrud.registerEventFormModal(root),\n contextId = $(SELECTORS.CALENDAR_MONTH_WRAPPER).data('context-id');\n registerCalendarEventListeners(root, eventFormPromise);\n\n if (contextId) {\n // Bind click events to calendar days.\n root.on('click', SELECTORS.DAY, function(e) {\n var target = $(e.target);\n const displayingSmallBlockCalendar = root.parents('aside').data('blockregion') === 'side-pre';\n\n if (!viewingFullCalendar && displayingSmallBlockCalendar) {\n const dateContainer = target.closest(SELECTORS.DAY);\n const wrapper = target.closest(CalendarSelectors.wrapper);\n const courseId = wrapper.data('courseid');\n const params = {\n view: 'day',\n time: dateContainer.data('day-timestamp'),\n course: courseId,\n };\n window.location.assign(Url.relativeUrl('calendar/view.php', params));\n } else {\n const hasViewDayLink = target.closest(SELECTORS.VIEW_DAY_LINK).length;\n const shouldShowNewEventModal = !hasViewDayLink;\n if (shouldShowNewEventModal) {\n var startTime = $(this).attr('data-new-event-timestamp');\n eventFormPromise.then(function(modal) {\n var wrapper = target.closest(CalendarSelectors.wrapper);\n modal.setCourseId(wrapper.data('courseid'));\n\n var categoryId = wrapper.data('categoryid');\n if (typeof categoryId !== 'undefined') {\n modal.setCategoryId(categoryId);\n }\n\n modal.setContextId(wrapper.data('contextId'));\n modal.setStartTime(startTime);\n modal.show();\n return;\n }).catch(Notification.exception);\n }\n }\n e.preventDefault();\n });\n }\n };\n\n return {\n init: function(root) {\n root = $(root);\n CalendarViewManager.init(root);\n registerEventListeners(root);\n }\n };\n});\n"],"names":["define","$","Templates","Notification","CalendarRepository","CalendarEvents","CalendarViewManager","CalendarCrud","CalendarSelectors","Config","Url","SELECTORS","handleMoveEvent","e","eventId","originElement","destinationElement","originTimestamp","destinationTimestamp","attr","render","then","html","js","find","addClass","appendNodeContents","updateEventStartDay","trigger","eventMoved","always","destinationLoadingElement","removeClass","replaceNode","originLoadingElement","catch","exception","registerEventListeners","root","viewingFullCalendar","document","getElementById","fullCalendarView","on","dayLink","target","closest","year","data","month","day","courseId","categoryId","urlParams","view","time","course","urlParamString","Object","entries","map","_ref","key","value","encodeURIComponent","join","refreshDayContent","preventDefault","updateUrl","window","location","assign","relativeUrl","elements","courseSelector","selectElement","this","val","courseName","text","reloadCurrentMonth","handleCourseChange","Number","eventFormPromise","registerEventFormModal","contextId","eventFormModalPromise","body","created","deleted","updated","editActionEvent","url","moveEvent","registerEditListeners","registerCalendarEventListeners","displayingSmallBlockCalendar","parents","dateContainer","wrapper","params","length","startTime","modal","setCourseId","setCategoryId","setContextId","setStartTime","show","init"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"calendar.min.js","sources":["../src/calendar.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * This module is the highest level module for the calendar. It is\n * responsible for initialising all of the components required for\n * the calendar to run. It also coordinates the interaction between\n * components by listening for and responding to different events\n * triggered within the calendar UI.\n *\n * @module core_calendar/calendar\n * @copyright 2017 Simey Lameze \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([\n 'jquery',\n 'core/templates',\n 'core/notification',\n 'core_calendar/repository',\n 'core_calendar/events',\n 'core_calendar/view_manager',\n 'core_calendar/crud',\n 'core_calendar/selectors',\n 'core/config',\n 'core/url',\n 'core/str',\n],\nfunction(\n $,\n Templates,\n Notification,\n CalendarRepository,\n CalendarEvents,\n CalendarViewManager,\n CalendarCrud,\n CalendarSelectors,\n Config,\n Url,\n Str,\n) {\n\n var SELECTORS = {\n ROOT: \"[data-region='calendar']\",\n DAY: \"[data-region='day']\",\n NEW_EVENT_BUTTON: \"[data-action='new-event-button']\",\n DAY_CONTENT: \"[data-region='day-content']\",\n LOADING_ICON: '.loading-icon',\n VIEW_DAY_LINK: \"[data-action='view-day-link']\",\n CALENDAR_MONTH_WRAPPER: \".calendarwrapper\",\n TODAY: '.today',\n DAY_NUMBER_CIRCLE: '.day-number-circle',\n DAY_NUMBER: '.day-number',\n SCREEN_READER_ANNOUNCEMENTS: '.calendar-announcements',\n CURRENT_MONTH: '.calendar-controls .current'\n };\n\n /**\n * Handler for the drag and drop move event. Provides a loading indicator\n * while the request is sent to the server to update the event start date.\n *\n * Triggers a eventMoved calendar javascript event if the event was successfully\n * updated.\n *\n * @param {event} e The calendar move event\n * @param {int} eventId The event id being moved\n * @param {object|null} originElement The jQuery element for where the event is moving from\n * @param {object} destinationElement The jQuery element for where the event is moving to\n */\n var handleMoveEvent = function(e, eventId, originElement, destinationElement) {\n var originTimestamp = null;\n var destinationTimestamp = destinationElement.attr('data-day-timestamp');\n\n if (originElement) {\n originTimestamp = originElement.attr('data-day-timestamp');\n }\n\n // If the event has actually changed day.\n if (!originElement || originTimestamp != destinationTimestamp) {\n Templates.render('core/loading', {})\n .then(function(html, js) {\n // First we show some loading icons in each of the days being affected.\n destinationElement.find(SELECTORS.DAY_CONTENT).addClass('hidden');\n Templates.appendNodeContents(destinationElement, html, js);\n\n if (originElement) {\n originElement.find(SELECTORS.DAY_CONTENT).addClass('hidden');\n Templates.appendNodeContents(originElement, html, js);\n }\n return;\n })\n .then(function() {\n // Send a request to the server to make the change.\n return CalendarRepository.updateEventStartDay(eventId, destinationTimestamp);\n })\n .then(function() {\n // If the update was successful then broadcast an event letting the calendar\n // know that an event has been moved.\n $('body').trigger(CalendarEvents.eventMoved, [eventId, originElement, destinationElement]);\n return;\n })\n .always(function() {\n // Always remove the loading icons regardless of whether the update\n // request was successful or not.\n var destinationLoadingElement = destinationElement.find(SELECTORS.LOADING_ICON);\n destinationElement.find(SELECTORS.DAY_CONTENT).removeClass('hidden');\n Templates.replaceNode(destinationLoadingElement, '', '');\n\n if (originElement) {\n var originLoadingElement = originElement.find(SELECTORS.LOADING_ICON);\n originElement.find(SELECTORS.DAY_CONTENT).removeClass('hidden');\n Templates.replaceNode(originLoadingElement, '', '');\n }\n return;\n })\n .catch(Notification.exception);\n }\n };\n\n /**\n * Listen to and handle any calendar events fired by the calendar UI.\n *\n * @method registerCalendarEventListeners\n * @param {object} root The calendar root element\n * @param {object} eventFormModalPromise A promise reolved with the event form modal\n */\n var registerCalendarEventListeners = function(root, eventFormModalPromise) {\n var body = $('body');\n\n body.on(CalendarEvents.created, function() {\n CalendarViewManager.reloadCurrentMonth(root);\n });\n body.on(CalendarEvents.deleted, function() {\n CalendarViewManager.reloadCurrentMonth(root);\n });\n body.on(CalendarEvents.updated, function() {\n CalendarViewManager.reloadCurrentMonth(root);\n });\n body.on(CalendarEvents.editActionEvent, function(e, url) {\n // Action events needs to be edit directly on the course module.\n window.location.assign(url);\n });\n // Handle the event fired by the drag and drop code.\n body.on(CalendarEvents.moveEvent, handleMoveEvent);\n // When an event is successfully moved we should updated the UI.\n body.on(CalendarEvents.eventMoved, function() {\n CalendarViewManager.reloadCurrentMonth(root);\n });\n // Announce the newly loaded month to screen readers.\n body.on(CalendarEvents.monthChanged, root, async function() {\n const monthName = body.find(SELECTORS.CURRENT_MONTH).text();\n const monthAnnoucement = await Str.get_string('newmonthannouncement', 'calendar', monthName);\n body.find(SELECTORS.SCREEN_READER_ANNOUNCEMENTS).html(monthAnnoucement);\n });\n\n CalendarCrud.registerEditListeners(root, eventFormModalPromise);\n };\n\n /**\n * Register event listeners for the module.\n *\n * @param {object} root The calendar root element\n */\n var registerEventListeners = function(root) {\n const viewingFullCalendar = document.getElementById(CalendarSelectors.fullCalendarView);\n // Listen the click on the day link to render the day view.\n root.on('click', SELECTORS.VIEW_DAY_LINK, function(e) {\n var dayLink = $(e.target).closest(SELECTORS.VIEW_DAY_LINK);\n var year = dayLink.data('year'),\n month = dayLink.data('month'),\n day = dayLink.data('day'),\n courseId = dayLink.data('courseid'),\n categoryId = dayLink.data('categoryid');\n const urlParams = {\n view: 'day',\n time: dayLink.data('timestamp'),\n course: courseId,\n };\n if (viewingFullCalendar) {\n // Construct the URL parameter string from the urlParams object.\n const urlParamString = Object.entries(urlParams)\n .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)\n .join('&');\n CalendarViewManager.refreshDayContent(root, year, month, day, courseId, categoryId, root,\n 'core_calendar/calendar_day').then(function() {\n e.preventDefault();\n return CalendarViewManager.updateUrl(urlParamString);\n }).catch(Notification.exception);\n } else {\n window.location.assign(Url.relativeUrl('calendar/view.php', urlParams));\n }\n });\n\n root.on('change', CalendarSelectors.elements.courseSelector, function() {\n var selectElement = $(this);\n var courseId = selectElement.val();\n const courseName = $(\"option:selected\", selectElement).text();\n CalendarViewManager.reloadCurrentMonth(root, courseId, null)\n .then(function() {\n // We need to get the selector again because the content has changed.\n return root.find(CalendarSelectors.elements.courseSelector).val(courseId);\n })\n .then(function() {\n CalendarViewManager.updateUrl('?view=month&course=' + courseId);\n CalendarViewManager.handleCourseChange(Number(courseId), courseName);\n return;\n })\n .catch(Notification.exception);\n });\n\n var eventFormPromise = CalendarCrud.registerEventFormModal(root),\n contextId = $(SELECTORS.CALENDAR_MONTH_WRAPPER).data('context-id');\n registerCalendarEventListeners(root, eventFormPromise);\n\n if (contextId) {\n // Bind click events to calendar days.\n root.on('click', SELECTORS.DAY, function(e) {\n var target = $(e.target);\n const displayingSmallBlockCalendar = root.parents('aside').data('blockregion') === 'side-pre';\n\n if (!viewingFullCalendar && displayingSmallBlockCalendar) {\n const dateContainer = target.closest(SELECTORS.DAY);\n const wrapper = target.closest(CalendarSelectors.wrapper);\n const courseId = wrapper.data('courseid');\n const params = {\n view: 'day',\n time: dateContainer.data('day-timestamp'),\n course: courseId,\n };\n window.location.assign(Url.relativeUrl('calendar/view.php', params));\n } else {\n const hasViewDayLink = target.closest(SELECTORS.VIEW_DAY_LINK).length;\n const shouldShowNewEventModal = !hasViewDayLink;\n if (shouldShowNewEventModal) {\n var startTime = $(this).attr('data-new-event-timestamp');\n eventFormPromise.then(function(modal) {\n var wrapper = target.closest(CalendarSelectors.wrapper);\n modal.setCourseId(wrapper.data('courseid'));\n\n var categoryId = wrapper.data('categoryid');\n if (typeof categoryId !== 'undefined') {\n modal.setCategoryId(categoryId);\n }\n\n modal.setContextId(wrapper.data('contextId'));\n modal.setStartTime(startTime);\n modal.show();\n return;\n }).catch(Notification.exception);\n }\n }\n e.preventDefault();\n });\n }\n };\n\n return {\n init: function(root) {\n root = $(root);\n CalendarViewManager.init(root);\n registerEventListeners(root);\n }\n };\n});\n"],"names":["define","$","Templates","Notification","CalendarRepository","CalendarEvents","CalendarViewManager","CalendarCrud","CalendarSelectors","Config","Url","Str","SELECTORS","handleMoveEvent","e","eventId","originElement","destinationElement","originTimestamp","destinationTimestamp","attr","render","then","html","js","find","addClass","appendNodeContents","updateEventStartDay","trigger","eventMoved","always","destinationLoadingElement","removeClass","replaceNode","originLoadingElement","catch","exception","registerEventListeners","root","viewingFullCalendar","document","getElementById","fullCalendarView","on","dayLink","target","closest","year","data","month","day","courseId","categoryId","urlParams","view","time","course","urlParamString","Object","entries","map","_ref","key","value","encodeURIComponent","join","refreshDayContent","preventDefault","updateUrl","window","location","assign","relativeUrl","elements","courseSelector","selectElement","this","val","courseName","text","reloadCurrentMonth","handleCourseChange","Number","eventFormPromise","registerEventFormModal","contextId","eventFormModalPromise","body","created","deleted","updated","editActionEvent","url","moveEvent","monthChanged","async","monthName","monthAnnoucement","get_string","registerEditListeners","registerCalendarEventListeners","displayingSmallBlockCalendar","parents","dateContainer","wrapper","params","length","startTime","modal","setCourseId","setCategoryId","setContextId","setStartTime","show","init"],"mappings":"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"} \ No newline at end of file diff --git a/calendar/amd/build/popover.min.js b/calendar/amd/build/popover.min.js index e66571e8fbe11..e7c42111597d4 100644 --- a/calendar/amd/build/popover.min.js +++ b/calendar/amd/build/popover.min.js @@ -6,6 +6,6 @@ define("core_calendar/popover",["theme_boost/popover","jquery","core_calendar/se * @copyright 2021 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 4.0 - */function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}_jquery=(obj=_jquery)&&obj.__esModule?obj:{default:obj},CalendarSelectors=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(CalendarSelectors);const isPopoverConfigured=new Map,showPopover=target=>{if(!isPopoverConfigured.has(target)){const dateEle=(0,_jquery.default)(target);dateEle.popover({trigger:"manual",placement:"top",html:!0,content:()=>{const source=dateEle.find(CalendarSelectors.elements.dateContent),content=(0,_jquery.default)("
");if(source.length){const temptContent=source.find(".hidden").clone(!1);content.html(temptContent.html())}return content.html()}}),isPopoverConfigured.set(target,!0)}var dateContainer;dateContainer=target,"none"===window.getComputedStyle(dateContainer.querySelector(CalendarSelectors.elements.dateContent)).display&&((0,_jquery.default)(target).popover("show"),target.addEventListener("mouseleave",hidePopover),target.addEventListener("focusout",hidePopover))},hidePopover=e=>{const target=e.target,dateContainer=e.target.closest(CalendarSelectors.elements.dateContainer);if(dateContainer&&isPopoverConfigured.has(dateContainer)){const isTargetActive=target.contains(document.activeElement),isTargetHover=target.matches(":hover");isTargetActive||isTargetHover||((0,_jquery.default)(dateContainer).popover("hide"),dateContainer.removeEventListener("mouseleave",hidePopover),dateContainer.removeEventListener("focusout",hidePopover))}};let listenersRegistered=!1;listenersRegistered||((()=>{const showPopoverHandler=e=>{const dateContainer=e.target.closest(CalendarSelectors.elements.dateContainer);dateContainer&&(e.preventDefault(),showPopover(dateContainer))};document.addEventListener("mouseover",showPopoverHandler),document.addEventListener("focusin",showPopoverHandler)})(),listenersRegistered=!0)})); + */function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}_jquery=(obj=_jquery)&&obj.__esModule?obj:{default:obj},CalendarSelectors=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(CalendarSelectors);const isPopoverConfigured=new Map,showPopover=target=>{const dateContainer=target.closest(CalendarSelectors.elements.dateContainer);if(!isPopoverConfigured.has(dateContainer)){(0,_jquery.default)(target).popover({trigger:"manual",placement:"top",html:!0,title:dateContainer.dataset.title,content:()=>{const source=(0,_jquery.default)(dateContainer).find(CalendarSelectors.elements.dateContent),content=(0,_jquery.default)("
");if(source.length){const temptContent=source.find(".hidden").clone(!1);content.html(temptContent.html())}return content.html()}}),isPopoverConfigured.set(dateContainer,!0)}(dateContainer=>"none"===window.getComputedStyle(dateContainer.querySelector(CalendarSelectors.elements.dateContent)).display)(dateContainer)&&((0,_jquery.default)(target).popover("show"),target.addEventListener("mouseleave",hidePopover),target.addEventListener("focusout",hidePopover))},hidePopover=e=>{const target=e.target,dateContainer=e.target.closest(CalendarSelectors.elements.dateContainer);if(dateContainer&&isPopoverConfigured.has(dateContainer)){const isTargetActive=target.contains(document.activeElement),isTargetHover=target.matches(":hover");isTargetActive||isTargetHover||((0,_jquery.default)(target).popover("hide"),target.removeEventListener("mouseleave",hidePopover),target.removeEventListener("focusout",hidePopover))}};let listenersRegistered=!1;listenersRegistered||((()=>{const showPopoverHandler=e=>{const dayLink=e.target.closest(CalendarSelectors.links.dayLink);dayLink&&(e.preventDefault(),showPopover(dayLink))};document.addEventListener("mouseover",showPopoverHandler),document.addEventListener("focusin",showPopoverHandler)})(),listenersRegistered=!0)})); //# sourceMappingURL=popover.min.js.map \ No newline at end of file diff --git a/calendar/amd/build/popover.min.js.map b/calendar/amd/build/popover.min.js.map index 0eea6d290760b..abf6259884424 100644 --- a/calendar/amd/build/popover.min.js.map +++ b/calendar/amd/build/popover.min.js.map @@ -1 +1 @@ -{"version":3,"file":"popover.min.js","sources":["../src/popover.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Javascript popover for the `core_calendar` subsystem.\n *\n * @module core_calendar/popover\n * @copyright 2021 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 4.0\n */\n\nimport 'theme_boost/popover';\nimport jQuery from 'jquery';\nimport * as CalendarSelectors from 'core_calendar/selectors';\n\n/**\n * Check if we are allowing to enable the popover or not.\n * @param {Element} dateContainer\n * @returns {boolean}\n */\nconst isPopoverAvailable = (dateContainer) => {\n return window.getComputedStyle(dateContainer.querySelector(CalendarSelectors.elements.dateContent)).display === 'none';\n};\n\nconst isPopoverConfigured = new Map();\n\nconst showPopover = target => {\n if (!isPopoverConfigured.has(target)) {\n const dateEle = jQuery(target);\n dateEle.popover({\n trigger: 'manual',\n placement: 'top',\n html: true,\n content: () => {\n const source = dateEle.find(CalendarSelectors.elements.dateContent);\n const content = jQuery('
');\n if (source.length) {\n const temptContent = source.find('.hidden').clone(false);\n content.html(temptContent.html());\n }\n return content.html();\n }\n });\n\n isPopoverConfigured.set(target, true);\n }\n\n if (isPopoverAvailable(target)) {\n jQuery(target).popover('show');\n target.addEventListener('mouseleave', hidePopover);\n target.addEventListener('focusout', hidePopover);\n }\n};\n\nconst hidePopover = e => {\n const target = e.target;\n const dateContainer = e.target.closest(CalendarSelectors.elements.dateContainer);\n if (!dateContainer) {\n return;\n }\n if (isPopoverConfigured.has(dateContainer)) {\n const isTargetActive = target.contains(document.activeElement);\n const isTargetHover = target.matches(':hover');\n if (!isTargetActive && !isTargetHover) {\n jQuery(dateContainer).popover('hide');\n dateContainer.removeEventListener('mouseleave', hidePopover);\n dateContainer.removeEventListener('focusout', hidePopover);\n }\n }\n};\n\n/**\n * Register events for date container.\n */\nconst registerEventListeners = () => {\n const showPopoverHandler = (e) => {\n const dateContainer = e.target.closest(CalendarSelectors.elements.dateContainer);\n if (!dateContainer) {\n return;\n }\n\n e.preventDefault();\n showPopover(dateContainer);\n };\n\n document.addEventListener('mouseover', showPopoverHandler);\n document.addEventListener('focusin', showPopoverHandler);\n};\n\nlet listenersRegistered = false;\nif (!listenersRegistered) {\n registerEventListeners();\n listenersRegistered = true;\n}\n"],"names":["isPopoverConfigured","Map","showPopover","target","has","dateEle","popover","trigger","placement","html","content","source","find","CalendarSelectors","elements","dateContent","length","temptContent","clone","set","dateContainer","window","getComputedStyle","querySelector","display","addEventListener","hidePopover","e","closest","isTargetActive","contains","document","activeElement","isTargetHover","matches","removeEventListener","listenersRegistered","showPopoverHandler","preventDefault","registerEventListeners"],"mappings":";;;;;;;;wgCAqCMA,oBAAsB,IAAIC,IAE1BC,YAAcC,aACXH,oBAAoBI,IAAID,QAAS,OAC5BE,SAAU,mBAAOF,QACvBE,QAAQC,QAAQ,CACZC,QAAS,SACTC,UAAW,MACXC,MAAM,EACNC,QAAS,WACCC,OAASN,QAAQO,KAAKC,kBAAkBC,SAASC,aACjDL,SAAU,mBAAO,YACnBC,OAAOK,OAAQ,OACTC,aAAeN,OAAOC,KAAK,WAAWM,OAAM,GAClDR,QAAQD,KAAKQ,aAAaR,eAEvBC,QAAQD,UAIvBT,oBAAoBmB,IAAIhB,QAAQ,GAxBZiB,IAAAA,cAAAA,cA2BDjB,OA1ByF,SAAzGkB,OAAOC,iBAAiBF,cAAcG,cAAcV,kBAAkBC,SAASC,cAAcS,8BA2BzFrB,QAAQG,QAAQ,QACvBH,OAAOsB,iBAAiB,aAAcC,aACtCvB,OAAOsB,iBAAiB,WAAYC,eAItCA,YAAcC,UACVxB,OAASwB,EAAExB,OACXiB,cAAgBO,EAAExB,OAAOyB,QAAQf,kBAAkBC,SAASM,kBAC7DA,eAGDpB,oBAAoBI,IAAIgB,eAAgB,OAClCS,eAAiB1B,OAAO2B,SAASC,SAASC,eAC1CC,cAAgB9B,OAAO+B,QAAQ,UAChCL,gBAAmBI,oCACbb,eAAed,QAAQ,QAC9Bc,cAAce,oBAAoB,aAAcT,aAChDN,cAAce,oBAAoB,WAAYT,oBAuBtDU,qBAAsB,EACrBA,sBAhB0B,YACrBC,mBAAsBV,UAClBP,cAAgBO,EAAExB,OAAOyB,QAAQf,kBAAkBC,SAASM,eAC7DA,gBAILO,EAAEW,iBACFpC,YAAYkB,iBAGhBW,SAASN,iBAAiB,YAAaY,oBACvCN,SAASN,iBAAiB,UAAWY,qBAKrCE,GACAH,qBAAsB"} \ No newline at end of file +{"version":3,"file":"popover.min.js","sources":["../src/popover.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Javascript popover for the `core_calendar` subsystem.\n *\n * @module core_calendar/popover\n * @copyright 2021 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 4.0\n */\n\nimport 'theme_boost/popover';\nimport jQuery from 'jquery';\nimport * as CalendarSelectors from 'core_calendar/selectors';\n\n/**\n * Check if we are allowing to enable the popover or not.\n * @param {Element} dateContainer\n * @returns {boolean}\n */\nconst isPopoverAvailable = (dateContainer) => {\n return window.getComputedStyle(dateContainer.querySelector(CalendarSelectors.elements.dateContent)).display === 'none';\n};\n\nconst isPopoverConfigured = new Map();\n\nconst showPopover = target => {\n const dateContainer = target.closest(CalendarSelectors.elements.dateContainer);\n if (!isPopoverConfigured.has(dateContainer)) {\n const dateEle = jQuery(target);\n dateEle.popover({\n trigger: 'manual',\n placement: 'top',\n html: true,\n title: dateContainer.dataset.title,\n content: () => {\n const source = jQuery(dateContainer).find(CalendarSelectors.elements.dateContent);\n const content = jQuery('
');\n if (source.length) {\n const temptContent = source.find('.hidden').clone(false);\n content.html(temptContent.html());\n }\n return content.html();\n }\n });\n\n isPopoverConfigured.set(dateContainer, true);\n }\n\n if (isPopoverAvailable(dateContainer)) {\n jQuery(target).popover('show');\n target.addEventListener('mouseleave', hidePopover);\n target.addEventListener('focusout', hidePopover);\n }\n};\n\nconst hidePopover = e => {\n const target = e.target;\n const dateContainer = e.target.closest(CalendarSelectors.elements.dateContainer);\n if (!dateContainer) {\n return;\n }\n if (isPopoverConfigured.has(dateContainer)) {\n const isTargetActive = target.contains(document.activeElement);\n const isTargetHover = target.matches(':hover');\n if (!isTargetActive && !isTargetHover) {\n jQuery(target).popover('hide');\n target.removeEventListener('mouseleave', hidePopover);\n target.removeEventListener('focusout', hidePopover);\n }\n }\n};\n\n/**\n * Register events for date container.\n */\nconst registerEventListeners = () => {\n const showPopoverHandler = (e) => {\n const dayLink = e.target.closest(CalendarSelectors.links.dayLink);\n if (!dayLink) {\n return;\n }\n\n e.preventDefault();\n showPopover(dayLink);\n };\n\n document.addEventListener('mouseover', showPopoverHandler);\n document.addEventListener('focusin', showPopoverHandler);\n};\n\nlet listenersRegistered = false;\nif (!listenersRegistered) {\n registerEventListeners();\n listenersRegistered = true;\n}\n"],"names":["isPopoverConfigured","Map","showPopover","target","dateContainer","closest","CalendarSelectors","elements","has","popover","trigger","placement","html","title","dataset","content","source","find","dateContent","length","temptContent","clone","set","window","getComputedStyle","querySelector","display","isPopoverAvailable","addEventListener","hidePopover","e","isTargetActive","contains","document","activeElement","isTargetHover","matches","removeEventListener","listenersRegistered","showPopoverHandler","dayLink","links","preventDefault","registerEventListeners"],"mappings":";;;;;;;;wgCAqCMA,oBAAsB,IAAIC,IAE1BC,YAAcC,eACVC,cAAgBD,OAAOE,QAAQC,kBAAkBC,SAASH,mBAC3DJ,oBAAoBQ,IAAIJ,eAAgB,EACzB,mBAAOD,QACfM,QAAQ,CACZC,QAAS,SACTC,UAAW,MACXC,MAAM,EACNC,MAAOT,cAAcU,QAAQD,MAC7BE,QAAS,WACCC,QAAS,mBAAOZ,eAAea,KAAKX,kBAAkBC,SAASW,aAC/DH,SAAU,mBAAO,YACnBC,OAAOG,OAAQ,OACTC,aAAeJ,OAAOC,KAAK,WAAWI,OAAM,GAClDN,QAAQH,KAAKQ,aAAaR,eAEvBG,QAAQH,UAIvBZ,oBAAoBsB,IAAIlB,eAAe,GA1BnBA,CAAAA,eACwF,SAAzGmB,OAAOC,iBAAiBpB,cAAcqB,cAAcnB,kBAAkBC,SAASW,cAAcQ,QA4BhGC,CAAmBvB,qCACZD,QAAQM,QAAQ,QACvBN,OAAOyB,iBAAiB,aAAcC,aACtC1B,OAAOyB,iBAAiB,WAAYC,eAItCA,YAAcC,UACV3B,OAAS2B,EAAE3B,OACXC,cAAgB0B,EAAE3B,OAAOE,QAAQC,kBAAkBC,SAASH,kBAC7DA,eAGDJ,oBAAoBQ,IAAIJ,eAAgB,OAClC2B,eAAiB5B,OAAO6B,SAASC,SAASC,eAC1CC,cAAgBhC,OAAOiC,QAAQ,UAChCL,gBAAmBI,oCACbhC,QAAQM,QAAQ,QACvBN,OAAOkC,oBAAoB,aAAcR,aACzC1B,OAAOkC,oBAAoB,WAAYR,oBAuB/CS,qBAAsB,EACrBA,sBAhB0B,YACrBC,mBAAsBT,UAClBU,QAAUV,EAAE3B,OAAOE,QAAQC,kBAAkBmC,MAAMD,SACpDA,UAILV,EAAEY,iBACFxC,YAAYsC,WAGhBP,SAASL,iBAAiB,YAAaW,oBACvCN,SAASL,iBAAiB,UAAWW,qBAKrCI,GACAL,qBAAsB"} \ No newline at end of file diff --git a/calendar/amd/build/selectors.min.js b/calendar/amd/build/selectors.min.js index 97d9acf6f812d..964f965fbdfec 100644 --- a/calendar/amd/build/selectors.min.js +++ b/calendar/amd/build/selectors.min.js @@ -5,6 +5,6 @@ * @copyright 2017 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -define("core_calendar/selectors",[],(function(){return{eventFilterItem:"[data-action='filter-event-type']",eventType:{site:"[data-eventtype-site]",category:"[data-eventtype-category]",course:"[data-eventtype-course]",group:"[data-eventtype-group]",user:"[data-eventtype-user]",other:"[data-eventtype-other]"},popoverType:{site:"[data-popover-eventtype-site]",category:"[data-popover-eventtype-category]",course:"[data-popover-eventtype-course]",group:"[data-popover-eventtype-group]",user:"[data-popover-eventtype-user]",other:"[data-popover-eventtype-other]"},calendarPeriods:{month:"[data-period='month']"},courseSelector:'select[name="course"]',viewSelector:'div[data-region="view-selector"]',actions:{create:'[data-action="new-event-button"]',edit:'[data-action="edit"]',remove:'[data-action="delete"]',viewEvent:'[data-action="view-event"]',deleteSubscription:'[data-action="delete-subscription"]'},elements:{courseSelector:'select[name="course"]',dateContainer:".clickable.hasevent",dateContent:'[data-region="day-content"]',monthDetailed:".calendarmonth.calendartable"},today:".today",day:'[data-region="day"]',calendarMain:'[data-region="calendar"]',wrapper:".calendarwrapper",eventItem:'[data-type="event"]',links:{navLink:".calendarwrapper .arrow_link",eventLink:"[data-region='event-item']",miniDayLink:"[data-region='mini-day-link']"},containers:{loadingIcon:'[data-region="overlay-icon-container"]'},mainCalendar:".maincalendar .heightcontainer",fullCalendarView:"page-calendar-view",pageHeaderHeadings:".page-header-headings h1"}})); +define("core_calendar/selectors",[],(function(){return{eventFilterItem:"[data-action='filter-event-type']",eventType:{site:"[data-eventtype-site]",category:"[data-eventtype-category]",course:"[data-eventtype-course]",group:"[data-eventtype-group]",user:"[data-eventtype-user]",other:"[data-eventtype-other]"},popoverType:{site:"[data-popover-eventtype-site]",category:"[data-popover-eventtype-category]",course:"[data-popover-eventtype-course]",group:"[data-popover-eventtype-group]",user:"[data-popover-eventtype-user]",other:"[data-popover-eventtype-other]"},calendarPeriods:{month:"[data-period='month']"},courseSelector:'select[name="course"]',viewSelector:'div[data-region="view-selector"]',actions:{create:'[data-action="new-event-button"]',edit:'[data-action="edit"]',remove:'[data-action="delete"]',viewEvent:'[data-action="view-event"]',deleteSubscription:'[data-action="delete-subscription"]'},elements:{courseSelector:'select[name="course"]',dateContainer:".clickable.hasevent",dateContent:'[data-region="day-content"]',monthDetailed:".calendarmonth.calendartable"},today:".today",day:'[data-region="day"]',calendarMain:'[data-region="calendar"]',wrapper:".calendarwrapper",eventItem:'[data-type="event"]',links:{navLink:".calendarwrapper .arrow_link",eventLink:"[data-region='event-item']",miniDayLink:"[data-region='mini-day-link']",dayLink:"[data-action='view-day-link']"},containers:{loadingIcon:'[data-region="overlay-icon-container"]'},mainCalendar:".maincalendar .heightcontainer",fullCalendarView:"page-calendar-view",pageHeaderHeadings:".page-header-headings h1"}})); //# sourceMappingURL=selectors.min.js.map \ No newline at end of file diff --git a/calendar/amd/build/selectors.min.js.map b/calendar/amd/build/selectors.min.js.map index b5cff793d81bf..13f7e11819296 100644 --- a/calendar/amd/build/selectors.min.js.map +++ b/calendar/amd/build/selectors.min.js.map @@ -1 +1 @@ -{"version":3,"file":"selectors.min.js","sources":["../src/selectors.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * CSS selectors for the calendar.\n *\n * @module core_calendar/selectors\n * @copyright 2017 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([], function() {\n return {\n eventFilterItem: \"[data-action='filter-event-type']\",\n eventType: {\n site: \"[data-eventtype-site]\",\n category: \"[data-eventtype-category]\",\n course: \"[data-eventtype-course]\",\n group: \"[data-eventtype-group]\",\n user: \"[data-eventtype-user]\",\n other: \"[data-eventtype-other]\",\n },\n popoverType: {\n site: \"[data-popover-eventtype-site]\",\n category: \"[data-popover-eventtype-category]\",\n course: \"[data-popover-eventtype-course]\",\n group: \"[data-popover-eventtype-group]\",\n user: \"[data-popover-eventtype-user]\",\n other: \"[data-popover-eventtype-other]\",\n },\n calendarPeriods: {\n month: \"[data-period='month']\",\n },\n courseSelector: 'select[name=\"course\"]',\n viewSelector: 'div[data-region=\"view-selector\"]',\n actions: {\n create: '[data-action=\"new-event-button\"]',\n edit: '[data-action=\"edit\"]',\n remove: '[data-action=\"delete\"]',\n viewEvent: '[data-action=\"view-event\"]',\n deleteSubscription: '[data-action=\"delete-subscription\"]',\n },\n elements: {\n courseSelector: 'select[name=\"course\"]',\n dateContainer: '.clickable.hasevent',\n dateContent: '[data-region=\"day-content\"]',\n monthDetailed: '.calendarmonth.calendartable',\n },\n today: '.today',\n day: '[data-region=\"day\"]',\n calendarMain: '[data-region=\"calendar\"]',\n wrapper: '.calendarwrapper',\n eventItem: '[data-type=\"event\"]',\n links: {\n navLink: '.calendarwrapper .arrow_link',\n eventLink: \"[data-region='event-item']\",\n miniDayLink: \"[data-region='mini-day-link']\",\n },\n containers: {\n loadingIcon: '[data-region=\"overlay-icon-container\"]',\n },\n mainCalendar: '.maincalendar .heightcontainer',\n fullCalendarView: 'page-calendar-view',\n pageHeaderHeadings: '.page-header-headings h1',\n };\n});\n"],"names":["define","eventFilterItem","eventType","site","category","course","group","user","other","popoverType","calendarPeriods","month","courseSelector","viewSelector","actions","create","edit","remove","viewEvent","deleteSubscription","elements","dateContainer","dateContent","monthDetailed","today","day","calendarMain","wrapper","eventItem","links","navLink","eventLink","miniDayLink","containers","loadingIcon","mainCalendar","fullCalendarView","pageHeaderHeadings"],"mappings":";;;;;;;AAsBAA,iCAAO,IAAI,iBACA,CACHC,gBAAiB,oCACjBC,UAAW,CACPC,KAAM,wBACNC,SAAU,4BACVC,OAAQ,0BACRC,MAAO,yBACPC,KAAM,wBACNC,MAAO,0BAEXC,YAAa,CACTN,KAAM,gCACNC,SAAU,oCACVC,OAAQ,kCACRC,MAAO,iCACPC,KAAM,gCACNC,MAAO,kCAEXE,gBAAiB,CACbC,MAAO,yBAEXC,eAAgB,wBAChBC,aAAc,mCACdC,QAAS,CACLC,OAAQ,mCACRC,KAAM,uBACNC,OAAQ,yBACRC,UAAW,6BACXC,mBAAoB,uCAExBC,SAAU,CACNR,eAAgB,wBAChBS,cAAe,sBACfC,YAAa,8BACbC,cAAe,gCAEnBC,MAAO,SACPC,IAAK,sBACLC,aAAc,2BACdC,QAAS,mBACTC,UAAW,sBACXC,MAAO,CACHC,QAAS,+BACTC,UAAW,6BACXC,YAAa,iCAEjBC,WAAY,CACRC,YAAa,0CAEjBC,aAAc,iCACdC,iBAAkB,qBAClBC,mBAAoB"} \ No newline at end of file +{"version":3,"file":"selectors.min.js","sources":["../src/selectors.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * CSS selectors for the calendar.\n *\n * @module core_calendar/selectors\n * @copyright 2017 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([], function() {\n return {\n eventFilterItem: \"[data-action='filter-event-type']\",\n eventType: {\n site: \"[data-eventtype-site]\",\n category: \"[data-eventtype-category]\",\n course: \"[data-eventtype-course]\",\n group: \"[data-eventtype-group]\",\n user: \"[data-eventtype-user]\",\n other: \"[data-eventtype-other]\",\n },\n popoverType: {\n site: \"[data-popover-eventtype-site]\",\n category: \"[data-popover-eventtype-category]\",\n course: \"[data-popover-eventtype-course]\",\n group: \"[data-popover-eventtype-group]\",\n user: \"[data-popover-eventtype-user]\",\n other: \"[data-popover-eventtype-other]\",\n },\n calendarPeriods: {\n month: \"[data-period='month']\",\n },\n courseSelector: 'select[name=\"course\"]',\n viewSelector: 'div[data-region=\"view-selector\"]',\n actions: {\n create: '[data-action=\"new-event-button\"]',\n edit: '[data-action=\"edit\"]',\n remove: '[data-action=\"delete\"]',\n viewEvent: '[data-action=\"view-event\"]',\n deleteSubscription: '[data-action=\"delete-subscription\"]',\n },\n elements: {\n courseSelector: 'select[name=\"course\"]',\n dateContainer: '.clickable.hasevent',\n dateContent: '[data-region=\"day-content\"]',\n monthDetailed: '.calendarmonth.calendartable',\n },\n today: '.today',\n day: '[data-region=\"day\"]',\n calendarMain: '[data-region=\"calendar\"]',\n wrapper: '.calendarwrapper',\n eventItem: '[data-type=\"event\"]',\n links: {\n navLink: '.calendarwrapper .arrow_link',\n eventLink: \"[data-region='event-item']\",\n miniDayLink: \"[data-region='mini-day-link']\",\n dayLink: \"[data-action='view-day-link']\",\n },\n containers: {\n loadingIcon: '[data-region=\"overlay-icon-container\"]',\n },\n mainCalendar: '.maincalendar .heightcontainer',\n fullCalendarView: 'page-calendar-view',\n pageHeaderHeadings: '.page-header-headings h1',\n };\n});\n"],"names":["define","eventFilterItem","eventType","site","category","course","group","user","other","popoverType","calendarPeriods","month","courseSelector","viewSelector","actions","create","edit","remove","viewEvent","deleteSubscription","elements","dateContainer","dateContent","monthDetailed","today","day","calendarMain","wrapper","eventItem","links","navLink","eventLink","miniDayLink","dayLink","containers","loadingIcon","mainCalendar","fullCalendarView","pageHeaderHeadings"],"mappings":";;;;;;;AAsBAA,iCAAO,IAAI,iBACA,CACHC,gBAAiB,oCACjBC,UAAW,CACPC,KAAM,wBACNC,SAAU,4BACVC,OAAQ,0BACRC,MAAO,yBACPC,KAAM,wBACNC,MAAO,0BAEXC,YAAa,CACTN,KAAM,gCACNC,SAAU,oCACVC,OAAQ,kCACRC,MAAO,iCACPC,KAAM,gCACNC,MAAO,kCAEXE,gBAAiB,CACbC,MAAO,yBAEXC,eAAgB,wBAChBC,aAAc,mCACdC,QAAS,CACLC,OAAQ,mCACRC,KAAM,uBACNC,OAAQ,yBACRC,UAAW,6BACXC,mBAAoB,uCAExBC,SAAU,CACNR,eAAgB,wBAChBS,cAAe,sBACfC,YAAa,8BACbC,cAAe,gCAEnBC,MAAO,SACPC,IAAK,sBACLC,aAAc,2BACdC,QAAS,mBACTC,UAAW,sBACXC,MAAO,CACHC,QAAS,+BACTC,UAAW,6BACXC,YAAa,gCACbC,QAAS,iCAEbC,WAAY,CACRC,YAAa,0CAEjBC,aAAc,iCACdC,iBAAkB,qBAClBC,mBAAoB"} \ No newline at end of file diff --git a/calendar/amd/src/calendar.js b/calendar/amd/src/calendar.js index 1ca8b17de066c..ee978d65f8aeb 100644 --- a/calendar/amd/src/calendar.js +++ b/calendar/amd/src/calendar.js @@ -35,6 +35,7 @@ define([ 'core_calendar/selectors', 'core/config', 'core/url', + 'core/str', ], function( $, @@ -47,6 +48,7 @@ function( CalendarSelectors, Config, Url, + Str, ) { var SELECTORS = { @@ -59,7 +61,9 @@ function( CALENDAR_MONTH_WRAPPER: ".calendarwrapper", TODAY: '.today', DAY_NUMBER_CIRCLE: '.day-number-circle', - DAY_NUMBER: '.day-number' + DAY_NUMBER: '.day-number', + SCREEN_READER_ANNOUNCEMENTS: '.calendar-announcements', + CURRENT_MONTH: '.calendar-controls .current' }; /** @@ -153,6 +157,12 @@ function( body.on(CalendarEvents.eventMoved, function() { CalendarViewManager.reloadCurrentMonth(root); }); + // Announce the newly loaded month to screen readers. + body.on(CalendarEvents.monthChanged, root, async function() { + const monthName = body.find(SELECTORS.CURRENT_MONTH).text(); + const monthAnnoucement = await Str.get_string('newmonthannouncement', 'calendar', monthName); + body.find(SELECTORS.SCREEN_READER_ANNOUNCEMENTS).html(monthAnnoucement); + }); CalendarCrud.registerEditListeners(root, eventFormModalPromise); }; diff --git a/calendar/amd/src/popover.js b/calendar/amd/src/popover.js index af77242d8bccd..e4434dfb6ef4a 100644 --- a/calendar/amd/src/popover.js +++ b/calendar/amd/src/popover.js @@ -38,14 +38,16 @@ const isPopoverAvailable = (dateContainer) => { const isPopoverConfigured = new Map(); const showPopover = target => { - if (!isPopoverConfigured.has(target)) { + const dateContainer = target.closest(CalendarSelectors.elements.dateContainer); + if (!isPopoverConfigured.has(dateContainer)) { const dateEle = jQuery(target); dateEle.popover({ trigger: 'manual', placement: 'top', html: true, + title: dateContainer.dataset.title, content: () => { - const source = dateEle.find(CalendarSelectors.elements.dateContent); + const source = jQuery(dateContainer).find(CalendarSelectors.elements.dateContent); const content = jQuery('
'); if (source.length) { const temptContent = source.find('.hidden').clone(false); @@ -55,10 +57,10 @@ const showPopover = target => { } }); - isPopoverConfigured.set(target, true); + isPopoverConfigured.set(dateContainer, true); } - if (isPopoverAvailable(target)) { + if (isPopoverAvailable(dateContainer)) { jQuery(target).popover('show'); target.addEventListener('mouseleave', hidePopover); target.addEventListener('focusout', hidePopover); @@ -75,9 +77,9 @@ const hidePopover = e => { const isTargetActive = target.contains(document.activeElement); const isTargetHover = target.matches(':hover'); if (!isTargetActive && !isTargetHover) { - jQuery(dateContainer).popover('hide'); - dateContainer.removeEventListener('mouseleave', hidePopover); - dateContainer.removeEventListener('focusout', hidePopover); + jQuery(target).popover('hide'); + target.removeEventListener('mouseleave', hidePopover); + target.removeEventListener('focusout', hidePopover); } } }; @@ -87,13 +89,13 @@ const hidePopover = e => { */ const registerEventListeners = () => { const showPopoverHandler = (e) => { - const dateContainer = e.target.closest(CalendarSelectors.elements.dateContainer); - if (!dateContainer) { + const dayLink = e.target.closest(CalendarSelectors.links.dayLink); + if (!dayLink) { return; } e.preventDefault(); - showPopover(dateContainer); + showPopover(dayLink); }; document.addEventListener('mouseover', showPopoverHandler); diff --git a/calendar/amd/src/selectors.js b/calendar/amd/src/selectors.js index aed0e3298f3c9..fa7e3aa374a91 100644 --- a/calendar/amd/src/selectors.js +++ b/calendar/amd/src/selectors.js @@ -66,6 +66,7 @@ define([], function() { navLink: '.calendarwrapper .arrow_link', eventLink: "[data-region='event-item']", miniDayLink: "[data-region='mini-day-link']", + dayLink: "[data-action='view-day-link']", }, containers: { loadingIcon: '[data-region="overlay-icon-container"]', diff --git a/calendar/templates/header.mustache b/calendar/templates/header.mustache index ae856708d752c..57a39d35f0523 100644 --- a/calendar/templates/header.mustache +++ b/calendar/templates/header.mustache @@ -39,4 +39,5 @@ {{{filter_selector}}} {{/filter_selector}} {{> core_calendar/add_event_button}} -
\ No newline at end of file +
+
diff --git a/calendar/tests/behat/behat_calendar.php b/calendar/tests/behat/behat_calendar.php index 3065ae8fd4684..acc758bbd162d 100644 --- a/calendar/tests/behat/behat_calendar.php +++ b/calendar/tests/behat/behat_calendar.php @@ -48,6 +48,10 @@ public static function get_partial_named_selectors(): array { new behat_component_named_selector('mini calendar block', [".//*[@data-block='calendar_month']"]), new behat_component_named_selector('full calendar page', [".//*[@id='page-calendar-view']"]), new behat_component_named_selector('calendar day', [".//*[@data-region='day'][@data-day=%locator%]"]), + new behat_component_named_selector( + 'responsive calendar day', + [".//*[@data-region='day'][@data-day=%locator%]/div[contains(@class, 'hidden-desktop')]"] + ), ]; } @@ -93,33 +97,51 @@ public function i_create_a_calendar_event($data) { /** * Hover over a specific day in the mini-calendar. * - * @Given /^I hover over day "(?P\d+)" of this month in the mini-calendar block$/ + * @Given /^I hover over day "(?P\d+)" of this month in the mini-calendar block(?P responsive view|)$/ * @param int $day The day of the current month + * @param string $responsive If not null, find the responsive version of the link. */ - public function i_hover_over_day_of_this_month_in_mini_calendar_block(int $day): void { - $this->execute("behat_general::i_hover_in_the", - [$day, 'core_calendar > calendar day', '', 'core_calendar > mini calendar block']); + public function i_hover_over_day_of_this_month_in_mini_calendar_block(int $day, string $responsive = ''): void { + $this->execute( + "behat_general::i_hover_in_the", + [ + $day, + empty($responsive) ? 'core_calendar > calendar day' : 'core_calendar > responsive calendar day', + '', + 'core_calendar > mini calendar block', + ], + ); } /** * Hover over a specific day in the full calendar page. * - * @Given /^I hover over day "(?P\d+)" of this month in the full calendar page$/ + * @Given /^I hover over day "(?P\d+)" of this month in the full calendar page(?P responsive view|)$/ * @param int $day The day of the current month + * @param string $responsive If not empty, use the repsonsive view. */ - public function i_hover_over_day_of_this_month_in_full_calendar_page(int $day): void { - $this->execute("behat_general::i_hover_in_the", - [$day, 'core_calendar > calendar day', '', 'core_calendar > full calendar page']); + public function i_hover_over_day_of_this_month_in_full_calendar_page(int $day, string $responsive = ''): void { + $this->execute( + "behat_general::i_hover_in_the", + [ + $day, + empty($responsive) ? 'core_calendar > calendar day' : 'core_calendar > responsive calendar day', + '', + 'core_calendar > full calendar page', + ], + ); } /** * Hover over today in the mini-calendar. * - * @Given /^I hover over today in the mini-calendar block$/ + * @Given /^I hover over today in the mini-calendar block( responsive view|)$/ + * + * @param string $responsive If not empty, use the responsive calendar link. */ - public function i_hover_over_today_in_mini_calendar_block(): void { + public function i_hover_over_today_in_mini_calendar_block(string $responsive = ''): void { $todaysday = date('j'); - $this->i_hover_over_day_of_this_month_in_mini_calendar_block($todaysday); + $this->i_hover_over_day_of_this_month_in_mini_calendar_block($todaysday, $responsive); } /** diff --git a/calendar/tests/behat/calendar.feature b/calendar/tests/behat/calendar.feature index a9cac798352af..3889ee38628c3 100644 --- a/calendar/tests/behat/calendar.feature +++ b/calendar/tests/behat/calendar.feature @@ -280,7 +280,7 @@ Feature: Perform basic calendar functionality # We need to give the browser a couple seconds to re-render the page after the screen has been resized. And I wait "1" seconds And I should not see "Event 1:1" - And I hover over day "1" of this month in the full calendar page + And I hover over day "1" of this month in the full calendar page responsive view And I should see "Event 1:1" @javascript diff --git a/course/amd/build/actions.min.js b/course/amd/build/actions.min.js index 83f0b11c33181..cf01b736161a0 100644 --- a/course/amd/build/actions.min.js +++ b/course/amd/build/actions.min.js @@ -6,6 +6,6 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.3 */ -define("core_course/actions",["jquery","core/ajax","core/templates","core/notification","core/str","core/url","core/yui","core/modal_copy_to_clipboard","core/modal_save_cancel","core/modal_events","core/key_codes","core/log","core_courseformat/courseeditor","core/event_dispatcher","core_course/events"],(function($,ajax,templates,notification,str,url,Y,ModalCopyToClipboard,ModalSaveCancel,ModalEvents,KeyCodes,log,editor,EventDispatcher,CourseEvents){const componentActions=["moveSection","moveCm","addSection","deleteSection","cmDelete","cmDuplicate","sectionHide","sectionShow","cmHide","cmShow","cmStealth","sectionHighlight","sectionUnhighlight","cmMoveRight","cmMoveLeft","cmNoGroups","cmVisibleGroups","cmSeparateGroups"],courseeditor=editor.getCurrentCourseEditor();let formatname;var CSS_EDITINPROGRESS="editinprogress",CSS_EDITINGMOVE="editing_move",SELECTOR={ACTIVITYLI:"li.activity",ACTIONAREA:".actions",ACTIVITYACTION:"a.cm-edit-action",MENU:".moodle-actionmenu[data-enhance=moodle-core-actionmenu]",TOGGLE:".toggle-display,.dropdown-toggle",SECTIONLI:"li.section",SECTIONACTIONMENU:".section_action_menu",SECTIONITEM:'[data-for="section_title"]',ADDSECTIONS:".changenumsections [data-add-sections]",SECTIONBADGES:'[data-region="sectionbadges"]'};Y.use("moodle-course-coursebase",(function(){var courseformatselector=M.course.format.get_section_selector();courseformatselector&&(SELECTOR.SECTIONLI=courseformatselector)}));const dispatchEvent=function(eventName,detail,container,options){return container instanceof Element||void 0===container.get||(container=container.get(0)),EventDispatcher.dispatchEvent(eventName,detail,container,options)};var getModuleId=function(element){const item=element.get(0);if(item.dataset.id)return item.dataset.id;let id;return Y.use("moodle-course-util",(function(Y){id=Y.Moodle.core_course.util.cm.getId(Y.Node(item))})),id},addActivitySpinner=function(activity){activity.addClass(CSS_EDITINPROGRESS);var actionarea=activity.find(SELECTOR.ACTIONAREA).get(0);if(actionarea){var spinner=M.util.add_spinner(Y,Y.Node(actionarea));return spinner.show(),void 0!==activity.data("id")&&courseeditor.dispatch("cmLock",[activity.data("id")],!0),spinner}return null},addSectionSpinner=function(sectionelement){sectionelement.addClass(CSS_EDITINPROGRESS);var actionarea=sectionelement.find(SELECTOR.SECTIONACTIONMENU).get(0);if(actionarea){var spinner=M.util.add_spinner(Y,Y.Node(actionarea));return spinner.show(),void 0!==sectionelement.data("id")&&courseeditor.dispatch("sectionLock",[sectionelement.data("id")],!0),spinner}return null},addSectionLightbox=function(sectionelement){const item=sectionelement.get(0);var lightbox=M.util.add_lightbox(Y,Y.Node(item));return"section"==item.dataset.for&&item.dataset.id&&(courseeditor.dispatch("sectionLock",[item.dataset.id],!0),lightbox.setAttribute("data-state","section"),lightbox.setAttribute("data-state-id",item.dataset.id)),lightbox.show(),lightbox},removeSpinner=function(element,spinner,delay){window.setTimeout((function(){if(element.removeClass(CSS_EDITINPROGRESS),spinner&&spinner.hide(),void 0!==element.data("id")){const mutation="section"===element.data("for")?"sectionLock":"cmLock";courseeditor.dispatch(mutation,[element.data("id")],!1)}}),delay)},removeLightbox=function(lightbox,delay){lightbox&&window.setTimeout((function(){lightbox.hide(),lightbox.getAttribute("data-state")&&courseeditor.dispatch("".concat(lightbox.getAttribute("data-state"),"Lock"),[lightbox.getAttribute("data-state-id")],!1)}),delay)},initActionMenu=function(elementid){Y.use("moodle-course-coursebase",(function(){M.course.coursebase.invoke_function("setup_for_resource","#"+elementid)})),M.core.actionmenu&&M.core.actionmenu.newDOMNode&&M.core.actionmenu.newDOMNode(Y.one("#"+elementid))},editModule=function(moduleElement,cmid,target){var lightbox,action=target.attr("data-action"),spinner=addActivitySpinner(moduleElement),promises=ajax.call([{methodname:"core_course_edit_module",args:{id:cmid,action:action,sectionreturn:target.attr("data-sectionreturn")?target.attr("data-sectionreturn"):null}}],!0);"duplicate"===action&&(lightbox=addSectionLightbox(target.closest(SELECTOR.SECTIONLI))),$.when.apply($,promises).done((function(data){var mainElement,tabables,isInside,foundElement,elementToFocus=(mainElement=moduleElement,tabables=$("a:visible"),isInside=!1,foundElement=null,tabables.each((function(){if($.contains(mainElement[0],this))isInside=!0;else if(isInside)return foundElement=this,!1;return!0})),foundElement);moduleElement.replaceWith(data);let affectedids=[];$("
"+data+"
").find(SELECTOR.ACTIVITYLI).each((function(index){initActionMenu($(this).attr("id")),0===index&&(!function(elementId,action){var mainelement=$("#"+elementId),selector="[data-action="+action+"]";"groupsseparate"!==action&&"groupsvisible"!==action&&"groupsnone"!==action||(selector="[data-action=groupsseparate],[data-action=groupsvisible],[data-action=groupsnone]"),mainelement.find(selector).is(":visible")?mainelement.find(selector).focus():mainelement.find(SELECTOR.MENU).find(SELECTOR.TOGGLE).focus()}($(this).attr("id"),action),elementToFocus=null),affectedids.push(getModuleId($(this)))})),elementToFocus&&elementToFocus.focus(),removeSpinner(moduleElement,spinner,400),removeLightbox(lightbox,400),moduleElement.trigger($.Event("coursemoduleedited",{ajaxreturn:data,action:action})),courseeditor.dispatch("legacyActivityAction",action,cmid,affectedids)})).fail((function(ex){removeSpinner(moduleElement,spinner),removeLightbox(lightbox);var e=$.Event("coursemoduleeditfailed",{exception:ex,action:action});moduleElement.trigger(e),e.isDefaultPrevented()||notification.exception(ex)}))},refreshModule=function(element,cmid,sectionreturn){void 0===sectionreturn&&(sectionreturn=courseeditor.sectionReturn);const activityElement=$(element);var spinner=addActivitySpinner(activityElement),promises=ajax.call([{methodname:"core_course_get_module",args:{id:cmid,sectionreturn:sectionreturn}}],!0);return new Promise(((resolve,reject)=>{$.when.apply($,promises).done((function(data){removeSpinner(activityElement,spinner,400),replaceActivityHtmlWith(data),resolve(data)})).fail((function(){removeSpinner(activityElement,spinner),reject()}))}))},confirmDeleteModule=function(mainelement,onconfirm){var modtypename=mainelement.attr("class").match(/modtype_([^\s]*)/)[1],modulename=function(element){var name;Y.use("moodle-course-util",(function(Y){name=Y.Moodle.core_course.util.cm.getName(Y.Node(element.get(0)))}));const state=courseeditor.state,cmid=getModuleId(element);var _state$cm$get;return!name&&state&&cmid&&(name=null===(_state$cm$get=state.cm.get(cmid))||void 0===_state$cm$get?void 0:_state$cm$get.name),name}(mainelement);str.get_string("pluginname",modtypename).done((function(pluginname){var plugindata={type:pluginname,name:modulename};str.get_strings([{key:"confirm",component:"core"},{key:null===modulename?"deletechecktype":"deletechecktypename",param:plugindata},{key:"yes"},{key:"no"}]).done((function(s){notification.confirm(s[0],s[1],s[2],s[3],onconfirm)}))}))},replaceActionItem=function(actionitem,image,stringname,stringcomponent,newaction){var stringRequests=[{key:stringname,component:stringcomponent}];return str.get_strings(stringRequests).then((function(strings){return actionitem.find("span.menu-action-text").html(strings[0]),templates.renderPix(image,"core")})).then((function(pixhtml){actionitem.find(".icon").replaceWith(pixhtml),actionitem.attr("data-action",newaction)})).catch(notification.exception)},defaultEditSectionHandler=function(sectionElement,actionItem,data,courseformat,sectionid){var action=actionItem.attr("data-action");if("hide"===action||"show"===action){if("hide"===action?(sectionElement.addClass("hidden"),setSectionBadge(sectionElement[0],"hiddenfromstudents",!0,!1),replaceActionItem(actionItem,"i/show","showfromothers","format_"+courseformat,"show")):(setSectionBadge(sectionElement[0],"hiddenfromstudents",!1,!1),sectionElement.removeClass("hidden"),replaceActionItem(actionItem,"i/hide","hidefromothers","format_"+courseformat,"hide")),void 0!==data.modules)for(var i in data.modules)replaceActivityHtmlWith(data.modules[i]);void 0!==data.section_availability&§ionElement.find(".section_availability").first().replaceWith(data.section_availability);void 0!==courseeditor.state.section.get(sectionid)&&courseeditor.dispatch("sectionState",[sectionid])}else if("setmarker"===action){var oldmarker=$(SELECTOR.SECTIONLI+".current"),oldActionItem=oldmarker.find(SELECTOR.SECTIONACTIONMENU+" a[data-action=removemarker]");oldmarker.removeClass("current"),replaceActionItem(oldActionItem,"i/marker","highlight","core","setmarker"),sectionElement.addClass("current"),replaceActionItem(actionItem,"i/marked","highlightoff","core","removemarker"),courseeditor.dispatch("legacySectionAction",action,sectionid),setSectionBadge(sectionElement[0],"iscurrent",!0,!0)}else"removemarker"===action&&(sectionElement.removeClass("current"),replaceActionItem(actionItem,"i/marker","highlight","core","setmarker"),courseeditor.dispatch("legacySectionAction",action,sectionid),setSectionBadge(sectionElement[0],"iscurrent",!1,!0))};var replaceActivityHtmlWith=function(activityHTML){$("
"+activityHTML+"
").find(SELECTOR.ACTIVITYLI).each((function(){var id=$(this).attr("id");let focusedPath=function(id){const element=document.getElementById(id);if(element&&element.contains(document.activeElement))return element.querySelector(SELECTOR.ACTIONAREA).contains(document.activeElement)?"".concat(SELECTOR.ACTIONAREA,' [tabindex="0"]'):document.activeElement.id?"#".concat(document.activeElement.id):void 0}(id);if($(SELECTOR.ACTIVITYLI+"#"+id).replaceWith(activityHTML),initActionMenu(id),focusedPath){var _newItem$querySelecto;null===(_newItem$querySelecto=document.getElementById(id).querySelector(focusedPath))||void 0===_newItem$querySelecto||_newItem$querySelecto.focus()}}))},editSection=function(sectionElement,sectionid,target,courseformat){var action=target.attr("data-action"),sectionreturn=target.attr("data-sectionreturn")?target.attr("data-sectionreturn"):null;if(courseeditor.supportComponents&&componentActions.includes(action))return!1;var spinner=addSectionSpinner(sectionElement),promises=ajax.call([{methodname:"core_course_edit_section",args:{id:sectionid,action:action,sectionreturn:sectionreturn}}],!0),lightbox=addSectionLightbox(sectionElement);return $.when.apply($,promises).done((function(dataencoded){var data=$.parseJSON(dataencoded);removeSpinner(sectionElement,spinner),removeLightbox(lightbox),sectionElement.find(SELECTOR.SECTIONACTIONMENU).find(SELECTOR.TOGGLE).focus();var e=$.Event("coursesectionedited",{ajaxreturn:data,action:action});sectionElement.trigger(e),e.isDefaultPrevented()||defaultEditSectionHandler(sectionElement,target,data,courseformat,sectionid)})).fail((function(ex){removeSpinner(sectionElement,spinner),removeLightbox(lightbox);var e=$.Event("coursesectioneditfailed",{exception:ex,action:action});sectionElement.trigger(e),e.isDefaultPrevented()||notification.exception(ex)})),!0},setSectionBadge=function(sectionElement,badgetype,add,removeOther){const sectionbadges=sectionElement.querySelector(SELECTOR.SECTIONBADGES);if(!sectionbadges)return;const badge=sectionbadges.querySelector('[data-type="'+badgetype+'"]');badge&&(add?(removeOther&&document.querySelectorAll('[data-type="'+badgetype+'"]').forEach((b=>{b.classList.add("d-none")})),badge.classList.remove("d-none")):badge.classList.add("d-none"))};return Y.use("moodle-course-coursebase",(function(){M.course.coursebase.register_module({set_visibility_resource_ui:function(args){var mainelement=$(args.element.getDOMNode()),cmid=getModuleId(mainelement);if(cmid){var sectionreturn=mainelement.find("."+CSS_EDITINGMOVE).attr("data-sectionreturn");refreshModule(mainelement,cmid,sectionreturn)}},updateMovedCmState:params=>{const cm=courseeditor.state.cm.get(params.cmid);void 0!==cm&&courseeditor.dispatch("sectionState",[cm.sectionid]),courseeditor.dispatch("cmState",[params.cmid])},updateMovedSectionState:()=>{courseeditor.dispatch("courseState")}})})),courseeditor.addMutations({legacyActivityAction:function(statemanager,action,cmid,affectedids){const state=statemanager.state,cm=state.cm.get(cmid);if(void 0===cm)return;const section=state.section.get(cm.sectionid);if(void 0!==section){switch(courseeditor.dispatch("cmLock",[cm.id],!0),statemanager.setReadOnly(!1),cm.locked=!1,action){case"delete":section.cmlist=section.cmlist.reduce(((cmlist,current)=>(current!=cmid&&cmlist.push(current),cmlist)),[]),state.cm.delete(cmid);break;case"hide":case"show":case"duplicate":courseeditor.dispatch("cmState",affectedids)}statemanager.setReadOnly(!0)}},legacySectionAction:function(statemanager,action,sectionid){const state=statemanager.state,section=state.section.get(sectionid);if(void 0!==section){switch(statemanager.setReadOnly(!1),section.locked=!0,statemanager.setReadOnly(!0),statemanager.setReadOnly(!1),section.locked=!1,action){case"setmarker":state.section.forEach((current=>{current.id!=sectionid&&(current.current=!1)})),section.current=!0;break;case"removemarker":section.current=!1}statemanager.setReadOnly(!0)}}}),{initCoursePage:function(courseformat){if(formatname=courseformat,$("body").on("click keypress",SELECTOR.ACTIVITYLI+" "+SELECTOR.ACTIVITYACTION+"[data-action]",(function(e){if("keypress"!==e.type||13===e.keyCode){var actionItem=$(this),moduleElement=actionItem.closest(SELECTOR.ACTIVITYLI),action=actionItem.attr("data-action"),moduleId=getModuleId(moduleElement);switch(action){case"moveleft":case"moveright":case"delete":case"duplicate":case"hide":case"stealth":case"show":case"groupsseparate":case"groupsvisible":case"groupsnone":break;default:return}moduleId&&(e.preventDefault(),"delete"===action?confirmDeleteModule(moduleElement,(function(){editModule(moduleElement,moduleId,actionItem)})):editModule(moduleElement,moduleId,actionItem))}})),$("body").on("click keypress",SELECTOR.SECTIONLI+" "+SELECTOR.SECTIONACTIONMENU+"[data-sectionid] a[data-action]",(function(e){if("keypress"===e.type&&13!==e.keyCode)return;var actionItem=$(this),sectionElement=actionItem.closest(SELECTOR.SECTIONLI),sectionId=actionItem.closest(SELECTOR.SECTIONACTIONMENU).attr("data-sectionid");if("permalink"===actionItem.attr("data-action"))return e.preventDefault(),void ModalCopyToClipboard.create({text:actionItem.attr("href")},str.get_string("sectionlink","course"));let isExecuted=!0;var message,onconfirm;actionItem.attr("data-confirm")?(message=actionItem.attr("data-confirm"),onconfirm=function(){isExecuted=editSection(sectionElement,sectionId,actionItem,courseformat)},str.get_strings([{key:"confirm"},{key:"yes"},{key:"no"}]).done((function(s){notification.confirm(s[0],message,s[1],s[2],onconfirm)}))):isExecuted=editSection(sectionElement,sectionId,actionItem,courseformat),isExecuted&&e.preventDefault()})),$("body").on("updated","".concat(SELECTOR.SECTIONLI," ").concat(SELECTOR.SECTIONITEM," [data-inplaceeditable]"),(function(e){if(e.ajaxreturn&&e.ajaxreturn.itemid){void 0!==courseeditor.state.section.get(e.ajaxreturn.itemid)&&courseeditor.dispatch("sectionState",[e.ajaxreturn.itemid])}})),$("body").on("updated","".concat(SELECTOR.ACTIVITYLI," [data-inplaceeditable]"),(function(e){e.ajaxreturn&&e.ajaxreturn.itemid&&courseeditor.dispatch("cmState",[e.ajaxreturn.itemid])})),courseeditor.supportComponents&&componentActions.includes("addSection"))return;const trigger=$(SELECTOR.ADDSECTIONS),modalTitle=trigger.attr("data-add-sections"),newSections=trigger.attr("data-new-sections");str.get_string("numberweeks").then((function(strNumberSections){var modalBody=$('
');return modalBody.find("label").html(strNumberSections),modalBody.html()})).then((body=>ModalSaveCancel.create({body:body,title:modalTitle}))).then((function(modal){var numSections=$(modal.getBody()).find("#add_section_numsections"),addSections=function(){""+parseInt(numSections.val())===numSections.val()&&parseInt(numSections.val())>=1&&(document.location=trigger.attr("href")+"&numsections="+parseInt(numSections.val()))};return modal.setSaveButtonText(modalTitle),modal.getRoot().on(ModalEvents.shown,(function(){numSections.focus().select().on("keydown",(function(e){e.keyCode===KeyCodes.enter&&addSections()}))})),modal.getRoot().on(ModalEvents.save,(function(e){e.preventDefault(),addSections()})),trigger.on("click",(e=>{e.preventDefault(),modal.show()})),modal})).catch(notification.exception)},replaceSectionActionItem:function(sectionelement,selector,image,stringname,stringcomponent,newaction){log.debug("replaceSectionActionItem() is deprecated and will be removed.");var actionitem=sectionelement.find(SELECTOR.SECTIONACTIONMENU+" "+selector);replaceActionItem(actionitem,image,stringname,stringcomponent,newaction)},refreshModule:refreshModule,refreshSection:function(element,sectionid,sectionreturn){void 0===sectionreturn&&(sectionreturn=courseeditor.sectionReturn);const sectionElement=$(element),promises=ajax.call([{methodname:"core_course_edit_section",args:{id:sectionid,action:"refresh",sectionreturn:sectionreturn}}],!0);var spinner=addSectionSpinner(sectionElement);return new Promise(((resolve,reject)=>{$.when.apply($,promises).done((dataencoded=>{removeSpinner(sectionElement,spinner);const data=$.parseJSON(dataencoded),newSectionElement=$(data.content);sectionElement.replaceWith(newSectionElement),$("".concat(SELECTOR.SECTIONLI,"#").concat(sectionid," ").concat(SELECTOR.ACTIVITYLI)).each(((index,activity)=>{initActionMenu(activity.data("id"))}));dispatchEvent(CourseEvents.sectionRefreshed,{ajaxreturn:data,action:"refresh",newSectionElement:newSectionElement.get(0)},newSectionElement).defaultPrevented||defaultEditSectionHandler(newSectionElement,$(SELECTOR.SECTIONLI+"#"+sectionid),data,formatname,sectionid),resolve(data)})).fail((ex=>{dispatchEvent("coursesectionrefreshfailed",{exception:ex,action:"refresh"},sectionElement).defaultPrevented||notification.exception(ex),reject()}))}))}}})); +define("core_course/actions",["jquery","core/ajax","core/templates","core/notification","core/str","core/url","core/yui","core/modal_copy_to_clipboard","core/modal_save_cancel","core/modal_events","core/key_codes","core/log","core_courseformat/courseeditor","core/event_dispatcher","core_course/events"],(function($,ajax,templates,notification,str,url,Y,ModalCopyToClipboard,ModalSaveCancel,ModalEvents,KeyCodes,log,editor,EventDispatcher,CourseEvents){const componentActions=["moveSection","moveCm","addSection","deleteSection","cmDelete","cmDuplicate","sectionHide","sectionShow","cmHide","cmShow","cmStealth","sectionHighlight","sectionUnhighlight","cmMoveRight","cmMoveLeft","cmNoGroups","cmVisibleGroups","cmSeparateGroups"],courseeditor=editor.getCurrentCourseEditor();let formatname;var CSS_EDITINPROGRESS="editinprogress",CSS_EDITINGMOVE="editing_move",SELECTOR={ACTIVITYLI:"li.activity",ACTIONAREA:".actions",ACTIVITYACTION:"a.cm-edit-action",MENU:".moodle-actionmenu[data-enhance=moodle-core-actionmenu]",TOGGLE:".toggle-display,.dropdown-toggle",SECTIONLI:"li.section",SECTIONACTIONMENU:".section_action_menu",SECTIONITEM:'[data-for="section_title"]',ADDSECTIONS:".changenumsections [data-add-sections]",SECTIONBADGES:'[data-region="sectionbadges"]'};Y.use("moodle-course-coursebase",(function(){var courseformatselector=M.course.format.get_section_selector();courseformatselector&&(SELECTOR.SECTIONLI=courseformatselector)}));const dispatchEvent=function(eventName,detail,container,options){return container instanceof Element||void 0===container.get||(container=container.get(0)),EventDispatcher.dispatchEvent(eventName,detail,container,options)};var getModuleId=function(element){const item=element.get(0);if(item.dataset.id)return item.dataset.id;let id;return Y.use("moodle-course-util",(function(Y){id=Y.Moodle.core_course.util.cm.getId(Y.Node(item))})),id},addActivitySpinner=function(activity){activity.addClass(CSS_EDITINPROGRESS);var actionarea=activity.find(SELECTOR.ACTIONAREA).get(0);if(actionarea){var spinner=M.util.add_spinner(Y,Y.Node(actionarea));return spinner.show(),void 0!==activity.data("id")&&courseeditor.dispatch("cmLock",[activity.data("id")],!0),spinner}return null},addSectionSpinner=function(sectionelement){sectionelement.addClass(CSS_EDITINPROGRESS);var actionarea=sectionelement.find(SELECTOR.SECTIONACTIONMENU).get(0);if(actionarea){var spinner=M.util.add_spinner(Y,Y.Node(actionarea));return spinner.show(),void 0!==sectionelement.data("id")&&courseeditor.dispatch("sectionLock",[sectionelement.data("id")],!0),spinner}return null},addSectionLightbox=function(sectionelement){const item=sectionelement.get(0);var lightbox=M.util.add_lightbox(Y,Y.Node(item));return"section"==item.dataset.for&&item.dataset.id&&(courseeditor.dispatch("sectionLock",[item.dataset.id],!0),lightbox.setAttribute("data-state","section"),lightbox.setAttribute("data-state-id",item.dataset.id)),lightbox.show(),lightbox},removeSpinner=function(element,spinner,delay){window.setTimeout((function(){if(element.removeClass(CSS_EDITINPROGRESS),spinner&&spinner.hide(),void 0!==element.data("id")){const mutation="section"===element.data("for")?"sectionLock":"cmLock";courseeditor.dispatch(mutation,[element.data("id")],!1)}}),delay)},removeLightbox=function(lightbox,delay){lightbox&&window.setTimeout((function(){lightbox.hide(),lightbox.getAttribute("data-state")&&courseeditor.dispatch("".concat(lightbox.getAttribute("data-state"),"Lock"),[lightbox.getAttribute("data-state-id")],!1)}),delay)},initActionMenu=function(elementid){Y.use("moodle-course-coursebase",(function(){M.course.coursebase.invoke_function("setup_for_resource","#"+elementid)})),M.core.actionmenu&&M.core.actionmenu.newDOMNode&&M.core.actionmenu.newDOMNode(Y.one("#"+elementid))},editModule=function(moduleElement,cmid,target){var lightbox,action=target.attr("data-action"),spinner=addActivitySpinner(moduleElement),promises=ajax.call([{methodname:"core_course_edit_module",args:{id:cmid,action:action,sectionreturn:target.attr("data-sectionreturn")?target.attr("data-sectionreturn"):null}}],!0);"duplicate"===action&&(lightbox=addSectionLightbox(target.closest(SELECTOR.SECTIONLI))),$.when.apply($,promises).done((function(data){var mainElement,tabables,isInside,foundElement,elementToFocus=(mainElement=moduleElement,tabables=$("a:visible"),isInside=!1,foundElement=null,tabables.each((function(){if($.contains(mainElement[0],this))isInside=!0;else if(isInside)return foundElement=this,!1;return!0})),foundElement);moduleElement.replaceWith(data);let affectedids=[];$("
"+data+"
").find(SELECTOR.ACTIVITYLI).each((function(index){initActionMenu($(this).attr("id")),0===index&&(!function(elementId,action){var mainelement=$("#"+elementId),selector="[data-action="+action+"]";"groupsseparate"!==action&&"groupsvisible"!==action&&"groupsnone"!==action||(selector="[data-action=groupsseparate],[data-action=groupsvisible],[data-action=groupsnone]"),mainelement.find(selector).is(":visible")?mainelement.find(selector).focus():mainelement.find(SELECTOR.MENU).find(SELECTOR.TOGGLE).focus()}($(this).attr("id"),action),elementToFocus=null),affectedids.push(getModuleId($(this)))})),elementToFocus&&elementToFocus.focus(),removeSpinner(moduleElement,spinner,400),removeLightbox(lightbox,400),moduleElement.trigger($.Event("coursemoduleedited",{ajaxreturn:data,action:action})),courseeditor.dispatch("legacyActivityAction",action,cmid,affectedids)})).fail((function(ex){removeSpinner(moduleElement,spinner),removeLightbox(lightbox);var e=$.Event("coursemoduleeditfailed",{exception:ex,action:action});moduleElement.trigger(e),e.isDefaultPrevented()||notification.exception(ex)}))},refreshModule=function(element,cmid,sectionreturn){void 0===sectionreturn&&(sectionreturn=courseeditor.sectionReturn);const activityElement=$(element);var spinner=addActivitySpinner(activityElement),promises=ajax.call([{methodname:"core_course_get_module",args:{id:cmid,sectionreturn:sectionreturn}}],!0);return new Promise(((resolve,reject)=>{$.when.apply($,promises).done((function(data){removeSpinner(activityElement,spinner,400),replaceActivityHtmlWith(data),resolve(data)})).fail((function(){removeSpinner(activityElement,spinner),reject()}))}))},confirmDeleteModule=function(mainelement,onconfirm){var modtypename=mainelement.attr("class").match(/modtype_([^\s]*)/)[1],modulename=function(element){var name;Y.use("moodle-course-util",(function(Y){name=Y.Moodle.core_course.util.cm.getName(Y.Node(element.get(0)))}));const state=courseeditor.state,cmid=getModuleId(element);var _state$cm$get;return!name&&state&&cmid&&(name=null===(_state$cm$get=state.cm.get(cmid))||void 0===_state$cm$get?void 0:_state$cm$get.name),name}(mainelement);str.get_string("pluginname",modtypename).done((function(pluginname){var plugindata={type:pluginname,name:modulename};str.get_strings([{key:"confirm",component:"core"},{key:null===modulename?"deletechecktype":"deletechecktypename",param:plugindata},{key:"yes"},{key:"no"}]).done((function(s){notification.confirm(s[0],s[1],s[2],s[3],onconfirm)}))}))},replaceActionItem=function(actionitem,image,stringname,stringcomponent,newaction){var stringRequests=[{key:stringname,component:stringcomponent}];return str.get_strings(stringRequests).then((function(strings){return actionitem.find("span.menu-action-text").html(strings[0]),templates.renderPix(image,"core")})).then((function(pixhtml){actionitem.find(".icon").replaceWith(pixhtml),actionitem.attr("data-action",newaction)})).catch(notification.exception)},defaultEditSectionHandler=function(sectionElement,actionItem,data,courseformat,sectionid){var action=actionItem.attr("data-action");if("hide"===action||"show"===action){if("hide"===action?(sectionElement.addClass("hidden"),setSectionBadge(sectionElement[0],"hiddenfromstudents",!0,!1),replaceActionItem(actionItem,"i/show","showfromothers","format_"+courseformat,"show")):(setSectionBadge(sectionElement[0],"hiddenfromstudents",!1,!1),sectionElement.removeClass("hidden"),replaceActionItem(actionItem,"i/hide","hidefromothers","format_"+courseformat,"hide")),void 0!==data.modules)for(var i in data.modules)replaceActivityHtmlWith(data.modules[i]);void 0!==data.section_availability&§ionElement.find(".section_availability").first().replaceWith(data.section_availability);void 0!==courseeditor.state.section.get(sectionid)&&courseeditor.dispatch("sectionState",[sectionid])}else if("setmarker"===action){var oldmarker=$(SELECTOR.SECTIONLI+".current"),oldActionItem=oldmarker.find(SELECTOR.SECTIONACTIONMENU+" a[data-action=removemarker]");oldmarker.removeClass("current"),replaceActionItem(oldActionItem,"i/marker","highlight","core","setmarker"),sectionElement.addClass("current"),replaceActionItem(actionItem,"i/marked","highlightoff","core","removemarker"),courseeditor.dispatch("legacySectionAction",action,sectionid),setSectionBadge(sectionElement[0],"iscurrent",!0,!0)}else"removemarker"===action&&(sectionElement.removeClass("current"),replaceActionItem(actionItem,"i/marker","highlight","core","setmarker"),courseeditor.dispatch("legacySectionAction",action,sectionid),setSectionBadge(sectionElement[0],"iscurrent",!1,!0))};var replaceActivityHtmlWith=function(activityHTML){$("
"+activityHTML+"
").find(SELECTOR.ACTIVITYLI).each((function(){var id=$(this).attr("id");let focusedPath=function(id){const element=document.getElementById(id);if(element&&element.contains(document.activeElement))return element.querySelector(SELECTOR.ACTIONAREA).contains(document.activeElement)?"".concat(SELECTOR.ACTIONAREA,' [tabindex="0"]'):document.activeElement.id?"#".concat(document.activeElement.id):void 0}(id);if($(SELECTOR.ACTIVITYLI+"#"+id).replaceWith(activityHTML),initActionMenu(id),focusedPath){var _newItem$querySelecto;null===(_newItem$querySelecto=document.getElementById(id).querySelector(focusedPath))||void 0===_newItem$querySelecto||_newItem$querySelecto.focus()}}))},editSection=function(sectionElement,sectionid,target,courseformat){var action=target.attr("data-action"),sectionreturn=target.attr("data-sectionreturn")?target.attr("data-sectionreturn"):null;if(courseeditor.supportComponents&&componentActions.includes(action))return!1;var spinner=addSectionSpinner(sectionElement),promises=ajax.call([{methodname:"core_course_edit_section",args:{id:sectionid,action:action,sectionreturn:sectionreturn}}],!0),lightbox=addSectionLightbox(sectionElement);return $.when.apply($,promises).done((function(dataencoded){var data=$.parseJSON(dataencoded);removeSpinner(sectionElement,spinner),removeLightbox(lightbox),sectionElement.find(SELECTOR.SECTIONACTIONMENU).find(SELECTOR.TOGGLE).focus();var e=$.Event("coursesectionedited",{ajaxreturn:data,action:action});sectionElement.trigger(e),e.isDefaultPrevented()||defaultEditSectionHandler(sectionElement,target,data,courseformat,sectionid)})).fail((function(ex){removeSpinner(sectionElement,spinner),removeLightbox(lightbox);var e=$.Event("coursesectioneditfailed",{exception:ex,action:action});sectionElement.trigger(e),e.isDefaultPrevented()||notification.exception(ex)})),!0},setSectionBadge=function(sectionElement,badgetype,add,removeOther){const sectionbadges=sectionElement.querySelector(SELECTOR.SECTIONBADGES);if(!sectionbadges)return;const badge=sectionbadges.querySelector('[data-type="'+badgetype+'"]');badge&&(add?(removeOther&&document.querySelectorAll('[data-type="'+badgetype+'"]').forEach((b=>{b.classList.add("d-none")})),badge.classList.remove("d-none")):badge.classList.add("d-none"))};return Y.use("moodle-course-coursebase",(function(){M.course.coursebase.register_module({set_visibility_resource_ui:function(args){var mainelement=$(args.element.getDOMNode()),cmid=getModuleId(mainelement);if(cmid){var sectionreturn=mainelement.find("."+CSS_EDITINGMOVE).attr("data-sectionreturn");refreshModule(mainelement,cmid,sectionreturn)}},updateMovedCmState:params=>{const cm=courseeditor.state.cm.get(params.cmid);void 0!==cm&&courseeditor.dispatch("sectionState",[cm.sectionid]),courseeditor.dispatch("cmState",[params.cmid])},updateMovedSectionState:()=>{courseeditor.dispatch("courseState")}})})),courseeditor.addMutations({legacyActivityAction:function(statemanager,action,cmid,affectedids){const state=statemanager.state,cm=state.cm.get(cmid);if(void 0===cm)return;const section=state.section.get(cm.sectionid);if(void 0!==section){switch(courseeditor.dispatch("cmLock",[cm.id],!0),statemanager.setReadOnly(!1),cm.locked=!1,action){case"delete":section.cmlist=section.cmlist.reduce(((cmlist,current)=>(current!=cmid&&cmlist.push(current),cmlist)),[]),state.cm.delete(cmid);break;case"hide":case"show":case"duplicate":courseeditor.dispatch("cmState",affectedids)}statemanager.setReadOnly(!0)}},legacySectionAction:function(statemanager,action,sectionid){const state=statemanager.state,section=state.section.get(sectionid);if(void 0!==section){switch(statemanager.setReadOnly(!1),section.locked=!0,statemanager.setReadOnly(!0),statemanager.setReadOnly(!1),section.locked=!1,action){case"setmarker":state.section.forEach((current=>{current.id!=sectionid&&(current.current=!1)})),section.current=!0;break;case"removemarker":section.current=!1}statemanager.setReadOnly(!0)}}}),{initCoursePage:function(courseformat){if(formatname=courseformat,$("body").on("click keypress",SELECTOR.ACTIVITYLI+" "+SELECTOR.ACTIVITYACTION+"[data-action]",(function(e){if("keypress"!==e.type||13===e.keyCode){var actionItem=$(this),moduleElement=actionItem.closest(SELECTOR.ACTIVITYLI),action=actionItem.attr("data-action"),moduleId=getModuleId(moduleElement);switch(action){case"moveleft":case"moveright":case"delete":case"duplicate":case"hide":case"stealth":case"show":case"groupsseparate":case"groupsvisible":case"groupsnone":break;default:return}moduleId&&(e.preventDefault(),"delete"===action?confirmDeleteModule(moduleElement,(function(){editModule(moduleElement,moduleId,actionItem)})):editModule(moduleElement,moduleId,actionItem))}})),$("body").on("click keypress",SELECTOR.SECTIONLI+" "+SELECTOR.SECTIONACTIONMENU+"[data-sectionid] a[data-action]",(function(e){if("keypress"===e.type&&13!==e.keyCode)return;var actionItem=$(this),sectionElement=actionItem.closest(SELECTOR.SECTIONLI),sectionId=actionItem.closest(SELECTOR.SECTIONACTIONMENU).attr("data-sectionid");if("permalink"===actionItem.attr("data-action"))return e.preventDefault(),void ModalCopyToClipboard.create({text:actionItem.attr("href")},str.get_string("sectionlink","course"));let isExecuted=!0;var message,onconfirm;actionItem.attr("data-confirm")?(message=actionItem.attr("data-confirm"),onconfirm=function(){isExecuted=editSection(sectionElement,sectionId,actionItem,courseformat)},str.get_strings([{key:"confirm"},{key:"yes"},{key:"no"}]).done((function(s){notification.confirm(s[0],message,s[1],s[2],onconfirm)}))):isExecuted=editSection(sectionElement,sectionId,actionItem,courseformat),isExecuted&&e.preventDefault()})),$("body").on("updated","".concat(SELECTOR.SECTIONITEM," [data-inplaceeditable]"),(function(e){if(e.ajaxreturn&&e.ajaxreturn.itemid){void 0!==courseeditor.state.section.get(e.ajaxreturn.itemid)&&courseeditor.dispatch("sectionState",[e.ajaxreturn.itemid])}})),$("body").on("updated","".concat(SELECTOR.ACTIVITYLI," [data-inplaceeditable]"),(function(e){e.ajaxreturn&&e.ajaxreturn.itemid&&courseeditor.dispatch("cmState",[e.ajaxreturn.itemid])})),courseeditor.supportComponents&&componentActions.includes("addSection"))return;const trigger=$(SELECTOR.ADDSECTIONS),modalTitle=trigger.attr("data-add-sections"),newSections=trigger.attr("data-new-sections");str.get_string("numberweeks").then((function(strNumberSections){var modalBody=$('
');return modalBody.find("label").html(strNumberSections),modalBody.html()})).then((body=>ModalSaveCancel.create({body:body,title:modalTitle}))).then((function(modal){var numSections=$(modal.getBody()).find("#add_section_numsections"),addSections=function(){""+parseInt(numSections.val())===numSections.val()&&parseInt(numSections.val())>=1&&(document.location=trigger.attr("href")+"&numsections="+parseInt(numSections.val()))};return modal.setSaveButtonText(modalTitle),modal.getRoot().on(ModalEvents.shown,(function(){numSections.focus().select().on("keydown",(function(e){e.keyCode===KeyCodes.enter&&addSections()}))})),modal.getRoot().on(ModalEvents.save,(function(e){e.preventDefault(),addSections()})),trigger.on("click",(e=>{e.preventDefault(),modal.show()})),modal})).catch(notification.exception)},replaceSectionActionItem:function(sectionelement,selector,image,stringname,stringcomponent,newaction){log.debug("replaceSectionActionItem() is deprecated and will be removed.");var actionitem=sectionelement.find(SELECTOR.SECTIONACTIONMENU+" "+selector);replaceActionItem(actionitem,image,stringname,stringcomponent,newaction)},refreshModule:refreshModule,refreshSection:function(element,sectionid,sectionreturn){void 0===sectionreturn&&(sectionreturn=courseeditor.sectionReturn);const sectionElement=$(element),promises=ajax.call([{methodname:"core_course_edit_section",args:{id:sectionid,action:"refresh",sectionreturn:sectionreturn}}],!0);var spinner=addSectionSpinner(sectionElement);return new Promise(((resolve,reject)=>{$.when.apply($,promises).done((dataencoded=>{removeSpinner(sectionElement,spinner);const data=$.parseJSON(dataencoded),newSectionElement=$(data.content);sectionElement.replaceWith(newSectionElement),$("".concat(SELECTOR.SECTIONLI,"#").concat(sectionid," ").concat(SELECTOR.ACTIVITYLI)).each(((index,activity)=>{initActionMenu(activity.data("id"))}));dispatchEvent(CourseEvents.sectionRefreshed,{ajaxreturn:data,action:"refresh",newSectionElement:newSectionElement.get(0)},newSectionElement).defaultPrevented||defaultEditSectionHandler(newSectionElement,$(SELECTOR.SECTIONLI+"#"+sectionid),data,formatname,sectionid),resolve(data)})).fail((ex=>{dispatchEvent("coursesectionrefreshfailed",{exception:ex,action:"refresh"},sectionElement).defaultPrevented||notification.exception(ex),reject()}))}))}}})); //# sourceMappingURL=actions.min.js.map \ No newline at end of file diff --git a/course/amd/build/actions.min.js.map b/course/amd/build/actions.min.js.map index a68b4bae2de80..94639c1ef9278 100644 --- a/course/amd/build/actions.min.js.map +++ b/course/amd/build/actions.min.js.map @@ -1 +1 @@ -{"version":3,"file":"actions.min.js","sources":["../src/actions.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Various actions on modules and sections in the editing mode - hiding, duplicating, deleting, etc.\n *\n * @module core_course/actions\n * @copyright 2016 Marina Glancy\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 3.3\n */\ndefine(\n [\n 'jquery',\n 'core/ajax',\n 'core/templates',\n 'core/notification',\n 'core/str',\n 'core/url',\n 'core/yui',\n 'core/modal_copy_to_clipboard',\n 'core/modal_save_cancel',\n 'core/modal_events',\n 'core/key_codes',\n 'core/log',\n 'core_courseformat/courseeditor',\n 'core/event_dispatcher',\n 'core_course/events'\n ],\n function(\n $,\n ajax,\n templates,\n notification,\n str,\n url,\n Y,\n ModalCopyToClipboard,\n ModalSaveCancel,\n ModalEvents,\n KeyCodes,\n log,\n editor,\n EventDispatcher,\n CourseEvents\n ) {\n\n // Eventually, core_courseformat/local/content/actions will handle all actions for\n // component compatible formats and the default actions.js won't be necessary anymore.\n // Meanwhile, we filter the migrated actions.\n const componentActions = [\n 'moveSection', 'moveCm', 'addSection', 'deleteSection', 'cmDelete', 'cmDuplicate', 'sectionHide', 'sectionShow',\n 'cmHide', 'cmShow', 'cmStealth', 'sectionHighlight', 'sectionUnhighlight', 'cmMoveRight', 'cmMoveLeft',\n 'cmNoGroups', 'cmVisibleGroups', 'cmSeparateGroups',\n ];\n\n // The course reactive instance.\n const courseeditor = editor.getCurrentCourseEditor();\n\n // The current course format name (loaded on init).\n let formatname;\n\n var CSS = {\n EDITINPROGRESS: 'editinprogress',\n SECTIONDRAGGABLE: 'sectiondraggable',\n EDITINGMOVE: 'editing_move'\n };\n var SELECTOR = {\n ACTIVITYLI: 'li.activity',\n ACTIONAREA: '.actions',\n ACTIVITYACTION: 'a.cm-edit-action',\n MENU: '.moodle-actionmenu[data-enhance=moodle-core-actionmenu]',\n TOGGLE: '.toggle-display,.dropdown-toggle',\n SECTIONLI: 'li.section',\n SECTIONACTIONMENU: '.section_action_menu',\n SECTIONITEM: '[data-for=\"section_title\"]',\n ADDSECTIONS: '.changenumsections [data-add-sections]',\n SECTIONBADGES: '[data-region=\"sectionbadges\"]',\n };\n\n Y.use('moodle-course-coursebase', function() {\n var courseformatselector = M.course.format.get_section_selector();\n if (courseformatselector) {\n SELECTOR.SECTIONLI = courseformatselector;\n }\n });\n\n /**\n * Dispatch event wrapper.\n *\n * Old jQuery events will be replaced by native events gradually.\n *\n * @method dispatchEvent\n * @param {String} eventName The name of the event\n * @param {Object} detail Any additional details to pass into the eveent\n * @param {Node|HTMLElement} container The point at which to dispatch the event\n * @param {Object} options\n * @param {Boolean} options.bubbles Whether to bubble up the DOM\n * @param {Boolean} options.cancelable Whether preventDefault() can be called\n * @param {Boolean} options.composed Whether the event can bubble across the ShadowDOM boundary\n * @returns {CustomEvent}\n */\n const dispatchEvent = function(eventName, detail, container, options) {\n // Most actions still uses jQuery node instead of regular HTMLElement.\n if (!(container instanceof Element) && container.get !== undefined) {\n container = container.get(0);\n }\n return EventDispatcher.dispatchEvent(eventName, detail, container, options);\n };\n\n /**\n * Wrapper for Y.Moodle.core_course.util.cm.getId\n *\n * @param {JQuery} element\n * @returns {Integer}\n */\n var getModuleId = function(element) {\n // Check if we have a data-id first.\n const item = element.get(0);\n if (item.dataset.id) {\n return item.dataset.id;\n }\n // Use YUI way if data-id is not present.\n let id;\n Y.use('moodle-course-util', function(Y) {\n id = Y.Moodle.core_course.util.cm.getId(Y.Node(item));\n });\n return id;\n };\n\n /**\n * Wrapper for Y.Moodle.core_course.util.cm.getName\n *\n * @param {JQuery} element\n * @returns {String}\n */\n var getModuleName = function(element) {\n var name;\n Y.use('moodle-course-util', function(Y) {\n name = Y.Moodle.core_course.util.cm.getName(Y.Node(element.get(0)));\n });\n // Check if we have the name in the course state.\n const state = courseeditor.state;\n const cmid = getModuleId(element);\n if (!name && state && cmid) {\n name = state.cm.get(cmid)?.name;\n }\n return name;\n };\n\n /**\n * Wrapper for M.util.add_spinner for an activity\n *\n * @param {JQuery} activity\n * @returns {Node}\n */\n var addActivitySpinner = function(activity) {\n activity.addClass(CSS.EDITINPROGRESS);\n var actionarea = activity.find(SELECTOR.ACTIONAREA).get(0);\n if (actionarea) {\n var spinner = M.util.add_spinner(Y, Y.Node(actionarea));\n spinner.show();\n // Lock the activity state element.\n if (activity.data('id') !== undefined) {\n courseeditor.dispatch('cmLock', [activity.data('id')], true);\n }\n return spinner;\n }\n return null;\n };\n\n /**\n * Wrapper for M.util.add_spinner for a section\n *\n * @param {JQuery} sectionelement\n * @returns {Node}\n */\n var addSectionSpinner = function(sectionelement) {\n sectionelement.addClass(CSS.EDITINPROGRESS);\n var actionarea = sectionelement.find(SELECTOR.SECTIONACTIONMENU).get(0);\n if (actionarea) {\n var spinner = M.util.add_spinner(Y, Y.Node(actionarea));\n spinner.show();\n // Lock the section state element.\n if (sectionelement.data('id') !== undefined) {\n courseeditor.dispatch('sectionLock', [sectionelement.data('id')], true);\n }\n return spinner;\n }\n return null;\n };\n\n /**\n * Wrapper for M.util.add_lightbox\n *\n * @param {JQuery} sectionelement\n * @returns {Node}\n */\n var addSectionLightbox = function(sectionelement) {\n const item = sectionelement.get(0);\n var lightbox = M.util.add_lightbox(Y, Y.Node(item));\n if (item.dataset.for == 'section' && item.dataset.id) {\n courseeditor.dispatch('sectionLock', [item.dataset.id], true);\n lightbox.setAttribute('data-state', 'section');\n lightbox.setAttribute('data-state-id', item.dataset.id);\n }\n lightbox.show();\n return lightbox;\n };\n\n /**\n * Removes the spinner element\n *\n * @param {JQuery} element\n * @param {Node} spinner\n * @param {Number} delay\n */\n var removeSpinner = function(element, spinner, delay) {\n window.setTimeout(function() {\n element.removeClass(CSS.EDITINPROGRESS);\n if (spinner) {\n spinner.hide();\n }\n // Unlock the state element.\n if (element.data('id') !== undefined) {\n const mutation = (element.data('for') === 'section') ? 'sectionLock' : 'cmLock';\n courseeditor.dispatch(mutation, [element.data('id')], false);\n }\n }, delay);\n };\n\n /**\n * Removes the lightbox element\n *\n * @param {Node} lightbox lighbox YUI element returned by addSectionLightbox\n * @param {Number} delay\n */\n var removeLightbox = function(lightbox, delay) {\n if (lightbox) {\n window.setTimeout(function() {\n lightbox.hide();\n // Unlock state if necessary.\n if (lightbox.getAttribute('data-state')) {\n courseeditor.dispatch(\n `${lightbox.getAttribute('data-state')}Lock`,\n [lightbox.getAttribute('data-state-id')],\n false\n );\n }\n }, delay);\n }\n };\n\n /**\n * Initialise action menu for the element (section or module)\n *\n * @param {String} elementid CSS id attribute of the element\n */\n var initActionMenu = function(elementid) {\n // Initialise action menu in the new activity.\n Y.use('moodle-course-coursebase', function() {\n M.course.coursebase.invoke_function('setup_for_resource', '#' + elementid);\n });\n if (M.core.actionmenu && M.core.actionmenu.newDOMNode) {\n M.core.actionmenu.newDOMNode(Y.one('#' + elementid));\n }\n };\n\n /**\n * Returns focus to the element that was clicked or \"Edit\" link if element is no longer visible.\n *\n * @param {String} elementId CSS id attribute of the element\n * @param {String} action data-action property of the element that was clicked\n */\n var focusActionItem = function(elementId, action) {\n var mainelement = $('#' + elementId);\n var selector = '[data-action=' + action + ']';\n if (action === 'groupsseparate' || action === 'groupsvisible' || action === 'groupsnone') {\n // New element will have different data-action.\n selector = '[data-action=groupsseparate],[data-action=groupsvisible],[data-action=groupsnone]';\n }\n if (mainelement.find(selector).is(':visible')) {\n mainelement.find(selector).focus();\n } else {\n // Element not visible, focus the \"Edit\" link.\n mainelement.find(SELECTOR.MENU).find(SELECTOR.TOGGLE).focus();\n }\n };\n\n /**\n * Find next after the element\n *\n * @param {JQuery} mainElement element that is about to be deleted\n * @returns {JQuery}\n */\n var findNextFocusable = function(mainElement) {\n var tabables = $(\"a:visible\");\n var isInside = false;\n var foundElement = null;\n tabables.each(function() {\n if ($.contains(mainElement[0], this)) {\n isInside = true;\n } else if (isInside) {\n foundElement = this;\n return false; // Returning false in .each() is equivalent to \"break;\" inside the loop in php.\n }\n return true;\n });\n return foundElement;\n };\n\n /**\n * Performs an action on a module (moving, deleting, duplicating, hiding, etc.)\n *\n * @param {JQuery} moduleElement activity element we perform action on\n * @param {Number} cmid\n * @param {JQuery} target the element (menu item) that was clicked\n */\n var editModule = function(moduleElement, cmid, target) {\n var action = target.attr('data-action');\n var spinner = addActivitySpinner(moduleElement);\n var promises = ajax.call([{\n methodname: 'core_course_edit_module',\n args: {id: cmid,\n action: action,\n sectionreturn: target.attr('data-sectionreturn') ? target.attr('data-sectionreturn') : null\n }\n }], true);\n\n var lightbox;\n if (action === 'duplicate') {\n lightbox = addSectionLightbox(target.closest(SELECTOR.SECTIONLI));\n }\n $.when.apply($, promises)\n .done(function(data) {\n var elementToFocus = findNextFocusable(moduleElement);\n moduleElement.replaceWith(data);\n let affectedids = [];\n // Initialise action menu for activity(ies) added as a result of this.\n $('
' + data + '
').find(SELECTOR.ACTIVITYLI).each(function(index) {\n initActionMenu($(this).attr('id'));\n if (index === 0) {\n focusActionItem($(this).attr('id'), action);\n elementToFocus = null;\n }\n // Save any activity id in cmids.\n affectedids.push(getModuleId($(this)));\n });\n // In case of activity deletion focus the next focusable element.\n if (elementToFocus) {\n elementToFocus.focus();\n }\n // Remove spinner and lightbox with a delay.\n removeSpinner(moduleElement, spinner, 400);\n removeLightbox(lightbox, 400);\n // Trigger event that can be observed by course formats.\n moduleElement.trigger($.Event('coursemoduleedited', {ajaxreturn: data, action: action}));\n\n // Modify cm state.\n courseeditor.dispatch('legacyActivityAction', action, cmid, affectedids);\n\n }).fail(function(ex) {\n // Remove spinner and lightbox.\n removeSpinner(moduleElement, spinner);\n removeLightbox(lightbox);\n // Trigger event that can be observed by course formats.\n var e = $.Event('coursemoduleeditfailed', {exception: ex, action: action});\n moduleElement.trigger(e);\n if (!e.isDefaultPrevented()) {\n notification.exception(ex);\n }\n });\n };\n\n /**\n * Requests html for the module via WS core_course_get_module and updates the module on the course page\n *\n * Used after d&d of the module to another section\n *\n * @param {JQuery|Element} element\n * @param {Number} cmid\n * @param {Number} sectionreturn\n * @return {Promise} the refresh promise\n */\n var refreshModule = function(element, cmid, sectionreturn) {\n\n if (sectionreturn === undefined) {\n sectionreturn = courseeditor.sectionReturn;\n }\n\n const activityElement = $(element);\n var spinner = addActivitySpinner(activityElement);\n var promises = ajax.call([{\n methodname: 'core_course_get_module',\n args: {id: cmid, sectionreturn: sectionreturn}\n }], true);\n\n return new Promise((resolve, reject) => {\n $.when.apply($, promises)\n .done(function(data) {\n removeSpinner(activityElement, spinner, 400);\n replaceActivityHtmlWith(data);\n resolve(data);\n }).fail(function() {\n removeSpinner(activityElement, spinner);\n reject();\n });\n });\n };\n\n /**\n * Requests html for the section via WS core_course_edit_section and updates the section on the course page\n *\n * @param {JQuery|Element} element\n * @param {Number} sectionid\n * @param {Number} sectionreturn\n * @return {Promise} the refresh promise\n */\n var refreshSection = function(element, sectionid, sectionreturn) {\n\n if (sectionreturn === undefined) {\n sectionreturn = courseeditor.sectionReturn;\n }\n\n const sectionElement = $(element);\n const action = 'refresh';\n const promises = ajax.call([{\n methodname: 'core_course_edit_section',\n args: {id: sectionid, action, sectionreturn},\n }], true);\n\n var spinner = addSectionSpinner(sectionElement);\n return new Promise((resolve, reject) => {\n $.when.apply($, promises)\n .done(dataencoded => {\n\n removeSpinner(sectionElement, spinner);\n const data = $.parseJSON(dataencoded);\n\n const newSectionElement = $(data.content);\n sectionElement.replaceWith(newSectionElement);\n\n // Init modules menus.\n $(`${SELECTOR.SECTIONLI}#${sectionid} ${SELECTOR.ACTIVITYLI}`).each(\n (index, activity) => {\n initActionMenu(activity.data('id'));\n }\n );\n\n // Trigger event that can be observed by course formats.\n const event = dispatchEvent(\n CourseEvents.sectionRefreshed,\n {\n ajaxreturn: data,\n action: action,\n newSectionElement: newSectionElement.get(0),\n },\n newSectionElement\n );\n\n if (!event.defaultPrevented) {\n defaultEditSectionHandler(\n newSectionElement, $(SELECTOR.SECTIONLI + '#' + sectionid),\n data,\n formatname,\n sectionid\n );\n }\n resolve(data);\n }).fail(ex => {\n // Trigger event that can be observed by course formats.\n const event = dispatchEvent(\n 'coursesectionrefreshfailed',\n {exception: ex, action: action},\n sectionElement\n );\n if (!event.defaultPrevented) {\n notification.exception(ex);\n }\n reject();\n });\n });\n };\n\n /**\n * Displays the delete confirmation to delete a module\n *\n * @param {JQuery} mainelement activity element we perform action on\n * @param {function} onconfirm function to execute on confirm\n */\n var confirmDeleteModule = function(mainelement, onconfirm) {\n var modtypename = mainelement.attr('class').match(/modtype_([^\\s]*)/)[1];\n var modulename = getModuleName(mainelement);\n\n str.get_string('pluginname', modtypename).done(function(pluginname) {\n var plugindata = {\n type: pluginname,\n name: modulename\n };\n str.get_strings([\n {key: 'confirm', component: 'core'},\n {key: modulename === null ? 'deletechecktype' : 'deletechecktypename', param: plugindata},\n {key: 'yes'},\n {key: 'no'}\n ]).done(function(s) {\n notification.confirm(s[0], s[1], s[2], s[3], onconfirm);\n }\n );\n });\n };\n\n /**\n * Displays the delete confirmation to delete a section\n *\n * @param {String} message confirmation message\n * @param {function} onconfirm function to execute on confirm\n */\n var confirmEditSection = function(message, onconfirm) {\n str.get_strings([\n {key: 'confirm'}, // TODO link text\n {key: 'yes'},\n {key: 'no'}\n ]).done(function(s) {\n notification.confirm(s[0], message, s[1], s[2], onconfirm);\n }\n );\n };\n\n /**\n * Replaces an action menu item with another one (for example Show->Hide, Set marker->Remove marker)\n *\n * @param {JQuery} actionitem\n * @param {String} image new image name (\"i/show\", \"i/hide\", etc.)\n * @param {String} stringname new string for the action menu item\n * @param {String} stringcomponent\n * @param {String} newaction new value for data-action attribute of the link\n * @return {Promise} promise which is resolved when the replacement has completed\n */\n var replaceActionItem = function(actionitem, image, stringname,\n stringcomponent, newaction) {\n\n var stringRequests = [{key: stringname, component: stringcomponent}];\n // Do not provide an icon with duplicate, different text to the menu item.\n\n return str.get_strings(stringRequests).then(function(strings) {\n actionitem.find('span.menu-action-text').html(strings[0]);\n\n return templates.renderPix(image, 'core');\n }).then(function(pixhtml) {\n actionitem.find('.icon').replaceWith(pixhtml);\n actionitem.attr('data-action', newaction);\n return;\n }).catch(notification.exception);\n };\n\n /**\n * Default post-processing for section AJAX edit actions.\n *\n * This can be overridden in course formats by listening to event coursesectionedited:\n *\n * $('body').on('coursesectionedited', 'li.section', function(e) {\n * var action = e.action,\n * sectionElement = $(e.target),\n * data = e.ajaxreturn;\n * // ... Do some processing here.\n * e.preventDefault(); // Prevent default handler.\n * });\n *\n * @param {JQuery} sectionElement\n * @param {JQuery} actionItem\n * @param {Object} data\n * @param {String} courseformat\n * @param {Number} sectionid\n */\n var defaultEditSectionHandler = function(sectionElement, actionItem, data, courseformat, sectionid) {\n var action = actionItem.attr('data-action');\n if (action === 'hide' || action === 'show') {\n if (action === 'hide') {\n sectionElement.addClass('hidden');\n setSectionBadge(sectionElement[0], 'hiddenfromstudents', true, false);\n replaceActionItem(actionItem, 'i/show',\n 'showfromothers', 'format_' + courseformat, 'show');\n } else {\n setSectionBadge(sectionElement[0], 'hiddenfromstudents', false, false);\n sectionElement.removeClass('hidden');\n replaceActionItem(actionItem, 'i/hide',\n 'hidefromothers', 'format_' + courseformat, 'hide');\n }\n // Replace the modules with new html (that indicates that they are now hidden or not hidden).\n if (data.modules !== undefined) {\n for (var i in data.modules) {\n replaceActivityHtmlWith(data.modules[i]);\n }\n }\n // Replace the section availability information.\n if (data.section_availability !== undefined) {\n sectionElement.find('.section_availability').first().replaceWith(data.section_availability);\n }\n // Modify course state.\n const section = courseeditor.state.section.get(sectionid);\n if (section !== undefined) {\n courseeditor.dispatch('sectionState', [sectionid]);\n }\n } else if (action === 'setmarker') {\n var oldmarker = $(SELECTOR.SECTIONLI + '.current'),\n oldActionItem = oldmarker.find(SELECTOR.SECTIONACTIONMENU + ' ' + 'a[data-action=removemarker]');\n oldmarker.removeClass('current');\n replaceActionItem(oldActionItem, 'i/marker',\n 'highlight', 'core', 'setmarker');\n sectionElement.addClass('current');\n replaceActionItem(actionItem, 'i/marked',\n 'highlightoff', 'core', 'removemarker');\n courseeditor.dispatch('legacySectionAction', action, sectionid);\n setSectionBadge(sectionElement[0], 'iscurrent', true, true);\n } else if (action === 'removemarker') {\n sectionElement.removeClass('current');\n replaceActionItem(actionItem, 'i/marker',\n 'highlight', 'core', 'setmarker');\n courseeditor.dispatch('legacySectionAction', action, sectionid);\n setSectionBadge(sectionElement[0], 'iscurrent', false, true);\n }\n };\n\n /**\n * Get the focused element path in an activity if any.\n *\n * This method is used to restore focus when the activity HTML is refreshed.\n * Only the main course editor elements can be refocused as they are always present\n * even if the activity content changes.\n *\n * @param {String} id the element id the activity element\n * @return {String|undefined} the inner path of the focused element or undefined\n */\n const getActivityFocusedElement = function(id) {\n const element = document.getElementById(id);\n if (!element || !element.contains(document.activeElement)) {\n return undefined;\n }\n // Check if the actions menu toggler is focused.\n if (element.querySelector(SELECTOR.ACTIONAREA).contains(document.activeElement)) {\n return `${SELECTOR.ACTIONAREA} [tabindex=\"0\"]`;\n }\n // Return the current element id if any.\n if (document.activeElement.id) {\n return `#${document.activeElement.id}`;\n }\n return undefined;\n };\n\n /**\n * Replaces the course module with the new html (used to update module after it was edited or its visibility was changed).\n *\n * @param {String} activityHTML\n */\n var replaceActivityHtmlWith = function(activityHTML) {\n $('
' + activityHTML + '
').find(SELECTOR.ACTIVITYLI).each(function() {\n // Extract id from the new activity html.\n var id = $(this).attr('id');\n // Check if the current focused element is inside the activity.\n let focusedPath = getActivityFocusedElement(id);\n // Find the existing element with the same id and replace its contents with new html.\n $(SELECTOR.ACTIVITYLI + '#' + id).replaceWith(activityHTML);\n // Initialise action menu.\n initActionMenu(id);\n // Re-focus the previous elements.\n if (focusedPath) {\n const newItem = document.getElementById(id);\n newItem.querySelector(focusedPath)?.focus();\n }\n\n });\n };\n\n /**\n * Performs an action on a module (moving, deleting, duplicating, hiding, etc.)\n *\n * @param {JQuery} sectionElement section element we perform action on\n * @param {Nunmber} sectionid\n * @param {JQuery} target the element (menu item) that was clicked\n * @param {String} courseformat\n * @return {boolean} true the action call is sent to the server or false if it is ignored.\n */\n var editSection = function(sectionElement, sectionid, target, courseformat) {\n var action = target.attr('data-action'),\n sectionreturn = target.attr('data-sectionreturn') ? target.attr('data-sectionreturn') : null;\n\n // Filter direct component handled actions.\n if (courseeditor.supportComponents && componentActions.includes(action)) {\n return false;\n }\n\n var spinner = addSectionSpinner(sectionElement);\n var promises = ajax.call([{\n methodname: 'core_course_edit_section',\n args: {id: sectionid, action: action, sectionreturn: sectionreturn}\n }], true);\n\n var lightbox = addSectionLightbox(sectionElement);\n $.when.apply($, promises)\n .done(function(dataencoded) {\n var data = $.parseJSON(dataencoded);\n removeSpinner(sectionElement, spinner);\n removeLightbox(lightbox);\n sectionElement.find(SELECTOR.SECTIONACTIONMENU).find(SELECTOR.TOGGLE).focus();\n // Trigger event that can be observed by course formats.\n var e = $.Event('coursesectionedited', {ajaxreturn: data, action: action});\n sectionElement.trigger(e);\n if (!e.isDefaultPrevented()) {\n defaultEditSectionHandler(sectionElement, target, data, courseformat, sectionid);\n }\n }).fail(function(ex) {\n // Remove spinner and lightbox.\n removeSpinner(sectionElement, spinner);\n removeLightbox(lightbox);\n // Trigger event that can be observed by course formats.\n var e = $.Event('coursesectioneditfailed', {exception: ex, action: action});\n sectionElement.trigger(e);\n if (!e.isDefaultPrevented()) {\n notification.exception(ex);\n }\n });\n return true;\n };\n\n /**\n * Sets the section badge in the section header.\n *\n * @param {JQuery} sectionElement section element we perform action on\n * @param {String} badgetype the type of badge this is for\n * @param {bool} add true to add, false to remove\n * @param {boolean} removeOther in case of adding a badge, whether to remove all other.\n */\n var setSectionBadge = function(sectionElement, badgetype, add, removeOther) {\n const sectionbadges = sectionElement.querySelector(SELECTOR.SECTIONBADGES);\n if (!sectionbadges) {\n return;\n }\n const badge = sectionbadges.querySelector('[data-type=\"' + badgetype + '\"]');\n if (!badge) {\n return;\n }\n if (add) {\n if (removeOther) {\n document.querySelectorAll('[data-type=\"' + badgetype + '\"]').forEach((b) => {\n b.classList.add('d-none');\n });\n }\n badge.classList.remove('d-none');\n } else {\n badge.classList.add('d-none');\n }\n };\n\n // Register a function to be executed after D&D of an activity.\n Y.use('moodle-course-coursebase', function() {\n M.course.coursebase.register_module({\n // Ignore camelcase eslint rule for the next line because it is an expected name of the callback.\n // eslint-disable-next-line camelcase\n set_visibility_resource_ui: function(args) {\n var mainelement = $(args.element.getDOMNode());\n var cmid = getModuleId(mainelement);\n if (cmid) {\n var sectionreturn = mainelement.find('.' + CSS.EDITINGMOVE).attr('data-sectionreturn');\n refreshModule(mainelement, cmid, sectionreturn);\n }\n },\n /**\n * Update the course state when some cm is moved via YUI.\n * @param {*} params\n */\n updateMovedCmState: (params) => {\n const state = courseeditor.state;\n\n // Update old section.\n const cm = state.cm.get(params.cmid);\n if (cm !== undefined) {\n courseeditor.dispatch('sectionState', [cm.sectionid]);\n }\n // Update cm state.\n courseeditor.dispatch('cmState', [params.cmid]);\n },\n /**\n * Update the course state when some section is moved via YUI.\n */\n updateMovedSectionState: () => {\n courseeditor.dispatch('courseState');\n },\n });\n });\n\n // From Moodle 4.0 all edit actions are being re-implemented as state mutation.\n // This means all method from this \"actions\" module will be deprecated when all the course\n // interface is migrated to reactive components.\n // Most legacy actions did not provide enough information to regenarate the course so they\n // use the mutations courseState, sectionState and cmState to get the updated state from\n // the server. However, some activity actions where we can prevent an extra webservice\n // call by implementing an adhoc mutation.\n courseeditor.addMutations({\n /**\n * Compatibility function to update Moodle 4.0 course state using legacy actions.\n *\n * This method only updates some actions which does not require to use cmState mutation\n * to get updated data form the server.\n *\n * @param {Object} statemanager the current state in read write mode\n * @param {String} action the performed action\n * @param {Number} cmid the affected course module id\n * @param {Array} affectedids all affected cm ids (for duplicate action)\n */\n legacyActivityAction: function(statemanager, action, cmid, affectedids) {\n\n const state = statemanager.state;\n const cm = state.cm.get(cmid);\n if (cm === undefined) {\n return;\n }\n const section = state.section.get(cm.sectionid);\n if (section === undefined) {\n return;\n }\n\n // Send the element is locked.\n courseeditor.dispatch('cmLock', [cm.id], true);\n\n // Now we do the real mutation.\n statemanager.setReadOnly(false);\n\n // This unlocked will take effect when the read only is restored.\n cm.locked = false;\n\n switch (action) {\n case 'delete':\n // Remove from section.\n section.cmlist = section.cmlist.reduce(\n (cmlist, current) => {\n if (current != cmid) {\n cmlist.push(current);\n }\n return cmlist;\n },\n []\n );\n // Delete form list.\n state.cm.delete(cmid);\n break;\n\n case 'hide':\n case 'show':\n case 'duplicate':\n courseeditor.dispatch('cmState', affectedids);\n break;\n }\n statemanager.setReadOnly(true);\n },\n legacySectionAction: function(statemanager, action, sectionid) {\n\n const state = statemanager.state;\n const section = state.section.get(sectionid);\n if (section === undefined) {\n return;\n }\n\n // Send the element is locked. Reactive events are only triggered when the state\n // read only mode is restored. We want to notify the interface the element is\n // locked so we need to do a quick lock operation before performing the rest\n // of the mutation.\n statemanager.setReadOnly(false);\n section.locked = true;\n statemanager.setReadOnly(true);\n\n // Now we do the real mutation.\n statemanager.setReadOnly(false);\n\n // This locked will take effect when the read only is restored.\n section.locked = false;\n\n switch (action) {\n case 'setmarker':\n // Remove previous marker.\n state.section.forEach((current) => {\n if (current.id != sectionid) {\n current.current = false;\n }\n });\n section.current = true;\n break;\n\n case 'removemarker':\n section.current = false;\n break;\n }\n statemanager.setReadOnly(true);\n },\n });\n\n return /** @alias module:core_course/actions */ {\n\n /**\n * Initialises course page\n *\n * @method init\n * @param {String} courseformat name of the current course format (for fetching strings)\n */\n initCoursePage: function(courseformat) {\n\n formatname = courseformat;\n\n // Add a handler for course module actions.\n $('body').on('click keypress', SELECTOR.ACTIVITYLI + ' ' +\n SELECTOR.ACTIVITYACTION + '[data-action]', function(e) {\n if (e.type === 'keypress' && e.keyCode !== 13) {\n return;\n }\n var actionItem = $(this),\n moduleElement = actionItem.closest(SELECTOR.ACTIVITYLI),\n action = actionItem.attr('data-action'),\n moduleId = getModuleId(moduleElement);\n switch (action) {\n case 'moveleft':\n case 'moveright':\n case 'delete':\n case 'duplicate':\n case 'hide':\n case 'stealth':\n case 'show':\n case 'groupsseparate':\n case 'groupsvisible':\n case 'groupsnone':\n break;\n default:\n // Nothing to do here!\n return;\n }\n if (!moduleId) {\n return;\n }\n e.preventDefault();\n if (action === 'delete') {\n // Deleting requires confirmation.\n confirmDeleteModule(moduleElement, function() {\n editModule(moduleElement, moduleId, actionItem);\n });\n } else {\n editModule(moduleElement, moduleId, actionItem);\n }\n });\n\n // Add a handler for section show/hide actions.\n $('body').on('click keypress', SELECTOR.SECTIONLI + ' ' +\n SELECTOR.SECTIONACTIONMENU + '[data-sectionid] ' +\n 'a[data-action]', function(e) {\n if (e.type === 'keypress' && e.keyCode !== 13) {\n return;\n }\n var actionItem = $(this),\n sectionElement = actionItem.closest(SELECTOR.SECTIONLI),\n sectionId = actionItem.closest(SELECTOR.SECTIONACTIONMENU).attr('data-sectionid');\n\n if (actionItem.attr('data-action') === 'permalink') {\n e.preventDefault();\n ModalCopyToClipboard.create({\n text: actionItem.attr('href'),\n }, str.get_string('sectionlink', 'course')\n );\n return;\n }\n\n let isExecuted = true;\n if (actionItem.attr('data-confirm')) {\n // Action requires confirmation.\n confirmEditSection(actionItem.attr('data-confirm'), function() {\n isExecuted = editSection(sectionElement, sectionId, actionItem, courseformat);\n });\n } else {\n isExecuted = editSection(sectionElement, sectionId, actionItem, courseformat);\n }\n // Prevent any other module from capturing the action if it is already in execution.\n if (isExecuted) {\n e.preventDefault();\n }\n });\n\n // The section and activity names are edited using inplace editable.\n // The \"update\" jQuery event must be captured in order to update the course state.\n $('body').on('updated', `${SELECTOR.SECTIONLI} ${SELECTOR.SECTIONITEM} [data-inplaceeditable]`, function(e) {\n if (e.ajaxreturn && e.ajaxreturn.itemid) {\n const state = courseeditor.state;\n const section = state.section.get(e.ajaxreturn.itemid);\n if (section !== undefined) {\n courseeditor.dispatch('sectionState', [e.ajaxreturn.itemid]);\n }\n }\n });\n $('body').on('updated', `${SELECTOR.ACTIVITYLI} [data-inplaceeditable]`, function(e) {\n if (e.ajaxreturn && e.ajaxreturn.itemid) {\n courseeditor.dispatch('cmState', [e.ajaxreturn.itemid]);\n }\n });\n\n // Component-based formats don't use modals to create sections.\n if (courseeditor.supportComponents && componentActions.includes('addSection')) {\n return;\n }\n\n // Add a handler for \"Add sections\" link to ask for a number of sections to add.\n const trigger = $(SELECTOR.ADDSECTIONS);\n const modalTitle = trigger.attr('data-add-sections');\n const newSections = trigger.attr('data-new-sections');\n str.get_string('numberweeks')\n .then(function(strNumberSections) {\n var modalBody = $('
' +\n '
');\n modalBody.find('label').html(strNumberSections);\n\n return modalBody.html();\n })\n .then((body) => ModalSaveCancel.create({\n body,\n title: modalTitle,\n }))\n .then(function(modal) {\n var numSections = $(modal.getBody()).find('#add_section_numsections'),\n addSections = function() {\n // Check if value of the \"Number of sections\" is a valid positive integer and redirect\n // to adding a section script.\n if ('' + parseInt(numSections.val()) === numSections.val() && parseInt(numSections.val()) >= 1) {\n document.location = trigger.attr('href') + '&numsections=' + parseInt(numSections.val());\n }\n };\n modal.setSaveButtonText(modalTitle);\n modal.getRoot().on(ModalEvents.shown, function() {\n // When modal is shown focus and select the input and add a listener to keypress of \"Enter\".\n numSections.focus().select().on('keydown', function(e) {\n if (e.keyCode === KeyCodes.enter) {\n addSections();\n }\n });\n });\n modal.getRoot().on(ModalEvents.save, function(e) {\n // When modal \"Add\" button is pressed.\n e.preventDefault();\n addSections();\n });\n\n trigger.on('click', (e) => {\n e.preventDefault();\n modal.show();\n });\n\n return modal;\n })\n .catch(notification.exception);\n },\n\n /**\n * Replaces a section action menu item with another one (for example Show->Hide, Set marker->Remove marker)\n *\n * This method can be used by course formats in their listener to the coursesectionedited event\n *\n * @deprecated since Moodle 3.9\n * @param {JQuery} sectionelement\n * @param {String} selector CSS selector inside the section element, for example \"a[data-action=show]\"\n * @param {String} image new image name (\"i/show\", \"i/hide\", etc.)\n * @param {String} stringname new string for the action menu item\n * @param {String} stringcomponent\n * @param {String} newaction new value for data-action attribute of the link\n */\n replaceSectionActionItem: function(sectionelement, selector, image, stringname,\n stringcomponent, newaction) {\n log.debug('replaceSectionActionItem() is deprecated and will be removed.');\n var actionitem = sectionelement.find(SELECTOR.SECTIONACTIONMENU + ' ' + selector);\n replaceActionItem(actionitem, image, stringname, stringcomponent, newaction);\n },\n // Method to refresh a module.\n refreshModule,\n refreshSection,\n };\n });\n"],"names":["define","$","ajax","templates","notification","str","url","Y","ModalCopyToClipboard","ModalSaveCancel","ModalEvents","KeyCodes","log","editor","EventDispatcher","CourseEvents","componentActions","courseeditor","getCurrentCourseEditor","formatname","CSS","SELECTOR","ACTIVITYLI","ACTIONAREA","ACTIVITYACTION","MENU","TOGGLE","SECTIONLI","SECTIONACTIONMENU","SECTIONITEM","ADDSECTIONS","SECTIONBADGES","use","courseformatselector","M","course","format","get_section_selector","dispatchEvent","eventName","detail","container","options","Element","undefined","get","getModuleId","element","item","dataset","id","Moodle","core_course","util","cm","getId","Node","addActivitySpinner","activity","addClass","actionarea","find","spinner","add_spinner","show","data","dispatch","addSectionSpinner","sectionelement","addSectionLightbox","lightbox","add_lightbox","for","setAttribute","removeSpinner","delay","window","setTimeout","removeClass","hide","mutation","removeLightbox","getAttribute","initActionMenu","elementid","coursebase","invoke_function","core","actionmenu","newDOMNode","one","editModule","moduleElement","cmid","target","action","attr","promises","call","methodname","args","sectionreturn","closest","when","apply","done","mainElement","tabables","isInside","foundElement","elementToFocus","each","contains","this","replaceWith","affectedids","index","elementId","mainelement","selector","is","focus","focusActionItem","push","trigger","Event","ajaxreturn","fail","ex","e","exception","isDefaultPrevented","refreshModule","sectionReturn","activityElement","Promise","resolve","reject","replaceActivityHtmlWith","confirmDeleteModule","onconfirm","modtypename","match","modulename","name","getName","state","_state$cm$get","getModuleName","get_string","pluginname","plugindata","type","get_strings","key","component","param","s","confirm","replaceActionItem","actionitem","image","stringname","stringcomponent","newaction","stringRequests","then","strings","html","renderPix","pixhtml","catch","defaultEditSectionHandler","sectionElement","actionItem","courseformat","sectionid","setSectionBadge","modules","i","section_availability","first","section","oldmarker","oldActionItem","activityHTML","focusedPath","document","getElementById","activeElement","querySelector","getActivityFocusedElement","editSection","supportComponents","includes","dataencoded","parseJSON","badgetype","add","removeOther","sectionbadges","badge","querySelectorAll","forEach","b","classList","remove","register_module","set_visibility_resource_ui","getDOMNode","updateMovedCmState","params","updateMovedSectionState","addMutations","legacyActivityAction","statemanager","setReadOnly","locked","cmlist","reduce","current","delete","legacySectionAction","initCoursePage","on","keyCode","moduleId","preventDefault","sectionId","create","text","isExecuted","message","itemid","modalTitle","newSections","strNumberSections","modalBody","body","title","modal","numSections","getBody","addSections","parseInt","val","location","setSaveButtonText","getRoot","shown","select","enter","save","replaceSectionActionItem","debug","refreshSection","newSectionElement","content","sectionRefreshed","defaultPrevented"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"actions.min.js","sources":["../src/actions.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Various actions on modules and sections in the editing mode - hiding, duplicating, deleting, etc.\n *\n * @module core_course/actions\n * @copyright 2016 Marina Glancy\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 3.3\n */\ndefine(\n [\n 'jquery',\n 'core/ajax',\n 'core/templates',\n 'core/notification',\n 'core/str',\n 'core/url',\n 'core/yui',\n 'core/modal_copy_to_clipboard',\n 'core/modal_save_cancel',\n 'core/modal_events',\n 'core/key_codes',\n 'core/log',\n 'core_courseformat/courseeditor',\n 'core/event_dispatcher',\n 'core_course/events'\n ],\n function(\n $,\n ajax,\n templates,\n notification,\n str,\n url,\n Y,\n ModalCopyToClipboard,\n ModalSaveCancel,\n ModalEvents,\n KeyCodes,\n log,\n editor,\n EventDispatcher,\n CourseEvents\n ) {\n\n // Eventually, core_courseformat/local/content/actions will handle all actions for\n // component compatible formats and the default actions.js won't be necessary anymore.\n // Meanwhile, we filter the migrated actions.\n const componentActions = [\n 'moveSection', 'moveCm', 'addSection', 'deleteSection', 'cmDelete', 'cmDuplicate', 'sectionHide', 'sectionShow',\n 'cmHide', 'cmShow', 'cmStealth', 'sectionHighlight', 'sectionUnhighlight', 'cmMoveRight', 'cmMoveLeft',\n 'cmNoGroups', 'cmVisibleGroups', 'cmSeparateGroups',\n ];\n\n // The course reactive instance.\n const courseeditor = editor.getCurrentCourseEditor();\n\n // The current course format name (loaded on init).\n let formatname;\n\n var CSS = {\n EDITINPROGRESS: 'editinprogress',\n SECTIONDRAGGABLE: 'sectiondraggable',\n EDITINGMOVE: 'editing_move'\n };\n var SELECTOR = {\n ACTIVITYLI: 'li.activity',\n ACTIONAREA: '.actions',\n ACTIVITYACTION: 'a.cm-edit-action',\n MENU: '.moodle-actionmenu[data-enhance=moodle-core-actionmenu]',\n TOGGLE: '.toggle-display,.dropdown-toggle',\n SECTIONLI: 'li.section',\n SECTIONACTIONMENU: '.section_action_menu',\n SECTIONITEM: '[data-for=\"section_title\"]',\n ADDSECTIONS: '.changenumsections [data-add-sections]',\n SECTIONBADGES: '[data-region=\"sectionbadges\"]',\n };\n\n Y.use('moodle-course-coursebase', function() {\n var courseformatselector = M.course.format.get_section_selector();\n if (courseformatselector) {\n SELECTOR.SECTIONLI = courseformatselector;\n }\n });\n\n /**\n * Dispatch event wrapper.\n *\n * Old jQuery events will be replaced by native events gradually.\n *\n * @method dispatchEvent\n * @param {String} eventName The name of the event\n * @param {Object} detail Any additional details to pass into the eveent\n * @param {Node|HTMLElement} container The point at which to dispatch the event\n * @param {Object} options\n * @param {Boolean} options.bubbles Whether to bubble up the DOM\n * @param {Boolean} options.cancelable Whether preventDefault() can be called\n * @param {Boolean} options.composed Whether the event can bubble across the ShadowDOM boundary\n * @returns {CustomEvent}\n */\n const dispatchEvent = function(eventName, detail, container, options) {\n // Most actions still uses jQuery node instead of regular HTMLElement.\n if (!(container instanceof Element) && container.get !== undefined) {\n container = container.get(0);\n }\n return EventDispatcher.dispatchEvent(eventName, detail, container, options);\n };\n\n /**\n * Wrapper for Y.Moodle.core_course.util.cm.getId\n *\n * @param {JQuery} element\n * @returns {Integer}\n */\n var getModuleId = function(element) {\n // Check if we have a data-id first.\n const item = element.get(0);\n if (item.dataset.id) {\n return item.dataset.id;\n }\n // Use YUI way if data-id is not present.\n let id;\n Y.use('moodle-course-util', function(Y) {\n id = Y.Moodle.core_course.util.cm.getId(Y.Node(item));\n });\n return id;\n };\n\n /**\n * Wrapper for Y.Moodle.core_course.util.cm.getName\n *\n * @param {JQuery} element\n * @returns {String}\n */\n var getModuleName = function(element) {\n var name;\n Y.use('moodle-course-util', function(Y) {\n name = Y.Moodle.core_course.util.cm.getName(Y.Node(element.get(0)));\n });\n // Check if we have the name in the course state.\n const state = courseeditor.state;\n const cmid = getModuleId(element);\n if (!name && state && cmid) {\n name = state.cm.get(cmid)?.name;\n }\n return name;\n };\n\n /**\n * Wrapper for M.util.add_spinner for an activity\n *\n * @param {JQuery} activity\n * @returns {Node}\n */\n var addActivitySpinner = function(activity) {\n activity.addClass(CSS.EDITINPROGRESS);\n var actionarea = activity.find(SELECTOR.ACTIONAREA).get(0);\n if (actionarea) {\n var spinner = M.util.add_spinner(Y, Y.Node(actionarea));\n spinner.show();\n // Lock the activity state element.\n if (activity.data('id') !== undefined) {\n courseeditor.dispatch('cmLock', [activity.data('id')], true);\n }\n return spinner;\n }\n return null;\n };\n\n /**\n * Wrapper for M.util.add_spinner for a section\n *\n * @param {JQuery} sectionelement\n * @returns {Node}\n */\n var addSectionSpinner = function(sectionelement) {\n sectionelement.addClass(CSS.EDITINPROGRESS);\n var actionarea = sectionelement.find(SELECTOR.SECTIONACTIONMENU).get(0);\n if (actionarea) {\n var spinner = M.util.add_spinner(Y, Y.Node(actionarea));\n spinner.show();\n // Lock the section state element.\n if (sectionelement.data('id') !== undefined) {\n courseeditor.dispatch('sectionLock', [sectionelement.data('id')], true);\n }\n return spinner;\n }\n return null;\n };\n\n /**\n * Wrapper for M.util.add_lightbox\n *\n * @param {JQuery} sectionelement\n * @returns {Node}\n */\n var addSectionLightbox = function(sectionelement) {\n const item = sectionelement.get(0);\n var lightbox = M.util.add_lightbox(Y, Y.Node(item));\n if (item.dataset.for == 'section' && item.dataset.id) {\n courseeditor.dispatch('sectionLock', [item.dataset.id], true);\n lightbox.setAttribute('data-state', 'section');\n lightbox.setAttribute('data-state-id', item.dataset.id);\n }\n lightbox.show();\n return lightbox;\n };\n\n /**\n * Removes the spinner element\n *\n * @param {JQuery} element\n * @param {Node} spinner\n * @param {Number} delay\n */\n var removeSpinner = function(element, spinner, delay) {\n window.setTimeout(function() {\n element.removeClass(CSS.EDITINPROGRESS);\n if (spinner) {\n spinner.hide();\n }\n // Unlock the state element.\n if (element.data('id') !== undefined) {\n const mutation = (element.data('for') === 'section') ? 'sectionLock' : 'cmLock';\n courseeditor.dispatch(mutation, [element.data('id')], false);\n }\n }, delay);\n };\n\n /**\n * Removes the lightbox element\n *\n * @param {Node} lightbox lighbox YUI element returned by addSectionLightbox\n * @param {Number} delay\n */\n var removeLightbox = function(lightbox, delay) {\n if (lightbox) {\n window.setTimeout(function() {\n lightbox.hide();\n // Unlock state if necessary.\n if (lightbox.getAttribute('data-state')) {\n courseeditor.dispatch(\n `${lightbox.getAttribute('data-state')}Lock`,\n [lightbox.getAttribute('data-state-id')],\n false\n );\n }\n }, delay);\n }\n };\n\n /**\n * Initialise action menu for the element (section or module)\n *\n * @param {String} elementid CSS id attribute of the element\n */\n var initActionMenu = function(elementid) {\n // Initialise action menu in the new activity.\n Y.use('moodle-course-coursebase', function() {\n M.course.coursebase.invoke_function('setup_for_resource', '#' + elementid);\n });\n if (M.core.actionmenu && M.core.actionmenu.newDOMNode) {\n M.core.actionmenu.newDOMNode(Y.one('#' + elementid));\n }\n };\n\n /**\n * Returns focus to the element that was clicked or \"Edit\" link if element is no longer visible.\n *\n * @param {String} elementId CSS id attribute of the element\n * @param {String} action data-action property of the element that was clicked\n */\n var focusActionItem = function(elementId, action) {\n var mainelement = $('#' + elementId);\n var selector = '[data-action=' + action + ']';\n if (action === 'groupsseparate' || action === 'groupsvisible' || action === 'groupsnone') {\n // New element will have different data-action.\n selector = '[data-action=groupsseparate],[data-action=groupsvisible],[data-action=groupsnone]';\n }\n if (mainelement.find(selector).is(':visible')) {\n mainelement.find(selector).focus();\n } else {\n // Element not visible, focus the \"Edit\" link.\n mainelement.find(SELECTOR.MENU).find(SELECTOR.TOGGLE).focus();\n }\n };\n\n /**\n * Find next
after the element\n *\n * @param {JQuery} mainElement element that is about to be deleted\n * @returns {JQuery}\n */\n var findNextFocusable = function(mainElement) {\n var tabables = $(\"a:visible\");\n var isInside = false;\n var foundElement = null;\n tabables.each(function() {\n if ($.contains(mainElement[0], this)) {\n isInside = true;\n } else if (isInside) {\n foundElement = this;\n return false; // Returning false in .each() is equivalent to \"break;\" inside the loop in php.\n }\n return true;\n });\n return foundElement;\n };\n\n /**\n * Performs an action on a module (moving, deleting, duplicating, hiding, etc.)\n *\n * @param {JQuery} moduleElement activity element we perform action on\n * @param {Number} cmid\n * @param {JQuery} target the element (menu item) that was clicked\n */\n var editModule = function(moduleElement, cmid, target) {\n var action = target.attr('data-action');\n var spinner = addActivitySpinner(moduleElement);\n var promises = ajax.call([{\n methodname: 'core_course_edit_module',\n args: {id: cmid,\n action: action,\n sectionreturn: target.attr('data-sectionreturn') ? target.attr('data-sectionreturn') : null\n }\n }], true);\n\n var lightbox;\n if (action === 'duplicate') {\n lightbox = addSectionLightbox(target.closest(SELECTOR.SECTIONLI));\n }\n $.when.apply($, promises)\n .done(function(data) {\n var elementToFocus = findNextFocusable(moduleElement);\n moduleElement.replaceWith(data);\n let affectedids = [];\n // Initialise action menu for activity(ies) added as a result of this.\n $('
' + data + '
').find(SELECTOR.ACTIVITYLI).each(function(index) {\n initActionMenu($(this).attr('id'));\n if (index === 0) {\n focusActionItem($(this).attr('id'), action);\n elementToFocus = null;\n }\n // Save any activity id in cmids.\n affectedids.push(getModuleId($(this)));\n });\n // In case of activity deletion focus the next focusable element.\n if (elementToFocus) {\n elementToFocus.focus();\n }\n // Remove spinner and lightbox with a delay.\n removeSpinner(moduleElement, spinner, 400);\n removeLightbox(lightbox, 400);\n // Trigger event that can be observed by course formats.\n moduleElement.trigger($.Event('coursemoduleedited', {ajaxreturn: data, action: action}));\n\n // Modify cm state.\n courseeditor.dispatch('legacyActivityAction', action, cmid, affectedids);\n\n }).fail(function(ex) {\n // Remove spinner and lightbox.\n removeSpinner(moduleElement, spinner);\n removeLightbox(lightbox);\n // Trigger event that can be observed by course formats.\n var e = $.Event('coursemoduleeditfailed', {exception: ex, action: action});\n moduleElement.trigger(e);\n if (!e.isDefaultPrevented()) {\n notification.exception(ex);\n }\n });\n };\n\n /**\n * Requests html for the module via WS core_course_get_module and updates the module on the course page\n *\n * Used after d&d of the module to another section\n *\n * @param {JQuery|Element} element\n * @param {Number} cmid\n * @param {Number} sectionreturn\n * @return {Promise} the refresh promise\n */\n var refreshModule = function(element, cmid, sectionreturn) {\n\n if (sectionreturn === undefined) {\n sectionreturn = courseeditor.sectionReturn;\n }\n\n const activityElement = $(element);\n var spinner = addActivitySpinner(activityElement);\n var promises = ajax.call([{\n methodname: 'core_course_get_module',\n args: {id: cmid, sectionreturn: sectionreturn}\n }], true);\n\n return new Promise((resolve, reject) => {\n $.when.apply($, promises)\n .done(function(data) {\n removeSpinner(activityElement, spinner, 400);\n replaceActivityHtmlWith(data);\n resolve(data);\n }).fail(function() {\n removeSpinner(activityElement, spinner);\n reject();\n });\n });\n };\n\n /**\n * Requests html for the section via WS core_course_edit_section and updates the section on the course page\n *\n * @param {JQuery|Element} element\n * @param {Number} sectionid\n * @param {Number} sectionreturn\n * @return {Promise} the refresh promise\n */\n var refreshSection = function(element, sectionid, sectionreturn) {\n\n if (sectionreturn === undefined) {\n sectionreturn = courseeditor.sectionReturn;\n }\n\n const sectionElement = $(element);\n const action = 'refresh';\n const promises = ajax.call([{\n methodname: 'core_course_edit_section',\n args: {id: sectionid, action, sectionreturn},\n }], true);\n\n var spinner = addSectionSpinner(sectionElement);\n return new Promise((resolve, reject) => {\n $.when.apply($, promises)\n .done(dataencoded => {\n\n removeSpinner(sectionElement, spinner);\n const data = $.parseJSON(dataencoded);\n\n const newSectionElement = $(data.content);\n sectionElement.replaceWith(newSectionElement);\n\n // Init modules menus.\n $(`${SELECTOR.SECTIONLI}#${sectionid} ${SELECTOR.ACTIVITYLI}`).each(\n (index, activity) => {\n initActionMenu(activity.data('id'));\n }\n );\n\n // Trigger event that can be observed by course formats.\n const event = dispatchEvent(\n CourseEvents.sectionRefreshed,\n {\n ajaxreturn: data,\n action: action,\n newSectionElement: newSectionElement.get(0),\n },\n newSectionElement\n );\n\n if (!event.defaultPrevented) {\n defaultEditSectionHandler(\n newSectionElement, $(SELECTOR.SECTIONLI + '#' + sectionid),\n data,\n formatname,\n sectionid\n );\n }\n resolve(data);\n }).fail(ex => {\n // Trigger event that can be observed by course formats.\n const event = dispatchEvent(\n 'coursesectionrefreshfailed',\n {exception: ex, action: action},\n sectionElement\n );\n if (!event.defaultPrevented) {\n notification.exception(ex);\n }\n reject();\n });\n });\n };\n\n /**\n * Displays the delete confirmation to delete a module\n *\n * @param {JQuery} mainelement activity element we perform action on\n * @param {function} onconfirm function to execute on confirm\n */\n var confirmDeleteModule = function(mainelement, onconfirm) {\n var modtypename = mainelement.attr('class').match(/modtype_([^\\s]*)/)[1];\n var modulename = getModuleName(mainelement);\n\n str.get_string('pluginname', modtypename).done(function(pluginname) {\n var plugindata = {\n type: pluginname,\n name: modulename\n };\n str.get_strings([\n {key: 'confirm', component: 'core'},\n {key: modulename === null ? 'deletechecktype' : 'deletechecktypename', param: plugindata},\n {key: 'yes'},\n {key: 'no'}\n ]).done(function(s) {\n notification.confirm(s[0], s[1], s[2], s[3], onconfirm);\n }\n );\n });\n };\n\n /**\n * Displays the delete confirmation to delete a section\n *\n * @param {String} message confirmation message\n * @param {function} onconfirm function to execute on confirm\n */\n var confirmEditSection = function(message, onconfirm) {\n str.get_strings([\n {key: 'confirm'}, // TODO link text\n {key: 'yes'},\n {key: 'no'}\n ]).done(function(s) {\n notification.confirm(s[0], message, s[1], s[2], onconfirm);\n }\n );\n };\n\n /**\n * Replaces an action menu item with another one (for example Show->Hide, Set marker->Remove marker)\n *\n * @param {JQuery} actionitem\n * @param {String} image new image name (\"i/show\", \"i/hide\", etc.)\n * @param {String} stringname new string for the action menu item\n * @param {String} stringcomponent\n * @param {String} newaction new value for data-action attribute of the link\n * @return {Promise} promise which is resolved when the replacement has completed\n */\n var replaceActionItem = function(actionitem, image, stringname,\n stringcomponent, newaction) {\n\n var stringRequests = [{key: stringname, component: stringcomponent}];\n // Do not provide an icon with duplicate, different text to the menu item.\n\n return str.get_strings(stringRequests).then(function(strings) {\n actionitem.find('span.menu-action-text').html(strings[0]);\n\n return templates.renderPix(image, 'core');\n }).then(function(pixhtml) {\n actionitem.find('.icon').replaceWith(pixhtml);\n actionitem.attr('data-action', newaction);\n return;\n }).catch(notification.exception);\n };\n\n /**\n * Default post-processing for section AJAX edit actions.\n *\n * This can be overridden in course formats by listening to event coursesectionedited:\n *\n * $('body').on('coursesectionedited', 'li.section', function(e) {\n * var action = e.action,\n * sectionElement = $(e.target),\n * data = e.ajaxreturn;\n * // ... Do some processing here.\n * e.preventDefault(); // Prevent default handler.\n * });\n *\n * @param {JQuery} sectionElement\n * @param {JQuery} actionItem\n * @param {Object} data\n * @param {String} courseformat\n * @param {Number} sectionid\n */\n var defaultEditSectionHandler = function(sectionElement, actionItem, data, courseformat, sectionid) {\n var action = actionItem.attr('data-action');\n if (action === 'hide' || action === 'show') {\n if (action === 'hide') {\n sectionElement.addClass('hidden');\n setSectionBadge(sectionElement[0], 'hiddenfromstudents', true, false);\n replaceActionItem(actionItem, 'i/show',\n 'showfromothers', 'format_' + courseformat, 'show');\n } else {\n setSectionBadge(sectionElement[0], 'hiddenfromstudents', false, false);\n sectionElement.removeClass('hidden');\n replaceActionItem(actionItem, 'i/hide',\n 'hidefromothers', 'format_' + courseformat, 'hide');\n }\n // Replace the modules with new html (that indicates that they are now hidden or not hidden).\n if (data.modules !== undefined) {\n for (var i in data.modules) {\n replaceActivityHtmlWith(data.modules[i]);\n }\n }\n // Replace the section availability information.\n if (data.section_availability !== undefined) {\n sectionElement.find('.section_availability').first().replaceWith(data.section_availability);\n }\n // Modify course state.\n const section = courseeditor.state.section.get(sectionid);\n if (section !== undefined) {\n courseeditor.dispatch('sectionState', [sectionid]);\n }\n } else if (action === 'setmarker') {\n var oldmarker = $(SELECTOR.SECTIONLI + '.current'),\n oldActionItem = oldmarker.find(SELECTOR.SECTIONACTIONMENU + ' ' + 'a[data-action=removemarker]');\n oldmarker.removeClass('current');\n replaceActionItem(oldActionItem, 'i/marker',\n 'highlight', 'core', 'setmarker');\n sectionElement.addClass('current');\n replaceActionItem(actionItem, 'i/marked',\n 'highlightoff', 'core', 'removemarker');\n courseeditor.dispatch('legacySectionAction', action, sectionid);\n setSectionBadge(sectionElement[0], 'iscurrent', true, true);\n } else if (action === 'removemarker') {\n sectionElement.removeClass('current');\n replaceActionItem(actionItem, 'i/marker',\n 'highlight', 'core', 'setmarker');\n courseeditor.dispatch('legacySectionAction', action, sectionid);\n setSectionBadge(sectionElement[0], 'iscurrent', false, true);\n }\n };\n\n /**\n * Get the focused element path in an activity if any.\n *\n * This method is used to restore focus when the activity HTML is refreshed.\n * Only the main course editor elements can be refocused as they are always present\n * even if the activity content changes.\n *\n * @param {String} id the element id the activity element\n * @return {String|undefined} the inner path of the focused element or undefined\n */\n const getActivityFocusedElement = function(id) {\n const element = document.getElementById(id);\n if (!element || !element.contains(document.activeElement)) {\n return undefined;\n }\n // Check if the actions menu toggler is focused.\n if (element.querySelector(SELECTOR.ACTIONAREA).contains(document.activeElement)) {\n return `${SELECTOR.ACTIONAREA} [tabindex=\"0\"]`;\n }\n // Return the current element id if any.\n if (document.activeElement.id) {\n return `#${document.activeElement.id}`;\n }\n return undefined;\n };\n\n /**\n * Replaces the course module with the new html (used to update module after it was edited or its visibility was changed).\n *\n * @param {String} activityHTML\n */\n var replaceActivityHtmlWith = function(activityHTML) {\n $('
' + activityHTML + '
').find(SELECTOR.ACTIVITYLI).each(function() {\n // Extract id from the new activity html.\n var id = $(this).attr('id');\n // Check if the current focused element is inside the activity.\n let focusedPath = getActivityFocusedElement(id);\n // Find the existing element with the same id and replace its contents with new html.\n $(SELECTOR.ACTIVITYLI + '#' + id).replaceWith(activityHTML);\n // Initialise action menu.\n initActionMenu(id);\n // Re-focus the previous elements.\n if (focusedPath) {\n const newItem = document.getElementById(id);\n newItem.querySelector(focusedPath)?.focus();\n }\n\n });\n };\n\n /**\n * Performs an action on a module (moving, deleting, duplicating, hiding, etc.)\n *\n * @param {JQuery} sectionElement section element we perform action on\n * @param {Nunmber} sectionid\n * @param {JQuery} target the element (menu item) that was clicked\n * @param {String} courseformat\n * @return {boolean} true the action call is sent to the server or false if it is ignored.\n */\n var editSection = function(sectionElement, sectionid, target, courseformat) {\n var action = target.attr('data-action'),\n sectionreturn = target.attr('data-sectionreturn') ? target.attr('data-sectionreturn') : null;\n\n // Filter direct component handled actions.\n if (courseeditor.supportComponents && componentActions.includes(action)) {\n return false;\n }\n\n var spinner = addSectionSpinner(sectionElement);\n var promises = ajax.call([{\n methodname: 'core_course_edit_section',\n args: {id: sectionid, action: action, sectionreturn: sectionreturn}\n }], true);\n\n var lightbox = addSectionLightbox(sectionElement);\n $.when.apply($, promises)\n .done(function(dataencoded) {\n var data = $.parseJSON(dataencoded);\n removeSpinner(sectionElement, spinner);\n removeLightbox(lightbox);\n sectionElement.find(SELECTOR.SECTIONACTIONMENU).find(SELECTOR.TOGGLE).focus();\n // Trigger event that can be observed by course formats.\n var e = $.Event('coursesectionedited', {ajaxreturn: data, action: action});\n sectionElement.trigger(e);\n if (!e.isDefaultPrevented()) {\n defaultEditSectionHandler(sectionElement, target, data, courseformat, sectionid);\n }\n }).fail(function(ex) {\n // Remove spinner and lightbox.\n removeSpinner(sectionElement, spinner);\n removeLightbox(lightbox);\n // Trigger event that can be observed by course formats.\n var e = $.Event('coursesectioneditfailed', {exception: ex, action: action});\n sectionElement.trigger(e);\n if (!e.isDefaultPrevented()) {\n notification.exception(ex);\n }\n });\n return true;\n };\n\n /**\n * Sets the section badge in the section header.\n *\n * @param {JQuery} sectionElement section element we perform action on\n * @param {String} badgetype the type of badge this is for\n * @param {bool} add true to add, false to remove\n * @param {boolean} removeOther in case of adding a badge, whether to remove all other.\n */\n var setSectionBadge = function(sectionElement, badgetype, add, removeOther) {\n const sectionbadges = sectionElement.querySelector(SELECTOR.SECTIONBADGES);\n if (!sectionbadges) {\n return;\n }\n const badge = sectionbadges.querySelector('[data-type=\"' + badgetype + '\"]');\n if (!badge) {\n return;\n }\n if (add) {\n if (removeOther) {\n document.querySelectorAll('[data-type=\"' + badgetype + '\"]').forEach((b) => {\n b.classList.add('d-none');\n });\n }\n badge.classList.remove('d-none');\n } else {\n badge.classList.add('d-none');\n }\n };\n\n // Register a function to be executed after D&D of an activity.\n Y.use('moodle-course-coursebase', function() {\n M.course.coursebase.register_module({\n // Ignore camelcase eslint rule for the next line because it is an expected name of the callback.\n // eslint-disable-next-line camelcase\n set_visibility_resource_ui: function(args) {\n var mainelement = $(args.element.getDOMNode());\n var cmid = getModuleId(mainelement);\n if (cmid) {\n var sectionreturn = mainelement.find('.' + CSS.EDITINGMOVE).attr('data-sectionreturn');\n refreshModule(mainelement, cmid, sectionreturn);\n }\n },\n /**\n * Update the course state when some cm is moved via YUI.\n * @param {*} params\n */\n updateMovedCmState: (params) => {\n const state = courseeditor.state;\n\n // Update old section.\n const cm = state.cm.get(params.cmid);\n if (cm !== undefined) {\n courseeditor.dispatch('sectionState', [cm.sectionid]);\n }\n // Update cm state.\n courseeditor.dispatch('cmState', [params.cmid]);\n },\n /**\n * Update the course state when some section is moved via YUI.\n */\n updateMovedSectionState: () => {\n courseeditor.dispatch('courseState');\n },\n });\n });\n\n // From Moodle 4.0 all edit actions are being re-implemented as state mutation.\n // This means all method from this \"actions\" module will be deprecated when all the course\n // interface is migrated to reactive components.\n // Most legacy actions did not provide enough information to regenarate the course so they\n // use the mutations courseState, sectionState and cmState to get the updated state from\n // the server. However, some activity actions where we can prevent an extra webservice\n // call by implementing an adhoc mutation.\n courseeditor.addMutations({\n /**\n * Compatibility function to update Moodle 4.0 course state using legacy actions.\n *\n * This method only updates some actions which does not require to use cmState mutation\n * to get updated data form the server.\n *\n * @param {Object} statemanager the current state in read write mode\n * @param {String} action the performed action\n * @param {Number} cmid the affected course module id\n * @param {Array} affectedids all affected cm ids (for duplicate action)\n */\n legacyActivityAction: function(statemanager, action, cmid, affectedids) {\n\n const state = statemanager.state;\n const cm = state.cm.get(cmid);\n if (cm === undefined) {\n return;\n }\n const section = state.section.get(cm.sectionid);\n if (section === undefined) {\n return;\n }\n\n // Send the element is locked.\n courseeditor.dispatch('cmLock', [cm.id], true);\n\n // Now we do the real mutation.\n statemanager.setReadOnly(false);\n\n // This unlocked will take effect when the read only is restored.\n cm.locked = false;\n\n switch (action) {\n case 'delete':\n // Remove from section.\n section.cmlist = section.cmlist.reduce(\n (cmlist, current) => {\n if (current != cmid) {\n cmlist.push(current);\n }\n return cmlist;\n },\n []\n );\n // Delete form list.\n state.cm.delete(cmid);\n break;\n\n case 'hide':\n case 'show':\n case 'duplicate':\n courseeditor.dispatch('cmState', affectedids);\n break;\n }\n statemanager.setReadOnly(true);\n },\n legacySectionAction: function(statemanager, action, sectionid) {\n\n const state = statemanager.state;\n const section = state.section.get(sectionid);\n if (section === undefined) {\n return;\n }\n\n // Send the element is locked. Reactive events are only triggered when the state\n // read only mode is restored. We want to notify the interface the element is\n // locked so we need to do a quick lock operation before performing the rest\n // of the mutation.\n statemanager.setReadOnly(false);\n section.locked = true;\n statemanager.setReadOnly(true);\n\n // Now we do the real mutation.\n statemanager.setReadOnly(false);\n\n // This locked will take effect when the read only is restored.\n section.locked = false;\n\n switch (action) {\n case 'setmarker':\n // Remove previous marker.\n state.section.forEach((current) => {\n if (current.id != sectionid) {\n current.current = false;\n }\n });\n section.current = true;\n break;\n\n case 'removemarker':\n section.current = false;\n break;\n }\n statemanager.setReadOnly(true);\n },\n });\n\n return /** @alias module:core_course/actions */ {\n\n /**\n * Initialises course page\n *\n * @method init\n * @param {String} courseformat name of the current course format (for fetching strings)\n */\n initCoursePage: function(courseformat) {\n\n formatname = courseformat;\n\n // Add a handler for course module actions.\n $('body').on('click keypress', SELECTOR.ACTIVITYLI + ' ' +\n SELECTOR.ACTIVITYACTION + '[data-action]', function(e) {\n if (e.type === 'keypress' && e.keyCode !== 13) {\n return;\n }\n var actionItem = $(this),\n moduleElement = actionItem.closest(SELECTOR.ACTIVITYLI),\n action = actionItem.attr('data-action'),\n moduleId = getModuleId(moduleElement);\n switch (action) {\n case 'moveleft':\n case 'moveright':\n case 'delete':\n case 'duplicate':\n case 'hide':\n case 'stealth':\n case 'show':\n case 'groupsseparate':\n case 'groupsvisible':\n case 'groupsnone':\n break;\n default:\n // Nothing to do here!\n return;\n }\n if (!moduleId) {\n return;\n }\n e.preventDefault();\n if (action === 'delete') {\n // Deleting requires confirmation.\n confirmDeleteModule(moduleElement, function() {\n editModule(moduleElement, moduleId, actionItem);\n });\n } else {\n editModule(moduleElement, moduleId, actionItem);\n }\n });\n\n // Add a handler for section show/hide actions.\n $('body').on('click keypress', SELECTOR.SECTIONLI + ' ' +\n SELECTOR.SECTIONACTIONMENU + '[data-sectionid] ' +\n 'a[data-action]', function(e) {\n if (e.type === 'keypress' && e.keyCode !== 13) {\n return;\n }\n var actionItem = $(this),\n sectionElement = actionItem.closest(SELECTOR.SECTIONLI),\n sectionId = actionItem.closest(SELECTOR.SECTIONACTIONMENU).attr('data-sectionid');\n\n if (actionItem.attr('data-action') === 'permalink') {\n e.preventDefault();\n ModalCopyToClipboard.create({\n text: actionItem.attr('href'),\n }, str.get_string('sectionlink', 'course')\n );\n return;\n }\n\n let isExecuted = true;\n if (actionItem.attr('data-confirm')) {\n // Action requires confirmation.\n confirmEditSection(actionItem.attr('data-confirm'), function() {\n isExecuted = editSection(sectionElement, sectionId, actionItem, courseformat);\n });\n } else {\n isExecuted = editSection(sectionElement, sectionId, actionItem, courseformat);\n }\n // Prevent any other module from capturing the action if it is already in execution.\n if (isExecuted) {\n e.preventDefault();\n }\n });\n\n // The section and activity names are edited using inplace editable.\n // The \"update\" jQuery event must be captured in order to update the course state.\n $('body').on('updated', `${SELECTOR.SECTIONITEM} [data-inplaceeditable]`, function(e) {\n if (e.ajaxreturn && e.ajaxreturn.itemid) {\n const state = courseeditor.state;\n const section = state.section.get(e.ajaxreturn.itemid);\n if (section !== undefined) {\n courseeditor.dispatch('sectionState', [e.ajaxreturn.itemid]);\n }\n }\n });\n $('body').on('updated', `${SELECTOR.ACTIVITYLI} [data-inplaceeditable]`, function(e) {\n if (e.ajaxreturn && e.ajaxreturn.itemid) {\n courseeditor.dispatch('cmState', [e.ajaxreturn.itemid]);\n }\n });\n\n // Component-based formats don't use modals to create sections.\n if (courseeditor.supportComponents && componentActions.includes('addSection')) {\n return;\n }\n\n // Add a handler for \"Add sections\" link to ask for a number of sections to add.\n const trigger = $(SELECTOR.ADDSECTIONS);\n const modalTitle = trigger.attr('data-add-sections');\n const newSections = trigger.attr('data-new-sections');\n str.get_string('numberweeks')\n .then(function(strNumberSections) {\n var modalBody = $('
' +\n '
');\n modalBody.find('label').html(strNumberSections);\n\n return modalBody.html();\n })\n .then((body) => ModalSaveCancel.create({\n body,\n title: modalTitle,\n }))\n .then(function(modal) {\n var numSections = $(modal.getBody()).find('#add_section_numsections'),\n addSections = function() {\n // Check if value of the \"Number of sections\" is a valid positive integer and redirect\n // to adding a section script.\n if ('' + parseInt(numSections.val()) === numSections.val() && parseInt(numSections.val()) >= 1) {\n document.location = trigger.attr('href') + '&numsections=' + parseInt(numSections.val());\n }\n };\n modal.setSaveButtonText(modalTitle);\n modal.getRoot().on(ModalEvents.shown, function() {\n // When modal is shown focus and select the input and add a listener to keypress of \"Enter\".\n numSections.focus().select().on('keydown', function(e) {\n if (e.keyCode === KeyCodes.enter) {\n addSections();\n }\n });\n });\n modal.getRoot().on(ModalEvents.save, function(e) {\n // When modal \"Add\" button is pressed.\n e.preventDefault();\n addSections();\n });\n\n trigger.on('click', (e) => {\n e.preventDefault();\n modal.show();\n });\n\n return modal;\n })\n .catch(notification.exception);\n },\n\n /**\n * Replaces a section action menu item with another one (for example Show->Hide, Set marker->Remove marker)\n *\n * This method can be used by course formats in their listener to the coursesectionedited event\n *\n * @deprecated since Moodle 3.9\n * @param {JQuery} sectionelement\n * @param {String} selector CSS selector inside the section element, for example \"a[data-action=show]\"\n * @param {String} image new image name (\"i/show\", \"i/hide\", etc.)\n * @param {String} stringname new string for the action menu item\n * @param {String} stringcomponent\n * @param {String} newaction new value for data-action attribute of the link\n */\n replaceSectionActionItem: function(sectionelement, selector, image, stringname,\n stringcomponent, newaction) {\n log.debug('replaceSectionActionItem() is deprecated and will be removed.');\n var actionitem = sectionelement.find(SELECTOR.SECTIONACTIONMENU + ' ' + selector);\n replaceActionItem(actionitem, image, stringname, stringcomponent, newaction);\n },\n // Method to refresh a module.\n refreshModule,\n refreshSection,\n };\n });\n"],"names":["define","$","ajax","templates","notification","str","url","Y","ModalCopyToClipboard","ModalSaveCancel","ModalEvents","KeyCodes","log","editor","EventDispatcher","CourseEvents","componentActions","courseeditor","getCurrentCourseEditor","formatname","CSS","SELECTOR","ACTIVITYLI","ACTIONAREA","ACTIVITYACTION","MENU","TOGGLE","SECTIONLI","SECTIONACTIONMENU","SECTIONITEM","ADDSECTIONS","SECTIONBADGES","use","courseformatselector","M","course","format","get_section_selector","dispatchEvent","eventName","detail","container","options","Element","undefined","get","getModuleId","element","item","dataset","id","Moodle","core_course","util","cm","getId","Node","addActivitySpinner","activity","addClass","actionarea","find","spinner","add_spinner","show","data","dispatch","addSectionSpinner","sectionelement","addSectionLightbox","lightbox","add_lightbox","for","setAttribute","removeSpinner","delay","window","setTimeout","removeClass","hide","mutation","removeLightbox","getAttribute","initActionMenu","elementid","coursebase","invoke_function","core","actionmenu","newDOMNode","one","editModule","moduleElement","cmid","target","action","attr","promises","call","methodname","args","sectionreturn","closest","when","apply","done","mainElement","tabables","isInside","foundElement","elementToFocus","each","contains","this","replaceWith","affectedids","index","elementId","mainelement","selector","is","focus","focusActionItem","push","trigger","Event","ajaxreturn","fail","ex","e","exception","isDefaultPrevented","refreshModule","sectionReturn","activityElement","Promise","resolve","reject","replaceActivityHtmlWith","confirmDeleteModule","onconfirm","modtypename","match","modulename","name","getName","state","_state$cm$get","getModuleName","get_string","pluginname","plugindata","type","get_strings","key","component","param","s","confirm","replaceActionItem","actionitem","image","stringname","stringcomponent","newaction","stringRequests","then","strings","html","renderPix","pixhtml","catch","defaultEditSectionHandler","sectionElement","actionItem","courseformat","sectionid","setSectionBadge","modules","i","section_availability","first","section","oldmarker","oldActionItem","activityHTML","focusedPath","document","getElementById","activeElement","querySelector","getActivityFocusedElement","editSection","supportComponents","includes","dataencoded","parseJSON","badgetype","add","removeOther","sectionbadges","badge","querySelectorAll","forEach","b","classList","remove","register_module","set_visibility_resource_ui","getDOMNode","updateMovedCmState","params","updateMovedSectionState","addMutations","legacyActivityAction","statemanager","setReadOnly","locked","cmlist","reduce","current","delete","legacySectionAction","initCoursePage","on","keyCode","moduleId","preventDefault","sectionId","create","text","isExecuted","message","itemid","modalTitle","newSections","strNumberSections","modalBody","body","title","modal","numSections","getBody","addSections","parseInt","val","location","setSaveButtonText","getRoot","shown","select","enter","save","replaceSectionActionItem","debug","refreshSection","newSectionElement","content","sectionRefreshed","defaultPrevented"],"mappings":"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"} \ No newline at end of file diff --git a/course/amd/src/actions.js b/course/amd/src/actions.js index 1758c2a1e7e7d..6a0d35aad0abe 100644 --- a/course/amd/src/actions.js +++ b/course/amd/src/actions.js @@ -994,7 +994,7 @@ define( // The section and activity names are edited using inplace editable. // The "update" jQuery event must be captured in order to update the course state. - $('body').on('updated', `${SELECTOR.SECTIONLI} ${SELECTOR.SECTIONITEM} [data-inplaceeditable]`, function(e) { + $('body').on('updated', `${SELECTOR.SECTIONITEM} [data-inplaceeditable]`, function(e) { if (e.ajaxreturn && e.ajaxreturn.itemid) { const state = courseeditor.state; const section = state.section.get(e.ajaxreturn.itemid); diff --git a/course/classes/local/repository/content_item_readonly_repository.php b/course/classes/local/repository/content_item_readonly_repository.php index 59995ec21ffd9..573860164df92 100644 --- a/course/classes/local/repository/content_item_readonly_repository.php +++ b/course/classes/local/repository/content_item_readonly_repository.php @@ -132,8 +132,7 @@ public function find_all(): array { $help = $this->get_core_module_help_string($mod->name); $archetype = plugin_supports('mod', $mod->name, FEATURE_MOD_ARCHETYPE, MOD_ARCHETYPE_OTHER); $purpose = plugin_supports('mod', $mod->name, FEATURE_MOD_PURPOSE, MOD_PURPOSE_OTHER); - $isbrandedfunction = $mod->name.'_is_branded'; - $isbranded = function_exists($isbrandedfunction) ? $isbrandedfunction() : false; + $isbranded = component_callback('mod_' . $mod->name, 'is_branded', [], false); $contentitem = new content_item( $mod->id, @@ -197,8 +196,7 @@ public function find_all_for_course(\stdClass $course, \stdClass $user): array { $help = $this->get_core_module_help_string($mod->name); $archetype = plugin_supports('mod', $mod->name, FEATURE_MOD_ARCHETYPE, MOD_ARCHETYPE_OTHER); $purpose = plugin_supports('mod', $mod->name, FEATURE_MOD_PURPOSE, MOD_PURPOSE_OTHER); - $isbrandedfunction = $mod->name.'_is_branded'; - $isbranded = function_exists($isbrandedfunction) ? $isbrandedfunction() : false; + $isbranded = component_callback('mod_' . $mod->name, 'is_branded', [], false); $icon = 'monologo'; // Quick check for monologo icons. diff --git a/course/editsection.php b/course/editsection.php index c379b08a775f0..9cae5dfad2add 100644 --- a/course/editsection.php +++ b/course/editsection.php @@ -104,7 +104,12 @@ ); $courseformat = course_get_format($course); -$defaultsectionname = $courseformat->get_default_section_name($section); + +if ($sectioninfo->is_delegated()) { + $defaultsectionname = $sectioninfo->name; +} else { + $defaultsectionname = $courseformat->get_default_section_name($section); +} $customdata = [ 'cs' => $sectioninfo, diff --git a/course/editsection_form.php b/course/editsection_form.php index 4575dc1e9604f..2e4f5babafd0b 100644 --- a/course/editsection_form.php +++ b/course/editsection_form.php @@ -25,12 +25,19 @@ function definition() { $mform->addElement('header', 'generalhdr', get_string('general')); - $mform->addElement('defaultcustom', 'name', get_string('sectionname'), [ - 'defaultvalue' => $this->_customdata['defaultsectionname'], - 'customvalue' => $sectioninfo->name, - ], ['size' => 30, 'maxlength' => 255]); - $mform->setDefault('name', false); - $mform->addGroupRule('name', array('name' => array(array(get_string('maximumchars', '', 255), 'maxlength', 255)))); + $mform->addElement( + 'text', + 'name', + get_string('sectionname'), + [ + 'placeholder' => $this->_customdata['defaultsectionname'], + 'size' => 30, + 'maxlength' => 255, + ], + ); + $mform->setType('name', PARAM_RAW); + $mform->setDefault('name', $sectioninfo->name); + $mform->addRule('name', get_string('maximumchars', '', 255), 'maxlength', 255, 'client'); /// Prepare course and the editor @@ -98,9 +105,6 @@ function set_data($default_values) { $editoroptions = $this->_customdata['editoroptions']; $default_values = file_prepare_standard_editor($default_values, 'summary', $editoroptions, $editoroptions['context'], 'course', 'section', $default_values->id); - if (strval($default_values->name) === '') { - $default_values->name = false; - } parent::set_data($default_values); } diff --git a/course/externallib.php b/course/externallib.php index 2021386db3b43..c815c52e7666f 100644 --- a/course/externallib.php +++ b/course/externallib.php @@ -258,8 +258,7 @@ public static function get_course_contents($courseid, $options = array()) { $modcontext = context_module::instance($cm->id); - $isbrandedfunction = $cm->modname.'_is_branded'; - $isbranded = function_exists($isbrandedfunction) ? $isbrandedfunction() : false; + $isbranded = component_callback('mod_' . $cm->modname, 'is_branded', [], false); // Common info (for people being able to see the module or availability dates). $module['id'] = $cm->id; diff --git a/course/format/amd/build/local/content.min.js b/course/format/amd/build/local/content.min.js index 59f3be3e1d0e2..332d5991cdc8d 100644 --- a/course/format/amd/build/local/content.min.js +++ b/course/format/amd/build/local/content.min.js @@ -6,6 +6,6 @@ define("core_courseformat/local/content",["exports","core/reactive","core/utils" * @class core_courseformat/local/content * @copyright 2020 Ferran Recio * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_config=_interopRequireDefault(_config),_inplace_editable=_interopRequireDefault(_inplace_editable),_section=_interopRequireDefault(_section),_cmitem=_interopRequireDefault(_cmitem),_fragment=_interopRequireDefault(_fragment),_templates=_interopRequireDefault(_templates),_actions=_interopRequireDefault(_actions),CourseEvents=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(CourseEvents),_jquery=_interopRequireDefault(_jquery),_pending=_interopRequireDefault(_pending);class Component extends _reactive.BaseComponent{create(descriptor){var _descriptor$sectionRe;this.name="course_format",this.selectors={SECTION:"[data-for='section']",SECTION_ITEM:"[data-for='section_title']",SECTION_CMLIST:"[data-for='cmlist']",COURSE_SECTIONLIST:"[data-for='course_sectionlist']",CM:"[data-for='cmitem']",TOGGLER:'[data-action="togglecoursecontentsection"]',COLLAPSE:'[data-toggle="collapse"]',TOGGLEALL:'[data-toggle="toggleall"]',ACTIVITYTAG:"li",SECTIONTAG:"li"},this.selectorGenerators={cmNameFor:id=>"[data-cm-name-for='".concat(id,"']")},this.classes={COLLAPSED:"collapsed",ACTIVITY:"activity",STATEDREADY:"stateready",SECTION:"section"},this.dettachedCms={},this.dettachedSections={},this.sections={},this.cms={},this.sectionReturn=null!==(_descriptor$sectionRe=descriptor.sectionReturn)&&void 0!==_descriptor$sectionRe?_descriptor$sectionRe:null,this.debouncedReloads=new Map}static init(target,selectors,sectionReturn){return new Component({element:document.getElementById(target),reactive:(0,_courseeditor.getCurrentCourseEditor)(),selectors:selectors,sectionReturn:sectionReturn})}stateReady(state){this._indexContents(),this.addEventListener(this.element,"click",this._sectionTogglers);const toogleAll=this.getElement(this.selectors.TOGGLEALL);if(toogleAll){const collapseElementIds=[...this.getElements(this.selectors.COLLAPSE)].map((element=>element.id));toogleAll.setAttribute("aria-controls",collapseElementIds.join(" ")),this.addEventListener(toogleAll,"click",this._allSectionToggler),this.addEventListener(toogleAll,"keydown",(e=>{" "===e.key&&this._allSectionToggler(e)})),this._refreshAllSectionsToggler(state)}this.reactive.supportComponents&&(this.reactive.isEditing&&new _actions.default(this),this.element.classList.add(this.classes.STATEDREADY)),this.addEventListener(this.element,CourseEvents.manualCompletionToggled,this._completionHandler),this.addEventListener(document,"scroll",this._scrollHandler),setTimeout((()=>{this._scrollHandler()}),500)}_sectionTogglers(event){const sectionlink=event.target.closest(this.selectors.TOGGLER),closestCollapse=event.target.closest(this.selectors.COLLAPSE),isChevron=null==closestCollapse?void 0:closestCollapse.closest(this.selectors.SECTION_ITEM);if(sectionlink||isChevron){var _toggler$classList$co;const section=event.target.closest(this.selectors.SECTION),toggler=section.querySelector(this.selectors.COLLAPSE),isCollapsed=null!==(_toggler$classList$co=null==toggler?void 0:toggler.classList.contains(this.classes.COLLAPSED))&&void 0!==_toggler$classList$co&&_toggler$classList$co;if(isChevron||isCollapsed){const sectionId=section.getAttribute("data-id");this.reactive.dispatch("sectionContentCollapsed",[sectionId],!isCollapsed)}}}_allSectionToggler(event){var _course$sectionlist;event.preventDefault();const isAllCollapsed=event.target.closest(this.selectors.TOGGLEALL).classList.contains(this.classes.COLLAPSED),course=this.reactive.get("course");this.reactive.dispatch("sectionContentCollapsed",null!==(_course$sectionlist=course.sectionlist)&&void 0!==_course$sectionlist?_course$sectionlist:[],!isAllCollapsed)}getWatchers(){return this.reactive.sectionReturn=this.sectionReturn,this.reactive.supportComponents?[{watch:"cm.visible:updated",handler:this._reloadCm},{watch:"cm.stealth:updated",handler:this._reloadCm},{watch:"cm.sectionid:updated",handler:this._reloadCm},{watch:"cm.indent:updated",handler:this._reloadCm},{watch:"cm.groupmode:updated",handler:this._reloadCm},{watch:"cm.name:updated",handler:this._refreshCmName},{watch:"section.number:updated",handler:this._refreshSectionNumber},{watch:"section.contentcollapsed:updated",handler:this._refreshSectionCollapsed},{watch:"transaction:start",handler:this._startProcessing},{watch:"course.sectionlist:updated",handler:this._refreshCourseSectionlist},{watch:"section.cmlist:updated",handler:this._refreshSectionCmlist},{watch:"section.visible:updated",handler:this._reloadSection},{watch:"state:updated",handler:this._indexContents}]:[]}_refreshCmName(_ref){let{element:element}=_ref;this.getElements(this.selectorGenerators.cmNameFor(element.id)).forEach((cmNameFor=>{cmNameFor.textContent=element.name}))}_refreshSectionCollapsed(_ref2){var _toggler$classList$co2;let{state:state,element:element}=_ref2;const target=this.getElement(this.selectors.SECTION,element.id);if(!target)throw new Error("Unknown section with ID ".concat(element.id));const toggler=target.querySelector(this.selectors.COLLAPSE),isCollapsed=null!==(_toggler$classList$co2=null==toggler?void 0:toggler.classList.contains(this.classes.COLLAPSED))&&void 0!==_toggler$classList$co2&&_toggler$classList$co2;if(element.contentcollapsed!==isCollapsed){var _toggler$dataset$targ;let collapsibleId=null!==(_toggler$dataset$targ=toggler.dataset.target)&&void 0!==_toggler$dataset$targ?_toggler$dataset$targ:toggler.getAttribute("href");if(!collapsibleId)return;collapsibleId=collapsibleId.replace("#","");const collapsible=document.getElementById(collapsibleId);if(!collapsible)return;(0,_jquery.default)(collapsible).collapse(element.contentcollapsed?"hide":"show")}this._refreshAllSectionsToggler(state)}_refreshAllSectionsToggler(state){const target=this.getElement(this.selectors.TOGGLEALL);if(!target)return;let allcollapsed=!0,allexpanded=!0;state.section.forEach((section=>{allcollapsed=allcollapsed&§ion.contentcollapsed,allexpanded=allexpanded&&!section.contentcollapsed})),allcollapsed&&(target.classList.add(this.classes.COLLAPSED),target.setAttribute("aria-expanded",!1)),allexpanded&&(target.classList.remove(this.classes.COLLAPSED),target.setAttribute("aria-expanded",!0))}_startProcessing(){this.dettachedCms={},this.dettachedSections={}}_completionHandler(_ref3){let{detail:detail}=_ref3;void 0!==detail&&this.reactive.dispatch("cmCompletion",[detail.cmid],detail.completed)}_scrollHandler(){const pageOffset=window.scrollY,items=this.reactive.getExporter().allItemsArray(this.reactive.state);let pageItem=null;items.every((item=>{const index="section"===item.type?this.sections:this.cms;if(void 0===index[item.id])return!0;const element=index[item.id].element;return pageItem=item,pageOffset>=element.offsetTop})),pageItem&&this.reactive.dispatch("setPageItem",pageItem.type,pageItem.id)}_refreshSectionNumber(_ref4){let{element:element}=_ref4;const target=this.getElement(this.selectors.SECTION,element.id);if(!target)return;target.id="section-".concat(element.number),target.dataset.sectionid=element.number,target.dataset.number=element.number;const inplace=_inplace_editable.default.getInplaceEditable(target.querySelector(this.selectors.SECTION_ITEM));if(inplace){const currentvalue=inplace.getValue(),currentitemid=inplace.getItemId();""===inplace.getValue()&&(currentitemid!=element.id||currentvalue==element.rawtitle&&""!=element.rawtitle||inplace.setValue(element.rawtitle))}}_refreshSectionCmlist(_ref5){var _element$cmlist;let{element:element}=_ref5;const cmlist=null!==(_element$cmlist=element.cmlist)&&void 0!==_element$cmlist?_element$cmlist:[],section=this.getElement(this.selectors.SECTION,element.id),listparent=null==section?void 0:section.querySelector(this.selectors.SECTION_CMLIST),createCm=this._createCmItem.bind(this);listparent&&this._fixOrder(listparent,cmlist,this.selectors.CM,this.dettachedCms,createCm)}_refreshCourseSectionlist(_ref6){let{state:state}=_ref6;if(null!==this.reactive.sectionReturn)return;const sectionlist=this.reactive.getExporter().listedSectionIds(state),listparent=this.getElement(this.selectors.COURSE_SECTIONLIST),createSection=this._createSectionItem.bind(this);listparent&&this._fixOrder(listparent,sectionlist,this.selectors.SECTION,this.dettachedSections,createSection)}_indexContents(){this._scanIndex(this.selectors.SECTION,this.sections,(item=>new _section.default(item))),this._scanIndex(this.selectors.CM,this.cms,(item=>new _cmitem.default(item)))}_scanIndex(selector,index,creationhandler){this.getElements("".concat(selector,":not([data-indexed])")).forEach((item=>{var _item$dataset;null!=item&&null!==(_item$dataset=item.dataset)&&void 0!==_item$dataset&&_item$dataset.id&&(void 0!==index[item.dataset.id]&&index[item.dataset.id].unregister(),index[item.dataset.id]=creationhandler({...this,element:item}),item.dataset.indexed=!0)}))}_reloadCm(_ref7){let{element:element}=_ref7;if(!this.getElement(this.selectors.CM,element.id))return;this._getDebouncedReloadCm(element.id)()}_getDebouncedReloadCm(cmId){const pendingKey="courseformat/content:reloadCm_".concat(cmId);let debouncedReload=this.debouncedReloads.get(pendingKey);if(debouncedReload)return debouncedReload;return debouncedReload=(0,_utils.debounce)((()=>{var _this$reactive$sectio;const pendingReload=new _pending.default(pendingKey);this.debouncedReloads.delete(pendingKey);const cmitem=this.getElement(this.selectors.CM,cmId);if(!cmitem)return pendingReload.resolve();return _fragment.default.loadFragment("core_courseformat","cmitem",_config.default.courseContextId,{id:cmId,courseid:_config.default.courseId,sr:null!==(_this$reactive$sectio=this.reactive.sectionReturn)&&void 0!==_this$reactive$sectio?_this$reactive$sectio:null}).then(((html,js)=>document.contains(cmitem)?(_templates.default.replaceNode(cmitem,html,js),this._indexContents(),pendingReload.resolve(),!0):(pendingReload.resolve(),!1))).catch((()=>{pendingReload.resolve()})),pendingReload}),200,{cancel:!0,pending:!0}),this.debouncedReloads.set(pendingKey,debouncedReload),debouncedReload}_cancelDebouncedReloadCm(cmId){const pendingKey="courseformat/content:reloadCm_".concat(cmId),debouncedReload=this.debouncedReloads.get(pendingKey);debouncedReload&&(debouncedReload.cancel(),this.debouncedReloads.delete(pendingKey))}_reloadSection(_ref8){let{element:element}=_ref8;const pendingReload=new _pending.default("courseformat/content:reloadSection_".concat(element.id)),sectionitem=this.getElement(this.selectors.SECTION,element.id);if(sectionitem){var _this$reactive$sectio2;for(const cmId of element.cmlist)this._cancelDebouncedReloadCm(cmId);_fragment.default.loadFragment("core_courseformat","section",_config.default.courseContextId,{id:element.id,courseid:_config.default.courseId,sr:null!==(_this$reactive$sectio2=this.reactive.sectionReturn)&&void 0!==_this$reactive$sectio2?_this$reactive$sectio2:null}).then(((html,js)=>{_templates.default.replaceNode(sectionitem,html,js),this._indexContents(),pendingReload.resolve()})).catch((()=>{pendingReload.resolve()}))}}_createCmItem(container,cmid){const newItem=document.createElement(this.selectors.ACTIVITYTAG);return newItem.dataset.for="cmitem",newItem.dataset.id=cmid,newItem.id="module-".concat(cmid),newItem.classList.add(this.classes.ACTIVITY),container.append(newItem),this._reloadCm({element:this.reactive.get("cm",cmid)}),newItem}_createSectionItem(container,sectionid){const section=this.reactive.get("section",sectionid),newItem=document.createElement(this.selectors.SECTIONTAG);return newItem.dataset.for="section",newItem.dataset.id=sectionid,newItem.dataset.number=section.number,newItem.id="section-".concat(sectionid),newItem.classList.add(this.classes.SECTION),container.append(newItem),this._reloadSection({element:section}),newItem}async _fixOrder(container,neworder,selector,dettachedelements,createMethod){if(void 0===container)return;if(!neworder.length)return container.classList.add("hidden"),void(container.innerHTML="");let dndFakeActivity;for(container.classList.remove("hidden"),neworder.forEach(((itemid,index)=>{var _ref9,_this$getElement;let item=null!==(_ref9=null!==(_this$getElement=this.getElement(selector,itemid))&&void 0!==_this$getElement?_this$getElement:dettachedelements[itemid])&&void 0!==_ref9?_ref9:createMethod(container,itemid);if(void 0===item)return;const currentitem=container.children[index];void 0!==currentitem?currentitem!==item&&container.insertBefore(item,currentitem):container.append(item)}));container.children.length>neworder.length;){var _lastchild$classList;const lastchild=container.lastChild;var _lastchild$dataset$id,_lastchild$dataset;if(null!=lastchild&&null!==(_lastchild$classList=lastchild.classList)&&void 0!==_lastchild$classList&&_lastchild$classList.contains("dndupload-preview"))dndFakeActivity=lastchild;else dettachedelements[null!==(_lastchild$dataset$id=null==lastchild||null===(_lastchild$dataset=lastchild.dataset)||void 0===_lastchild$dataset?void 0:_lastchild$dataset.id)&&void 0!==_lastchild$dataset$id?_lastchild$dataset$id:0]=lastchild;container.removeChild(lastchild)}dndFakeActivity&&container.append(dndFakeActivity)}}return _exports.default=Component,_exports.default})); + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_config=_interopRequireDefault(_config),_inplace_editable=_interopRequireDefault(_inplace_editable),_section=_interopRequireDefault(_section),_cmitem=_interopRequireDefault(_cmitem),_fragment=_interopRequireDefault(_fragment),_templates=_interopRequireDefault(_templates),_actions=_interopRequireDefault(_actions),CourseEvents=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(CourseEvents),_jquery=_interopRequireDefault(_jquery),_pending=_interopRequireDefault(_pending);class Component extends _reactive.BaseComponent{create(descriptor){var _descriptor$sectionRe;this.name="course_format",this.selectors={SECTION:"[data-for='section']",SECTION_ITEM:"[data-for='section_title']",SECTION_CMLIST:"[data-for='cmlist']",COURSE_SECTIONLIST:"[data-for='course_sectionlist']",CM:"[data-for='cmitem']",TOGGLER:'[data-action="togglecoursecontentsection"]',COLLAPSE:'[data-toggle="collapse"]',TOGGLEALL:'[data-toggle="toggleall"]',ACTIVITYTAG:"li",SECTIONTAG:"li"},this.selectorGenerators={cmNameFor:id=>"[data-cm-name-for='".concat(id,"']"),sectionNameFor:id=>"[data-section-name-for='".concat(id,"']")},this.classes={COLLAPSED:"collapsed",ACTIVITY:"activity",STATEDREADY:"stateready",SECTION:"section"},this.dettachedCms={},this.dettachedSections={},this.sections={},this.cms={},this.sectionReturn=null!==(_descriptor$sectionRe=descriptor.sectionReturn)&&void 0!==_descriptor$sectionRe?_descriptor$sectionRe:null,this.debouncedReloads=new Map}static init(target,selectors,sectionReturn){return new Component({element:document.getElementById(target),reactive:(0,_courseeditor.getCurrentCourseEditor)(),selectors:selectors,sectionReturn:sectionReturn})}stateReady(state){this._indexContents(),this.addEventListener(this.element,"click",this._sectionTogglers);const toogleAll=this.getElement(this.selectors.TOGGLEALL);if(toogleAll){const collapseElementIds=[...this.getElements(this.selectors.COLLAPSE)].map((element=>element.id));toogleAll.setAttribute("aria-controls",collapseElementIds.join(" ")),this.addEventListener(toogleAll,"click",this._allSectionToggler),this.addEventListener(toogleAll,"keydown",(e=>{" "===e.key&&this._allSectionToggler(e)})),this._refreshAllSectionsToggler(state)}this.reactive.supportComponents&&(this.reactive.isEditing&&new _actions.default(this),this.element.classList.add(this.classes.STATEDREADY)),this.addEventListener(this.element,CourseEvents.manualCompletionToggled,this._completionHandler),this.addEventListener(document,"scroll",this._scrollHandler),setTimeout((()=>{this._scrollHandler()}),500)}_sectionTogglers(event){const sectionlink=event.target.closest(this.selectors.TOGGLER),closestCollapse=event.target.closest(this.selectors.COLLAPSE),isChevron=null==closestCollapse?void 0:closestCollapse.closest(this.selectors.SECTION_ITEM);if(sectionlink||isChevron){var _toggler$classList$co;const section=event.target.closest(this.selectors.SECTION),toggler=section.querySelector(this.selectors.COLLAPSE),isCollapsed=null!==(_toggler$classList$co=null==toggler?void 0:toggler.classList.contains(this.classes.COLLAPSED))&&void 0!==_toggler$classList$co&&_toggler$classList$co;if(isChevron||isCollapsed){const sectionId=section.getAttribute("data-id");this.reactive.dispatch("sectionContentCollapsed",[sectionId],!isCollapsed)}}}_allSectionToggler(event){var _course$sectionlist;event.preventDefault();const isAllCollapsed=event.target.closest(this.selectors.TOGGLEALL).classList.contains(this.classes.COLLAPSED),course=this.reactive.get("course");this.reactive.dispatch("sectionContentCollapsed",null!==(_course$sectionlist=course.sectionlist)&&void 0!==_course$sectionlist?_course$sectionlist:[],!isAllCollapsed)}getWatchers(){return this.reactive.sectionReturn=this.sectionReturn,this.reactive.supportComponents?[{watch:"cm.visible:updated",handler:this._reloadCm},{watch:"cm.stealth:updated",handler:this._reloadCm},{watch:"cm.sectionid:updated",handler:this._reloadCm},{watch:"cm.indent:updated",handler:this._reloadCm},{watch:"cm.groupmode:updated",handler:this._reloadCm},{watch:"cm.name:updated",handler:this._refreshCmName},{watch:"section.number:updated",handler:this._refreshSectionNumber},{watch:"section.title:updated",handler:this._refreshSectionTitle},{watch:"section.contentcollapsed:updated",handler:this._refreshSectionCollapsed},{watch:"transaction:start",handler:this._startProcessing},{watch:"course.sectionlist:updated",handler:this._refreshCourseSectionlist},{watch:"section.cmlist:updated",handler:this._refreshSectionCmlist},{watch:"section.visible:updated",handler:this._reloadSection},{watch:"state:updated",handler:this._indexContents}]:[]}_refreshCmName(_ref){let{element:element}=_ref;this.getElements(this.selectorGenerators.cmNameFor(element.id)).forEach((cmNameFor=>{cmNameFor.textContent=element.name}))}_refreshSectionCollapsed(_ref2){var _toggler$classList$co2;let{state:state,element:element}=_ref2;const target=this.getElement(this.selectors.SECTION,element.id);if(!target)throw new Error("Unknown section with ID ".concat(element.id));const toggler=target.querySelector(this.selectors.COLLAPSE),isCollapsed=null!==(_toggler$classList$co2=null==toggler?void 0:toggler.classList.contains(this.classes.COLLAPSED))&&void 0!==_toggler$classList$co2&&_toggler$classList$co2;if(element.contentcollapsed!==isCollapsed){var _toggler$dataset$targ;let collapsibleId=null!==(_toggler$dataset$targ=toggler.dataset.target)&&void 0!==_toggler$dataset$targ?_toggler$dataset$targ:toggler.getAttribute("href");if(!collapsibleId)return;collapsibleId=collapsibleId.replace("#","");const collapsible=document.getElementById(collapsibleId);if(!collapsible)return;(0,_jquery.default)(collapsible).collapse(element.contentcollapsed?"hide":"show")}this._refreshAllSectionsToggler(state)}_refreshAllSectionsToggler(state){const target=this.getElement(this.selectors.TOGGLEALL);if(!target)return;let allcollapsed=!0,allexpanded=!0;state.section.forEach((section=>{allcollapsed=allcollapsed&§ion.contentcollapsed,allexpanded=allexpanded&&!section.contentcollapsed})),allcollapsed&&(target.classList.add(this.classes.COLLAPSED),target.setAttribute("aria-expanded",!1)),allexpanded&&(target.classList.remove(this.classes.COLLAPSED),target.setAttribute("aria-expanded",!0))}_startProcessing(){this.dettachedCms={},this.dettachedSections={}}_completionHandler(_ref3){let{detail:detail}=_ref3;void 0!==detail&&this.reactive.dispatch("cmCompletion",[detail.cmid],detail.completed)}_scrollHandler(){const pageOffset=window.scrollY,items=this.reactive.getExporter().allItemsArray(this.reactive.state);let pageItem=null;items.every((item=>{const index="section"===item.type?this.sections:this.cms;if(void 0===index[item.id])return!0;const element=index[item.id].element;return pageItem=item,pageOffset>=element.offsetTop})),pageItem&&this.reactive.dispatch("setPageItem",pageItem.type,pageItem.id)}_refreshSectionNumber(_ref4){let{element:element}=_ref4;const target=this.getElement(this.selectors.SECTION,element.id);if(!target)return;target.id="section-".concat(element.number),target.dataset.sectionid=element.number,target.dataset.number=element.number;const inplace=_inplace_editable.default.getInplaceEditable(target.querySelector(this.selectors.SECTION_ITEM));if(inplace){const currentvalue=inplace.getValue(),currentitemid=inplace.getItemId();""===inplace.getValue()&&(currentitemid!=element.id||currentvalue==element.rawtitle&&""!=element.rawtitle||inplace.setValue(element.rawtitle))}}_refreshSectionTitle(_ref5){let{element:element}=_ref5;document.querySelectorAll(this.selectorGenerators.sectionNameFor(element.id)).forEach((sectionNameFor=>{sectionNameFor.textContent=element.title}))}_refreshSectionCmlist(_ref6){var _element$cmlist;let{element:element}=_ref6;const cmlist=null!==(_element$cmlist=element.cmlist)&&void 0!==_element$cmlist?_element$cmlist:[],section=this.getElement(this.selectors.SECTION,element.id),listparent=null==section?void 0:section.querySelector(this.selectors.SECTION_CMLIST),createCm=this._createCmItem.bind(this);listparent&&this._fixOrder(listparent,cmlist,this.selectors.CM,this.dettachedCms,createCm)}_refreshCourseSectionlist(_ref7){let{state:state}=_ref7;if(null!==this.reactive.sectionReturn)return;const sectionlist=this.reactive.getExporter().listedSectionIds(state),listparent=this.getElement(this.selectors.COURSE_SECTIONLIST),createSection=this._createSectionItem.bind(this);listparent&&this._fixOrder(listparent,sectionlist,this.selectors.SECTION,this.dettachedSections,createSection)}_indexContents(){this._scanIndex(this.selectors.SECTION,this.sections,(item=>new _section.default(item))),this._scanIndex(this.selectors.CM,this.cms,(item=>new _cmitem.default(item)))}_scanIndex(selector,index,creationhandler){this.getElements("".concat(selector,":not([data-indexed])")).forEach((item=>{var _item$dataset;null!=item&&null!==(_item$dataset=item.dataset)&&void 0!==_item$dataset&&_item$dataset.id&&(void 0!==index[item.dataset.id]&&index[item.dataset.id].unregister(),index[item.dataset.id]=creationhandler({...this,element:item}),item.dataset.indexed=!0)}))}_reloadCm(_ref8){let{element:element}=_ref8;if(!this.getElement(this.selectors.CM,element.id))return;this._getDebouncedReloadCm(element.id)()}_getDebouncedReloadCm(cmId){const pendingKey="courseformat/content:reloadCm_".concat(cmId);let debouncedReload=this.debouncedReloads.get(pendingKey);if(debouncedReload)return debouncedReload;return debouncedReload=(0,_utils.debounce)((()=>{var _this$reactive$sectio;const pendingReload=new _pending.default(pendingKey);this.debouncedReloads.delete(pendingKey);const cmitem=this.getElement(this.selectors.CM,cmId);if(!cmitem)return pendingReload.resolve();return _fragment.default.loadFragment("core_courseformat","cmitem",_config.default.courseContextId,{id:cmId,courseid:_config.default.courseId,sr:null!==(_this$reactive$sectio=this.reactive.sectionReturn)&&void 0!==_this$reactive$sectio?_this$reactive$sectio:null}).then(((html,js)=>document.contains(cmitem)?(_templates.default.replaceNode(cmitem,html,js),this._indexContents(),pendingReload.resolve(),!0):(pendingReload.resolve(),!1))).catch((()=>{pendingReload.resolve()})),pendingReload}),200,{cancel:!0,pending:!0}),this.debouncedReloads.set(pendingKey,debouncedReload),debouncedReload}_cancelDebouncedReloadCm(cmId){const pendingKey="courseformat/content:reloadCm_".concat(cmId),debouncedReload=this.debouncedReloads.get(pendingKey);debouncedReload&&(debouncedReload.cancel(),this.debouncedReloads.delete(pendingKey))}_reloadSection(_ref9){let{element:element}=_ref9;const pendingReload=new _pending.default("courseformat/content:reloadSection_".concat(element.id)),sectionitem=this.getElement(this.selectors.SECTION,element.id);if(sectionitem){var _this$reactive$sectio2;for(const cmId of element.cmlist)this._cancelDebouncedReloadCm(cmId);_fragment.default.loadFragment("core_courseformat","section",_config.default.courseContextId,{id:element.id,courseid:_config.default.courseId,sr:null!==(_this$reactive$sectio2=this.reactive.sectionReturn)&&void 0!==_this$reactive$sectio2?_this$reactive$sectio2:null}).then(((html,js)=>{_templates.default.replaceNode(sectionitem,html,js),this._indexContents(),pendingReload.resolve()})).catch((()=>{pendingReload.resolve()}))}}_createCmItem(container,cmid){const newItem=document.createElement(this.selectors.ACTIVITYTAG);return newItem.dataset.for="cmitem",newItem.dataset.id=cmid,newItem.id="module-".concat(cmid),newItem.classList.add(this.classes.ACTIVITY),container.append(newItem),this._reloadCm({element:this.reactive.get("cm",cmid)}),newItem}_createSectionItem(container,sectionid){const section=this.reactive.get("section",sectionid),newItem=document.createElement(this.selectors.SECTIONTAG);return newItem.dataset.for="section",newItem.dataset.id=sectionid,newItem.dataset.number=section.number,newItem.id="section-".concat(sectionid),newItem.classList.add(this.classes.SECTION),container.append(newItem),this._reloadSection({element:section}),newItem}async _fixOrder(container,neworder,selector,dettachedelements,createMethod){if(void 0===container)return;if(!neworder.length)return container.classList.add("hidden"),void(container.innerHTML="");let dndFakeActivity;for(container.classList.remove("hidden"),neworder.forEach(((itemid,index)=>{var _ref10,_this$getElement;let item=null!==(_ref10=null!==(_this$getElement=this.getElement(selector,itemid))&&void 0!==_this$getElement?_this$getElement:dettachedelements[itemid])&&void 0!==_ref10?_ref10:createMethod(container,itemid);if(void 0===item)return;const currentitem=container.children[index];void 0!==currentitem?currentitem!==item&&container.insertBefore(item,currentitem):container.append(item)}));container.children.length>neworder.length;){var _lastchild$classList;const lastchild=container.lastChild;var _lastchild$dataset$id,_lastchild$dataset;if(null!=lastchild&&null!==(_lastchild$classList=lastchild.classList)&&void 0!==_lastchild$classList&&_lastchild$classList.contains("dndupload-preview"))dndFakeActivity=lastchild;else dettachedelements[null!==(_lastchild$dataset$id=null==lastchild||null===(_lastchild$dataset=lastchild.dataset)||void 0===_lastchild$dataset?void 0:_lastchild$dataset.id)&&void 0!==_lastchild$dataset$id?_lastchild$dataset$id:0]=lastchild;container.removeChild(lastchild)}dndFakeActivity&&container.append(dndFakeActivity)}}return _exports.default=Component,_exports.default})); //# sourceMappingURL=content.min.js.map \ No newline at end of file diff --git a/course/format/amd/build/local/content.min.js.map b/course/format/amd/build/local/content.min.js.map index f57ddfbefeb45..095d4abd4748e 100644 --- a/course/format/amd/build/local/content.min.js.map +++ b/course/format/amd/build/local/content.min.js.map @@ -1 +1 @@ -{"version":3,"file":"content.min.js","sources":["../../src/local/content.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Course index main component.\n *\n * @module core_courseformat/local/content\n * @class core_courseformat/local/content\n * @copyright 2020 Ferran Recio \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {BaseComponent} from 'core/reactive';\nimport {debounce} from 'core/utils';\nimport {getCurrentCourseEditor} from 'core_courseformat/courseeditor';\nimport Config from 'core/config';\nimport inplaceeditable from 'core/inplace_editable';\nimport Section from 'core_courseformat/local/content/section';\nimport CmItem from 'core_courseformat/local/content/section/cmitem';\nimport Fragment from 'core/fragment';\nimport Templates from 'core/templates';\nimport DispatchActions from 'core_courseformat/local/content/actions';\nimport * as CourseEvents from 'core_course/events';\n// The jQuery module is only used for interacting with Boostrap 4. It can we removed when MDL-71979 is integrated.\nimport jQuery from 'jquery';\nimport Pending from 'core/pending';\n\nexport default class Component extends BaseComponent {\n\n /**\n * Constructor hook.\n *\n * @param {Object} descriptor the component descriptor\n */\n create(descriptor) {\n // Optional component name for debugging.\n this.name = 'course_format';\n // Default query selectors.\n this.selectors = {\n SECTION: `[data-for='section']`,\n SECTION_ITEM: `[data-for='section_title']`,\n SECTION_CMLIST: `[data-for='cmlist']`,\n COURSE_SECTIONLIST: `[data-for='course_sectionlist']`,\n CM: `[data-for='cmitem']`,\n TOGGLER: `[data-action=\"togglecoursecontentsection\"]`,\n COLLAPSE: `[data-toggle=\"collapse\"]`,\n TOGGLEALL: `[data-toggle=\"toggleall\"]`,\n // Formats can override the activity tag but a default one is needed to create new elements.\n ACTIVITYTAG: 'li',\n SECTIONTAG: 'li',\n };\n this.selectorGenerators = {\n cmNameFor: (id) => `[data-cm-name-for='${id}']`,\n };\n // Default classes to toggle on refresh.\n this.classes = {\n COLLAPSED: `collapsed`,\n // Course content classes.\n ACTIVITY: `activity`,\n STATEDREADY: `stateready`,\n SECTION: `section`,\n };\n // Array to save dettached elements during element resorting.\n this.dettachedCms = {};\n this.dettachedSections = {};\n // Index of sections and cms components.\n this.sections = {};\n this.cms = {};\n // The page section return.\n this.sectionReturn = descriptor.sectionReturn ?? null;\n this.debouncedReloads = new Map();\n }\n\n /**\n * Static method to create a component instance form the mustahce template.\n *\n * @param {string} target the DOM main element or its ID\n * @param {object} selectors optional css selector overrides\n * @param {number} sectionReturn the content section return\n * @return {Component}\n */\n static init(target, selectors, sectionReturn) {\n return new Component({\n element: document.getElementById(target),\n reactive: getCurrentCourseEditor(),\n selectors,\n sectionReturn,\n });\n }\n\n /**\n * Initial state ready method.\n *\n * @param {Object} state the state data\n */\n stateReady(state) {\n this._indexContents();\n // Activate section togglers.\n this.addEventListener(this.element, 'click', this._sectionTogglers);\n\n // Collapse/Expand all sections button.\n const toogleAll = this.getElement(this.selectors.TOGGLEALL);\n if (toogleAll) {\n\n // Ensure collapse menu button adds aria-controls attribute referring to each collapsible element.\n const collapseElements = this.getElements(this.selectors.COLLAPSE);\n const collapseElementIds = [...collapseElements].map(element => element.id);\n toogleAll.setAttribute('aria-controls', collapseElementIds.join(' '));\n\n this.addEventListener(toogleAll, 'click', this._allSectionToggler);\n this.addEventListener(toogleAll, 'keydown', e => {\n // Collapse/expand all sections when Space key is pressed on the toggle button.\n if (e.key === ' ') {\n this._allSectionToggler(e);\n }\n });\n this._refreshAllSectionsToggler(state);\n }\n\n if (this.reactive.supportComponents) {\n // Actions are only available in edit mode.\n if (this.reactive.isEditing) {\n new DispatchActions(this);\n }\n\n // Mark content as state ready.\n this.element.classList.add(this.classes.STATEDREADY);\n }\n\n // Capture completion events.\n this.addEventListener(\n this.element,\n CourseEvents.manualCompletionToggled,\n this._completionHandler\n );\n\n // Capture page scroll to update page item.\n this.addEventListener(\n document,\n \"scroll\",\n this._scrollHandler\n );\n setTimeout(() => {\n this._scrollHandler();\n }, 500);\n }\n\n /**\n * Setup sections toggler.\n *\n * Toggler click is delegated to the main course content element because new sections can\n * appear at any moment and this way we prevent accidental double bindings.\n *\n * @param {Event} event the triggered event\n */\n _sectionTogglers(event) {\n const sectionlink = event.target.closest(this.selectors.TOGGLER);\n const closestCollapse = event.target.closest(this.selectors.COLLAPSE);\n // Assume that chevron is the only collapse toggler in a section heading;\n // I think this is the most efficient way to verify at the moment.\n const isChevron = closestCollapse?.closest(this.selectors.SECTION_ITEM);\n\n if (sectionlink || isChevron) {\n\n const section = event.target.closest(this.selectors.SECTION);\n const toggler = section.querySelector(this.selectors.COLLAPSE);\n const isCollapsed = toggler?.classList.contains(this.classes.COLLAPSED) ?? false;\n\n if (isChevron || isCollapsed) {\n // Update the state.\n const sectionId = section.getAttribute('data-id');\n this.reactive.dispatch(\n 'sectionContentCollapsed',\n [sectionId],\n !isCollapsed\n );\n }\n }\n }\n\n /**\n * Handle the collapse/expand all sections button.\n *\n * Toggler click is delegated to the main course content element because new sections can\n * appear at any moment and this way we prevent accidental double bindings.\n *\n * @param {Event} event the triggered event\n */\n _allSectionToggler(event) {\n event.preventDefault();\n\n const target = event.target.closest(this.selectors.TOGGLEALL);\n const isAllCollapsed = target.classList.contains(this.classes.COLLAPSED);\n\n const course = this.reactive.get('course');\n this.reactive.dispatch(\n 'sectionContentCollapsed',\n course.sectionlist ?? [],\n !isAllCollapsed\n );\n }\n\n /**\n * Return the component watchers.\n *\n * @returns {Array} of watchers\n */\n getWatchers() {\n // Section return is a global page variable but most formats define it just before start printing\n // the course content. This is the reason why we define this page setting here.\n this.reactive.sectionReturn = this.sectionReturn;\n\n // Check if the course format is compatible with reactive components.\n if (!this.reactive.supportComponents) {\n return [];\n }\n return [\n // State changes that require to reload some course modules.\n {watch: `cm.visible:updated`, handler: this._reloadCm},\n {watch: `cm.stealth:updated`, handler: this._reloadCm},\n {watch: `cm.sectionid:updated`, handler: this._reloadCm},\n {watch: `cm.indent:updated`, handler: this._reloadCm},\n {watch: `cm.groupmode:updated`, handler: this._reloadCm},\n {watch: `cm.name:updated`, handler: this._refreshCmName},\n // Update section number and title.\n {watch: `section.number:updated`, handler: this._refreshSectionNumber},\n // Collapse and expand sections.\n {watch: `section.contentcollapsed:updated`, handler: this._refreshSectionCollapsed},\n // Sections and cm sorting.\n {watch: `transaction:start`, handler: this._startProcessing},\n {watch: `course.sectionlist:updated`, handler: this._refreshCourseSectionlist},\n {watch: `section.cmlist:updated`, handler: this._refreshSectionCmlist},\n // Section visibility.\n {watch: `section.visible:updated`, handler: this._reloadSection},\n // Reindex sections and cms.\n {watch: `state:updated`, handler: this._indexContents},\n ];\n }\n\n /**\n * Update a course module name on the whole page.\n *\n * @param {object} param\n * @param {Object} param.element details the update details.\n */\n _refreshCmName({element}) {\n // Update classes.\n // Replace the text content of the cm name.\n const allCmNamesFor = this.getElements(\n this.selectorGenerators.cmNameFor(element.id)\n );\n allCmNamesFor.forEach((cmNameFor) => {\n cmNameFor.textContent = element.name;\n });\n }\n\n /**\n * Update section collapsed state via bootstrap 4 if necessary.\n *\n * Formats that do not use bootstrap 4 must override this method in order to keep the section\n * toggling working.\n *\n * @param {object} args\n * @param {Object} args.state The state data\n * @param {Object} args.element The element to update\n */\n _refreshSectionCollapsed({state, element}) {\n const target = this.getElement(this.selectors.SECTION, element.id);\n if (!target) {\n throw new Error(`Unknown section with ID ${element.id}`);\n }\n // Check if it is already done.\n const toggler = target.querySelector(this.selectors.COLLAPSE);\n const isCollapsed = toggler?.classList.contains(this.classes.COLLAPSED) ?? false;\n\n if (element.contentcollapsed !== isCollapsed) {\n let collapsibleId = toggler.dataset.target ?? toggler.getAttribute(\"href\");\n if (!collapsibleId) {\n return;\n }\n collapsibleId = collapsibleId.replace('#', '');\n const collapsible = document.getElementById(collapsibleId);\n if (!collapsible) {\n return;\n }\n\n // Course index is based on Bootstrap 4 collapsibles. To collapse them we need jQuery to\n // interact with collapsibles methods. Hopefully, this will change in Bootstrap 5 because\n // it does not require jQuery anymore (when MDL-71979 is integrated).\n jQuery(collapsible).collapse(element.contentcollapsed ? 'hide' : 'show');\n }\n\n this._refreshAllSectionsToggler(state);\n }\n\n /**\n * Refresh the collapse/expand all sections element.\n *\n * @param {Object} state The state data\n */\n _refreshAllSectionsToggler(state) {\n const target = this.getElement(this.selectors.TOGGLEALL);\n if (!target) {\n return;\n }\n // Check if we have all sections collapsed/expanded.\n let allcollapsed = true;\n let allexpanded = true;\n state.section.forEach(\n section => {\n allcollapsed = allcollapsed && section.contentcollapsed;\n allexpanded = allexpanded && !section.contentcollapsed;\n }\n );\n if (allcollapsed) {\n target.classList.add(this.classes.COLLAPSED);\n target.setAttribute('aria-expanded', false);\n }\n if (allexpanded) {\n target.classList.remove(this.classes.COLLAPSED);\n target.setAttribute('aria-expanded', true);\n }\n }\n\n /**\n * Setup the component to start a transaction.\n *\n * Some of the course actions replaces the current DOM element with a new one before updating the\n * course state. This means the component cannot preload any index properly until the transaction starts.\n *\n */\n _startProcessing() {\n // During a section or cm sorting, some elements could be dettached from the DOM and we\n // need to store somewhare in case they are needed later.\n this.dettachedCms = {};\n this.dettachedSections = {};\n }\n\n /**\n * Activity manual completion listener.\n *\n * @param {Event} event the custom ecent\n */\n _completionHandler({detail}) {\n if (detail === undefined) {\n return;\n }\n this.reactive.dispatch('cmCompletion', [detail.cmid], detail.completed);\n }\n\n /**\n * Check the current page scroll and update the active element if necessary.\n */\n _scrollHandler() {\n const pageOffset = window.scrollY;\n const items = this.reactive.getExporter().allItemsArray(this.reactive.state);\n // Check what is the active element now.\n let pageItem = null;\n items.every(item => {\n const index = (item.type === 'section') ? this.sections : this.cms;\n if (index[item.id] === undefined) {\n return true;\n }\n\n const element = index[item.id].element;\n pageItem = item;\n return pageOffset >= element.offsetTop;\n });\n if (pageItem) {\n this.reactive.dispatch('setPageItem', pageItem.type, pageItem.id);\n }\n }\n\n /**\n * Update a course section when the section number changes.\n *\n * The courseActions module used for most course section tools still depends on css classes and\n * section numbers (not id). To prevent inconsistencies when a section is moved, we need to refresh\n * the\n *\n * Course formats can override the section title rendering so the frontend depends heavily on backend\n * rendering. Luckily in edit mode we can trigger a title update using the inplace_editable module.\n *\n * @param {Object} param\n * @param {Object} param.element details the update details.\n */\n _refreshSectionNumber({element}) {\n // Find the element.\n const target = this.getElement(this.selectors.SECTION, element.id);\n if (!target) {\n // Job done. Nothing to refresh.\n return;\n }\n // Update section numbers in all data, css and YUI attributes.\n target.id = `section-${element.number}`;\n // YUI uses section number as section id in data-sectionid, in principle if a format use components\n // don't need this sectionid attribute anymore, but we keep the compatibility in case some plugin\n // use it for legacy purposes.\n target.dataset.sectionid = element.number;\n // The data-number is the attribute used by components to store the section number.\n target.dataset.number = element.number;\n\n // Update title and title inplace editable, if any.\n const inplace = inplaceeditable.getInplaceEditable(target.querySelector(this.selectors.SECTION_ITEM));\n if (inplace) {\n // The course content HTML can be modified at any moment, so the function need to do some checkings\n // to make sure the inplace editable still represents the same itemid.\n const currentvalue = inplace.getValue();\n const currentitemid = inplace.getItemId();\n // Unnamed sections must be recalculated.\n if (inplace.getValue() === '') {\n // The value to send can be an empty value if it is a default name.\n if (currentitemid == element.id && (currentvalue != element.rawtitle || element.rawtitle == '')) {\n inplace.setValue(element.rawtitle);\n }\n }\n }\n }\n\n /**\n * Refresh a section cm list.\n *\n * @param {Object} param\n * @param {Object} param.element details the update details.\n */\n _refreshSectionCmlist({element}) {\n const cmlist = element.cmlist ?? [];\n const section = this.getElement(this.selectors.SECTION, element.id);\n const listparent = section?.querySelector(this.selectors.SECTION_CMLIST);\n // A method to create a fake element to be replaced when the item is ready.\n const createCm = this._createCmItem.bind(this);\n if (listparent) {\n this._fixOrder(listparent, cmlist, this.selectors.CM, this.dettachedCms, createCm);\n }\n }\n\n /**\n * Refresh the section list.\n *\n * @param {Object} param\n * @param {Object} param.state the full state object.\n */\n _refreshCourseSectionlist({state}) {\n // If we have a section return means we only show a single section so no need to fix order.\n if (this.reactive.sectionReturn !== null) {\n return;\n }\n const sectionlist = this.reactive.getExporter().listedSectionIds(state);\n const listparent = this.getElement(this.selectors.COURSE_SECTIONLIST);\n // For now section cannot be created at a frontend level.\n const createSection = this._createSectionItem.bind(this);\n if (listparent) {\n this._fixOrder(listparent, sectionlist, this.selectors.SECTION, this.dettachedSections, createSection);\n }\n }\n\n /**\n * Regenerate content indexes.\n *\n * This method is used when a legacy action refresh some content element.\n */\n _indexContents() {\n // Find unindexed sections.\n this._scanIndex(\n this.selectors.SECTION,\n this.sections,\n (item) => {\n return new Section(item);\n }\n );\n\n // Find unindexed cms.\n this._scanIndex(\n this.selectors.CM,\n this.cms,\n (item) => {\n return new CmItem(item);\n }\n );\n }\n\n /**\n * Reindex a content (section or cm) of the course content.\n *\n * This method is used internally by _indexContents.\n *\n * @param {string} selector the DOM selector to scan\n * @param {*} index the index attribute to update\n * @param {*} creationhandler method to create a new indexed element\n */\n _scanIndex(selector, index, creationhandler) {\n const items = this.getElements(`${selector}:not([data-indexed])`);\n items.forEach((item) => {\n if (!item?.dataset?.id) {\n return;\n }\n // Delete previous item component.\n if (index[item.dataset.id] !== undefined) {\n index[item.dataset.id].unregister();\n }\n // Create the new component.\n index[item.dataset.id] = creationhandler({\n ...this,\n element: item,\n });\n // Mark as indexed.\n item.dataset.indexed = true;\n });\n }\n\n /**\n * Reload a course module contents.\n *\n * Most course module HTML is still strongly backend dependant.\n * Some changes require to get a new version of the module.\n *\n * @param {object} param0 the watcher details\n * @param {object} param0.element the state object\n */\n _reloadCm({element}) {\n if (!this.getElement(this.selectors.CM, element.id)) {\n return;\n }\n const debouncedReload = this._getDebouncedReloadCm(element.id);\n debouncedReload();\n }\n\n /**\n * Generate or get a reload CM debounced function.\n * @param {Number} cmId\n * @returns {Function} the debounced reload function\n */\n _getDebouncedReloadCm(cmId) {\n const pendingKey = `courseformat/content:reloadCm_${cmId}`;\n let debouncedReload = this.debouncedReloads.get(pendingKey);\n if (debouncedReload) {\n return debouncedReload;\n }\n const reload = () => {\n const pendingReload = new Pending(pendingKey);\n this.debouncedReloads.delete(pendingKey);\n const cmitem = this.getElement(this.selectors.CM, cmId);\n if (!cmitem) {\n return pendingReload.resolve();\n }\n const promise = Fragment.loadFragment(\n 'core_courseformat',\n 'cmitem',\n Config.courseContextId,\n {\n id: cmId,\n courseid: Config.courseId,\n sr: this.reactive.sectionReturn ?? null,\n }\n );\n promise.then((html, js) => {\n // Other state change can reload the CM or the section before this one.\n if (!document.contains(cmitem)) {\n pendingReload.resolve();\n return false;\n }\n Templates.replaceNode(cmitem, html, js);\n this._indexContents();\n pendingReload.resolve();\n return true;\n }).catch(() => {\n pendingReload.resolve();\n });\n return pendingReload;\n };\n debouncedReload = debounce(\n reload,\n 200,\n {\n cancel: true, pending: true\n }\n );\n this.debouncedReloads.set(pendingKey, debouncedReload);\n return debouncedReload;\n }\n\n /**\n * Cancel the active reload CM debounced function, if any.\n * @param {Number} cmId\n */\n _cancelDebouncedReloadCm(cmId) {\n const pendingKey = `courseformat/content:reloadCm_${cmId}`;\n const debouncedReload = this.debouncedReloads.get(pendingKey);\n if (!debouncedReload) {\n return;\n }\n debouncedReload.cancel();\n this.debouncedReloads.delete(pendingKey);\n }\n\n /**\n * Reload a course section contents.\n *\n * Section HTML is still strongly backend dependant.\n * Some changes require to get a new version of the section.\n *\n * @param {details} param0 the watcher details\n * @param {object} param0.element the state object\n */\n _reloadSection({element}) {\n const pendingReload = new Pending(`courseformat/content:reloadSection_${element.id}`);\n const sectionitem = this.getElement(this.selectors.SECTION, element.id);\n if (sectionitem) {\n // Cancel any pending reload because the section will reload cms too.\n for (const cmId of element.cmlist) {\n this._cancelDebouncedReloadCm(cmId);\n }\n const promise = Fragment.loadFragment(\n 'core_courseformat',\n 'section',\n Config.courseContextId,\n {\n id: element.id,\n courseid: Config.courseId,\n sr: this.reactive.sectionReturn ?? null,\n }\n );\n promise.then((html, js) => {\n Templates.replaceNode(sectionitem, html, js);\n this._indexContents();\n pendingReload.resolve();\n }).catch(() => {\n pendingReload.resolve();\n });\n }\n }\n\n /**\n * Create a new course module item in a section.\n *\n * Thos method will append a fake item in the container and trigger an ajax request to\n * replace the fake element by the real content.\n *\n * @param {Element} container the container element (section)\n * @param {Number} cmid the course-module ID\n * @returns {Element} the created element\n */\n _createCmItem(container, cmid) {\n const newItem = document.createElement(this.selectors.ACTIVITYTAG);\n newItem.dataset.for = 'cmitem';\n newItem.dataset.id = cmid;\n // The legacy actions.js requires a specific ID and class to refresh the CM.\n newItem.id = `module-${cmid}`;\n newItem.classList.add(this.classes.ACTIVITY);\n container.append(newItem);\n this._reloadCm({\n element: this.reactive.get('cm', cmid),\n });\n return newItem;\n }\n\n /**\n * Create a new section item.\n *\n * This method will append a fake item in the container and trigger an ajax request to\n * replace the fake element by the real content.\n *\n * @param {Element} container the container element (section)\n * @param {Number} sectionid the course-module ID\n * @returns {Element} the created element\n */\n _createSectionItem(container, sectionid) {\n const section = this.reactive.get('section', sectionid);\n const newItem = document.createElement(this.selectors.SECTIONTAG);\n newItem.dataset.for = 'section';\n newItem.dataset.id = sectionid;\n newItem.dataset.number = section.number;\n // The legacy actions.js requires a specific ID and class to refresh the section.\n newItem.id = `section-${sectionid}`;\n newItem.classList.add(this.classes.SECTION);\n container.append(newItem);\n this._reloadSection({\n element: section,\n });\n return newItem;\n }\n\n /**\n * Fix/reorder the section or cms order.\n *\n * @param {Element} container the HTML element to reorder.\n * @param {Array} neworder an array with the ids order\n * @param {string} selector the element selector\n * @param {Object} dettachedelements a list of dettached elements\n * @param {function} createMethod method to create missing elements\n */\n async _fixOrder(container, neworder, selector, dettachedelements, createMethod) {\n if (container === undefined) {\n return;\n }\n\n // Empty lists should not be visible.\n if (!neworder.length) {\n container.classList.add('hidden');\n container.innerHTML = '';\n return;\n }\n\n // Grant the list is visible (in case it was empty).\n container.classList.remove('hidden');\n\n // Move the elements in order at the beginning of the list.\n neworder.forEach((itemid, index) => {\n let item = this.getElement(selector, itemid) ?? dettachedelements[itemid] ?? createMethod(container, itemid);\n if (item === undefined) {\n // Missing elements cannot be sorted.\n return;\n }\n // Get the current elemnt at that position.\n const currentitem = container.children[index];\n if (currentitem === undefined) {\n container.append(item);\n return;\n }\n if (currentitem !== item) {\n container.insertBefore(item, currentitem);\n }\n });\n\n // Dndupload add a fake element we need to keep.\n let dndFakeActivity;\n\n // Remove the remaining elements.\n while (container.children.length > neworder.length) {\n const lastchild = container.lastChild;\n if (lastchild?.classList?.contains('dndupload-preview')) {\n dndFakeActivity = lastchild;\n } else {\n dettachedelements[lastchild?.dataset?.id ?? 0] = lastchild;\n }\n container.removeChild(lastchild);\n }\n // Restore dndupload fake element.\n if (dndFakeActivity) {\n container.append(dndFakeActivity);\n }\n }\n}\n"],"names":["Component","BaseComponent","create","descriptor","name","selectors","SECTION","SECTION_ITEM","SECTION_CMLIST","COURSE_SECTIONLIST","CM","TOGGLER","COLLAPSE","TOGGLEALL","ACTIVITYTAG","SECTIONTAG","selectorGenerators","cmNameFor","id","classes","COLLAPSED","ACTIVITY","STATEDREADY","dettachedCms","dettachedSections","sections","cms","sectionReturn","debouncedReloads","Map","target","element","document","getElementById","reactive","stateReady","state","_indexContents","addEventListener","this","_sectionTogglers","toogleAll","getElement","collapseElementIds","getElements","map","setAttribute","join","_allSectionToggler","e","key","_refreshAllSectionsToggler","supportComponents","isEditing","DispatchActions","classList","add","CourseEvents","manualCompletionToggled","_completionHandler","_scrollHandler","setTimeout","event","sectionlink","closest","closestCollapse","isChevron","section","toggler","querySelector","isCollapsed","contains","sectionId","getAttribute","dispatch","preventDefault","isAllCollapsed","course","get","sectionlist","getWatchers","watch","handler","_reloadCm","_refreshCmName","_refreshSectionNumber","_refreshSectionCollapsed","_startProcessing","_refreshCourseSectionlist","_refreshSectionCmlist","_reloadSection","forEach","textContent","Error","contentcollapsed","collapsibleId","dataset","replace","collapsible","collapse","allcollapsed","allexpanded","remove","detail","undefined","cmid","completed","pageOffset","window","scrollY","items","getExporter","allItemsArray","pageItem","every","item","index","type","offsetTop","number","sectionid","inplace","inplaceeditable","getInplaceEditable","currentvalue","getValue","currentitemid","getItemId","rawtitle","setValue","cmlist","listparent","createCm","_createCmItem","bind","_fixOrder","listedSectionIds","createSection","_createSectionItem","_scanIndex","Section","CmItem","selector","creationhandler","_item$dataset","unregister","indexed","_getDebouncedReloadCm","debouncedReload","cmId","pendingKey","pendingReload","Pending","delete","cmitem","resolve","Fragment","loadFragment","Config","courseContextId","courseid","courseId","sr","then","html","js","replaceNode","catch","cancel","pending","set","_cancelDebouncedReloadCm","sectionitem","container","newItem","createElement","for","append","neworder","dettachedelements","createMethod","length","innerHTML","dndFakeActivity","itemid","currentitem","children","insertBefore","lastchild","lastChild","_lastchild$classList","_lastchild$dataset","removeChild"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"content.min.js","sources":["../../src/local/content.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Course index main component.\n *\n * @module core_courseformat/local/content\n * @class core_courseformat/local/content\n * @copyright 2020 Ferran Recio \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {BaseComponent} from 'core/reactive';\nimport {debounce} from 'core/utils';\nimport {getCurrentCourseEditor} from 'core_courseformat/courseeditor';\nimport Config from 'core/config';\nimport inplaceeditable from 'core/inplace_editable';\nimport Section from 'core_courseformat/local/content/section';\nimport CmItem from 'core_courseformat/local/content/section/cmitem';\nimport Fragment from 'core/fragment';\nimport Templates from 'core/templates';\nimport DispatchActions from 'core_courseformat/local/content/actions';\nimport * as CourseEvents from 'core_course/events';\n// The jQuery module is only used for interacting with Boostrap 4. It can we removed when MDL-71979 is integrated.\nimport jQuery from 'jquery';\nimport Pending from 'core/pending';\n\nexport default class Component extends BaseComponent {\n\n /**\n * Constructor hook.\n *\n * @param {Object} descriptor the component descriptor\n */\n create(descriptor) {\n // Optional component name for debugging.\n this.name = 'course_format';\n // Default query selectors.\n this.selectors = {\n SECTION: `[data-for='section']`,\n SECTION_ITEM: `[data-for='section_title']`,\n SECTION_CMLIST: `[data-for='cmlist']`,\n COURSE_SECTIONLIST: `[data-for='course_sectionlist']`,\n CM: `[data-for='cmitem']`,\n TOGGLER: `[data-action=\"togglecoursecontentsection\"]`,\n COLLAPSE: `[data-toggle=\"collapse\"]`,\n TOGGLEALL: `[data-toggle=\"toggleall\"]`,\n // Formats can override the activity tag but a default one is needed to create new elements.\n ACTIVITYTAG: 'li',\n SECTIONTAG: 'li',\n };\n this.selectorGenerators = {\n cmNameFor: (id) => `[data-cm-name-for='${id}']`,\n sectionNameFor: (id) => `[data-section-name-for='${id}']`,\n };\n // Default classes to toggle on refresh.\n this.classes = {\n COLLAPSED: `collapsed`,\n // Course content classes.\n ACTIVITY: `activity`,\n STATEDREADY: `stateready`,\n SECTION: `section`,\n };\n // Array to save dettached elements during element resorting.\n this.dettachedCms = {};\n this.dettachedSections = {};\n // Index of sections and cms components.\n this.sections = {};\n this.cms = {};\n // The page section return.\n this.sectionReturn = descriptor.sectionReturn ?? null;\n this.debouncedReloads = new Map();\n }\n\n /**\n * Static method to create a component instance form the mustahce template.\n *\n * @param {string} target the DOM main element or its ID\n * @param {object} selectors optional css selector overrides\n * @param {number} sectionReturn the content section return\n * @return {Component}\n */\n static init(target, selectors, sectionReturn) {\n return new Component({\n element: document.getElementById(target),\n reactive: getCurrentCourseEditor(),\n selectors,\n sectionReturn,\n });\n }\n\n /**\n * Initial state ready method.\n *\n * @param {Object} state the state data\n */\n stateReady(state) {\n this._indexContents();\n // Activate section togglers.\n this.addEventListener(this.element, 'click', this._sectionTogglers);\n\n // Collapse/Expand all sections button.\n const toogleAll = this.getElement(this.selectors.TOGGLEALL);\n if (toogleAll) {\n\n // Ensure collapse menu button adds aria-controls attribute referring to each collapsible element.\n const collapseElements = this.getElements(this.selectors.COLLAPSE);\n const collapseElementIds = [...collapseElements].map(element => element.id);\n toogleAll.setAttribute('aria-controls', collapseElementIds.join(' '));\n\n this.addEventListener(toogleAll, 'click', this._allSectionToggler);\n this.addEventListener(toogleAll, 'keydown', e => {\n // Collapse/expand all sections when Space key is pressed on the toggle button.\n if (e.key === ' ') {\n this._allSectionToggler(e);\n }\n });\n this._refreshAllSectionsToggler(state);\n }\n\n if (this.reactive.supportComponents) {\n // Actions are only available in edit mode.\n if (this.reactive.isEditing) {\n new DispatchActions(this);\n }\n\n // Mark content as state ready.\n this.element.classList.add(this.classes.STATEDREADY);\n }\n\n // Capture completion events.\n this.addEventListener(\n this.element,\n CourseEvents.manualCompletionToggled,\n this._completionHandler\n );\n\n // Capture page scroll to update page item.\n this.addEventListener(\n document,\n \"scroll\",\n this._scrollHandler\n );\n setTimeout(() => {\n this._scrollHandler();\n }, 500);\n }\n\n /**\n * Setup sections toggler.\n *\n * Toggler click is delegated to the main course content element because new sections can\n * appear at any moment and this way we prevent accidental double bindings.\n *\n * @param {Event} event the triggered event\n */\n _sectionTogglers(event) {\n const sectionlink = event.target.closest(this.selectors.TOGGLER);\n const closestCollapse = event.target.closest(this.selectors.COLLAPSE);\n // Assume that chevron is the only collapse toggler in a section heading;\n // I think this is the most efficient way to verify at the moment.\n const isChevron = closestCollapse?.closest(this.selectors.SECTION_ITEM);\n\n if (sectionlink || isChevron) {\n\n const section = event.target.closest(this.selectors.SECTION);\n const toggler = section.querySelector(this.selectors.COLLAPSE);\n const isCollapsed = toggler?.classList.contains(this.classes.COLLAPSED) ?? false;\n\n if (isChevron || isCollapsed) {\n // Update the state.\n const sectionId = section.getAttribute('data-id');\n this.reactive.dispatch(\n 'sectionContentCollapsed',\n [sectionId],\n !isCollapsed\n );\n }\n }\n }\n\n /**\n * Handle the collapse/expand all sections button.\n *\n * Toggler click is delegated to the main course content element because new sections can\n * appear at any moment and this way we prevent accidental double bindings.\n *\n * @param {Event} event the triggered event\n */\n _allSectionToggler(event) {\n event.preventDefault();\n\n const target = event.target.closest(this.selectors.TOGGLEALL);\n const isAllCollapsed = target.classList.contains(this.classes.COLLAPSED);\n\n const course = this.reactive.get('course');\n this.reactive.dispatch(\n 'sectionContentCollapsed',\n course.sectionlist ?? [],\n !isAllCollapsed\n );\n }\n\n /**\n * Return the component watchers.\n *\n * @returns {Array} of watchers\n */\n getWatchers() {\n // Section return is a global page variable but most formats define it just before start printing\n // the course content. This is the reason why we define this page setting here.\n this.reactive.sectionReturn = this.sectionReturn;\n\n // Check if the course format is compatible with reactive components.\n if (!this.reactive.supportComponents) {\n return [];\n }\n return [\n // State changes that require to reload some course modules.\n {watch: `cm.visible:updated`, handler: this._reloadCm},\n {watch: `cm.stealth:updated`, handler: this._reloadCm},\n {watch: `cm.sectionid:updated`, handler: this._reloadCm},\n {watch: `cm.indent:updated`, handler: this._reloadCm},\n {watch: `cm.groupmode:updated`, handler: this._reloadCm},\n {watch: `cm.name:updated`, handler: this._refreshCmName},\n // Update section number and title.\n {watch: `section.number:updated`, handler: this._refreshSectionNumber},\n {watch: `section.title:updated`, handler: this._refreshSectionTitle},\n // Collapse and expand sections.\n {watch: `section.contentcollapsed:updated`, handler: this._refreshSectionCollapsed},\n // Sections and cm sorting.\n {watch: `transaction:start`, handler: this._startProcessing},\n {watch: `course.sectionlist:updated`, handler: this._refreshCourseSectionlist},\n {watch: `section.cmlist:updated`, handler: this._refreshSectionCmlist},\n // Section visibility.\n {watch: `section.visible:updated`, handler: this._reloadSection},\n // Reindex sections and cms.\n {watch: `state:updated`, handler: this._indexContents},\n ];\n }\n\n /**\n * Update a course module name on the whole page.\n *\n * @param {object} param\n * @param {Object} param.element details the update details.\n */\n _refreshCmName({element}) {\n // Update classes.\n // Replace the text content of the cm name.\n const allCmNamesFor = this.getElements(\n this.selectorGenerators.cmNameFor(element.id)\n );\n allCmNamesFor.forEach((cmNameFor) => {\n cmNameFor.textContent = element.name;\n });\n }\n\n /**\n * Update section collapsed state via bootstrap 4 if necessary.\n *\n * Formats that do not use bootstrap 4 must override this method in order to keep the section\n * toggling working.\n *\n * @param {object} args\n * @param {Object} args.state The state data\n * @param {Object} args.element The element to update\n */\n _refreshSectionCollapsed({state, element}) {\n const target = this.getElement(this.selectors.SECTION, element.id);\n if (!target) {\n throw new Error(`Unknown section with ID ${element.id}`);\n }\n // Check if it is already done.\n const toggler = target.querySelector(this.selectors.COLLAPSE);\n const isCollapsed = toggler?.classList.contains(this.classes.COLLAPSED) ?? false;\n\n if (element.contentcollapsed !== isCollapsed) {\n let collapsibleId = toggler.dataset.target ?? toggler.getAttribute(\"href\");\n if (!collapsibleId) {\n return;\n }\n collapsibleId = collapsibleId.replace('#', '');\n const collapsible = document.getElementById(collapsibleId);\n if (!collapsible) {\n return;\n }\n\n // Course index is based on Bootstrap 4 collapsibles. To collapse them we need jQuery to\n // interact with collapsibles methods. Hopefully, this will change in Bootstrap 5 because\n // it does not require jQuery anymore (when MDL-71979 is integrated).\n jQuery(collapsible).collapse(element.contentcollapsed ? 'hide' : 'show');\n }\n\n this._refreshAllSectionsToggler(state);\n }\n\n /**\n * Refresh the collapse/expand all sections element.\n *\n * @param {Object} state The state data\n */\n _refreshAllSectionsToggler(state) {\n const target = this.getElement(this.selectors.TOGGLEALL);\n if (!target) {\n return;\n }\n // Check if we have all sections collapsed/expanded.\n let allcollapsed = true;\n let allexpanded = true;\n state.section.forEach(\n section => {\n allcollapsed = allcollapsed && section.contentcollapsed;\n allexpanded = allexpanded && !section.contentcollapsed;\n }\n );\n if (allcollapsed) {\n target.classList.add(this.classes.COLLAPSED);\n target.setAttribute('aria-expanded', false);\n }\n if (allexpanded) {\n target.classList.remove(this.classes.COLLAPSED);\n target.setAttribute('aria-expanded', true);\n }\n }\n\n /**\n * Setup the component to start a transaction.\n *\n * Some of the course actions replaces the current DOM element with a new one before updating the\n * course state. This means the component cannot preload any index properly until the transaction starts.\n *\n */\n _startProcessing() {\n // During a section or cm sorting, some elements could be dettached from the DOM and we\n // need to store somewhare in case they are needed later.\n this.dettachedCms = {};\n this.dettachedSections = {};\n }\n\n /**\n * Activity manual completion listener.\n *\n * @param {Event} event the custom ecent\n */\n _completionHandler({detail}) {\n if (detail === undefined) {\n return;\n }\n this.reactive.dispatch('cmCompletion', [detail.cmid], detail.completed);\n }\n\n /**\n * Check the current page scroll and update the active element if necessary.\n */\n _scrollHandler() {\n const pageOffset = window.scrollY;\n const items = this.reactive.getExporter().allItemsArray(this.reactive.state);\n // Check what is the active element now.\n let pageItem = null;\n items.every(item => {\n const index = (item.type === 'section') ? this.sections : this.cms;\n if (index[item.id] === undefined) {\n return true;\n }\n\n const element = index[item.id].element;\n pageItem = item;\n return pageOffset >= element.offsetTop;\n });\n if (pageItem) {\n this.reactive.dispatch('setPageItem', pageItem.type, pageItem.id);\n }\n }\n\n /**\n * Update a course section when the section number changes.\n *\n * The courseActions module used for most course section tools still depends on css classes and\n * section numbers (not id). To prevent inconsistencies when a section is moved, we need to refresh\n * the\n *\n * Course formats can override the section title rendering so the frontend depends heavily on backend\n * rendering. Luckily in edit mode we can trigger a title update using the inplace_editable module.\n *\n * @param {Object} param\n * @param {Object} param.element details the update details.\n */\n _refreshSectionNumber({element}) {\n // Find the element.\n const target = this.getElement(this.selectors.SECTION, element.id);\n if (!target) {\n // Job done. Nothing to refresh.\n return;\n }\n // Update section numbers in all data, css and YUI attributes.\n target.id = `section-${element.number}`;\n // YUI uses section number as section id in data-sectionid, in principle if a format use components\n // don't need this sectionid attribute anymore, but we keep the compatibility in case some plugin\n // use it for legacy purposes.\n target.dataset.sectionid = element.number;\n // The data-number is the attribute used by components to store the section number.\n target.dataset.number = element.number;\n\n // Update title and title inplace editable, if any.\n const inplace = inplaceeditable.getInplaceEditable(target.querySelector(this.selectors.SECTION_ITEM));\n if (inplace) {\n // The course content HTML can be modified at any moment, so the function need to do some checkings\n // to make sure the inplace editable still represents the same itemid.\n const currentvalue = inplace.getValue();\n const currentitemid = inplace.getItemId();\n // Unnamed sections must be recalculated.\n if (inplace.getValue() === '') {\n // The value to send can be an empty value if it is a default name.\n if (currentitemid == element.id && (currentvalue != element.rawtitle || element.rawtitle == '')) {\n inplace.setValue(element.rawtitle);\n }\n }\n }\n }\n\n /**\n * Update a course section name on the whole page.\n *\n * @param {object} param\n * @param {Object} param.element details the update details.\n */\n _refreshSectionTitle({element}) {\n // Replace the text content of the section name in the whole page.\n const allSectionNamesFor = document.querySelectorAll(\n this.selectorGenerators.sectionNameFor(element.id)\n );\n allSectionNamesFor.forEach((sectionNameFor) => {\n sectionNameFor.textContent = element.title;\n });\n }\n\n /**\n * Refresh a section cm list.\n *\n * @param {Object} param\n * @param {Object} param.element details the update details.\n */\n _refreshSectionCmlist({element}) {\n const cmlist = element.cmlist ?? [];\n const section = this.getElement(this.selectors.SECTION, element.id);\n const listparent = section?.querySelector(this.selectors.SECTION_CMLIST);\n // A method to create a fake element to be replaced when the item is ready.\n const createCm = this._createCmItem.bind(this);\n if (listparent) {\n this._fixOrder(listparent, cmlist, this.selectors.CM, this.dettachedCms, createCm);\n }\n }\n\n /**\n * Refresh the section list.\n *\n * @param {Object} param\n * @param {Object} param.state the full state object.\n */\n _refreshCourseSectionlist({state}) {\n // If we have a section return means we only show a single section so no need to fix order.\n if (this.reactive.sectionReturn !== null) {\n return;\n }\n const sectionlist = this.reactive.getExporter().listedSectionIds(state);\n const listparent = this.getElement(this.selectors.COURSE_SECTIONLIST);\n // For now section cannot be created at a frontend level.\n const createSection = this._createSectionItem.bind(this);\n if (listparent) {\n this._fixOrder(listparent, sectionlist, this.selectors.SECTION, this.dettachedSections, createSection);\n }\n }\n\n /**\n * Regenerate content indexes.\n *\n * This method is used when a legacy action refresh some content element.\n */\n _indexContents() {\n // Find unindexed sections.\n this._scanIndex(\n this.selectors.SECTION,\n this.sections,\n (item) => {\n return new Section(item);\n }\n );\n\n // Find unindexed cms.\n this._scanIndex(\n this.selectors.CM,\n this.cms,\n (item) => {\n return new CmItem(item);\n }\n );\n }\n\n /**\n * Reindex a content (section or cm) of the course content.\n *\n * This method is used internally by _indexContents.\n *\n * @param {string} selector the DOM selector to scan\n * @param {*} index the index attribute to update\n * @param {*} creationhandler method to create a new indexed element\n */\n _scanIndex(selector, index, creationhandler) {\n const items = this.getElements(`${selector}:not([data-indexed])`);\n items.forEach((item) => {\n if (!item?.dataset?.id) {\n return;\n }\n // Delete previous item component.\n if (index[item.dataset.id] !== undefined) {\n index[item.dataset.id].unregister();\n }\n // Create the new component.\n index[item.dataset.id] = creationhandler({\n ...this,\n element: item,\n });\n // Mark as indexed.\n item.dataset.indexed = true;\n });\n }\n\n /**\n * Reload a course module contents.\n *\n * Most course module HTML is still strongly backend dependant.\n * Some changes require to get a new version of the module.\n *\n * @param {object} param0 the watcher details\n * @param {object} param0.element the state object\n */\n _reloadCm({element}) {\n if (!this.getElement(this.selectors.CM, element.id)) {\n return;\n }\n const debouncedReload = this._getDebouncedReloadCm(element.id);\n debouncedReload();\n }\n\n /**\n * Generate or get a reload CM debounced function.\n * @param {Number} cmId\n * @returns {Function} the debounced reload function\n */\n _getDebouncedReloadCm(cmId) {\n const pendingKey = `courseformat/content:reloadCm_${cmId}`;\n let debouncedReload = this.debouncedReloads.get(pendingKey);\n if (debouncedReload) {\n return debouncedReload;\n }\n const reload = () => {\n const pendingReload = new Pending(pendingKey);\n this.debouncedReloads.delete(pendingKey);\n const cmitem = this.getElement(this.selectors.CM, cmId);\n if (!cmitem) {\n return pendingReload.resolve();\n }\n const promise = Fragment.loadFragment(\n 'core_courseformat',\n 'cmitem',\n Config.courseContextId,\n {\n id: cmId,\n courseid: Config.courseId,\n sr: this.reactive.sectionReturn ?? null,\n }\n );\n promise.then((html, js) => {\n // Other state change can reload the CM or the section before this one.\n if (!document.contains(cmitem)) {\n pendingReload.resolve();\n return false;\n }\n Templates.replaceNode(cmitem, html, js);\n this._indexContents();\n pendingReload.resolve();\n return true;\n }).catch(() => {\n pendingReload.resolve();\n });\n return pendingReload;\n };\n debouncedReload = debounce(\n reload,\n 200,\n {\n cancel: true, pending: true\n }\n );\n this.debouncedReloads.set(pendingKey, debouncedReload);\n return debouncedReload;\n }\n\n /**\n * Cancel the active reload CM debounced function, if any.\n * @param {Number} cmId\n */\n _cancelDebouncedReloadCm(cmId) {\n const pendingKey = `courseformat/content:reloadCm_${cmId}`;\n const debouncedReload = this.debouncedReloads.get(pendingKey);\n if (!debouncedReload) {\n return;\n }\n debouncedReload.cancel();\n this.debouncedReloads.delete(pendingKey);\n }\n\n /**\n * Reload a course section contents.\n *\n * Section HTML is still strongly backend dependant.\n * Some changes require to get a new version of the section.\n *\n * @param {details} param0 the watcher details\n * @param {object} param0.element the state object\n */\n _reloadSection({element}) {\n const pendingReload = new Pending(`courseformat/content:reloadSection_${element.id}`);\n const sectionitem = this.getElement(this.selectors.SECTION, element.id);\n if (sectionitem) {\n // Cancel any pending reload because the section will reload cms too.\n for (const cmId of element.cmlist) {\n this._cancelDebouncedReloadCm(cmId);\n }\n const promise = Fragment.loadFragment(\n 'core_courseformat',\n 'section',\n Config.courseContextId,\n {\n id: element.id,\n courseid: Config.courseId,\n sr: this.reactive.sectionReturn ?? null,\n }\n );\n promise.then((html, js) => {\n Templates.replaceNode(sectionitem, html, js);\n this._indexContents();\n pendingReload.resolve();\n }).catch(() => {\n pendingReload.resolve();\n });\n }\n }\n\n /**\n * Create a new course module item in a section.\n *\n * Thos method will append a fake item in the container and trigger an ajax request to\n * replace the fake element by the real content.\n *\n * @param {Element} container the container element (section)\n * @param {Number} cmid the course-module ID\n * @returns {Element} the created element\n */\n _createCmItem(container, cmid) {\n const newItem = document.createElement(this.selectors.ACTIVITYTAG);\n newItem.dataset.for = 'cmitem';\n newItem.dataset.id = cmid;\n // The legacy actions.js requires a specific ID and class to refresh the CM.\n newItem.id = `module-${cmid}`;\n newItem.classList.add(this.classes.ACTIVITY);\n container.append(newItem);\n this._reloadCm({\n element: this.reactive.get('cm', cmid),\n });\n return newItem;\n }\n\n /**\n * Create a new section item.\n *\n * This method will append a fake item in the container and trigger an ajax request to\n * replace the fake element by the real content.\n *\n * @param {Element} container the container element (section)\n * @param {Number} sectionid the course-module ID\n * @returns {Element} the created element\n */\n _createSectionItem(container, sectionid) {\n const section = this.reactive.get('section', sectionid);\n const newItem = document.createElement(this.selectors.SECTIONTAG);\n newItem.dataset.for = 'section';\n newItem.dataset.id = sectionid;\n newItem.dataset.number = section.number;\n // The legacy actions.js requires a specific ID and class to refresh the section.\n newItem.id = `section-${sectionid}`;\n newItem.classList.add(this.classes.SECTION);\n container.append(newItem);\n this._reloadSection({\n element: section,\n });\n return newItem;\n }\n\n /**\n * Fix/reorder the section or cms order.\n *\n * @param {Element} container the HTML element to reorder.\n * @param {Array} neworder an array with the ids order\n * @param {string} selector the element selector\n * @param {Object} dettachedelements a list of dettached elements\n * @param {function} createMethod method to create missing elements\n */\n async _fixOrder(container, neworder, selector, dettachedelements, createMethod) {\n if (container === undefined) {\n return;\n }\n\n // Empty lists should not be visible.\n if (!neworder.length) {\n container.classList.add('hidden');\n container.innerHTML = '';\n return;\n }\n\n // Grant the list is visible (in case it was empty).\n container.classList.remove('hidden');\n\n // Move the elements in order at the beginning of the list.\n neworder.forEach((itemid, index) => {\n let item = this.getElement(selector, itemid) ?? dettachedelements[itemid] ?? createMethod(container, itemid);\n if (item === undefined) {\n // Missing elements cannot be sorted.\n return;\n }\n // Get the current elemnt at that position.\n const currentitem = container.children[index];\n if (currentitem === undefined) {\n container.append(item);\n return;\n }\n if (currentitem !== item) {\n container.insertBefore(item, currentitem);\n }\n });\n\n // Dndupload add a fake element we need to keep.\n let dndFakeActivity;\n\n // Remove the remaining elements.\n while (container.children.length > neworder.length) {\n const lastchild = container.lastChild;\n if (lastchild?.classList?.contains('dndupload-preview')) {\n dndFakeActivity = lastchild;\n } else {\n dettachedelements[lastchild?.dataset?.id ?? 0] = lastchild;\n }\n container.removeChild(lastchild);\n }\n // Restore dndupload fake element.\n if (dndFakeActivity) {\n container.append(dndFakeActivity);\n }\n }\n}\n"],"names":["Component","BaseComponent","create","descriptor","name","selectors","SECTION","SECTION_ITEM","SECTION_CMLIST","COURSE_SECTIONLIST","CM","TOGGLER","COLLAPSE","TOGGLEALL","ACTIVITYTAG","SECTIONTAG","selectorGenerators","cmNameFor","id","sectionNameFor","classes","COLLAPSED","ACTIVITY","STATEDREADY","dettachedCms","dettachedSections","sections","cms","sectionReturn","debouncedReloads","Map","target","element","document","getElementById","reactive","stateReady","state","_indexContents","addEventListener","this","_sectionTogglers","toogleAll","getElement","collapseElementIds","getElements","map","setAttribute","join","_allSectionToggler","e","key","_refreshAllSectionsToggler","supportComponents","isEditing","DispatchActions","classList","add","CourseEvents","manualCompletionToggled","_completionHandler","_scrollHandler","setTimeout","event","sectionlink","closest","closestCollapse","isChevron","section","toggler","querySelector","isCollapsed","contains","sectionId","getAttribute","dispatch","preventDefault","isAllCollapsed","course","get","sectionlist","getWatchers","watch","handler","_reloadCm","_refreshCmName","_refreshSectionNumber","_refreshSectionTitle","_refreshSectionCollapsed","_startProcessing","_refreshCourseSectionlist","_refreshSectionCmlist","_reloadSection","forEach","textContent","Error","contentcollapsed","collapsibleId","dataset","replace","collapsible","collapse","allcollapsed","allexpanded","remove","detail","undefined","cmid","completed","pageOffset","window","scrollY","items","getExporter","allItemsArray","pageItem","every","item","index","type","offsetTop","number","sectionid","inplace","inplaceeditable","getInplaceEditable","currentvalue","getValue","currentitemid","getItemId","rawtitle","setValue","querySelectorAll","title","cmlist","listparent","createCm","_createCmItem","bind","_fixOrder","listedSectionIds","createSection","_createSectionItem","_scanIndex","Section","CmItem","selector","creationhandler","_item$dataset","unregister","indexed","_getDebouncedReloadCm","debouncedReload","cmId","pendingKey","pendingReload","Pending","delete","cmitem","resolve","Fragment","loadFragment","Config","courseContextId","courseid","courseId","sr","then","html","js","replaceNode","catch","cancel","pending","set","_cancelDebouncedReloadCm","sectionitem","container","newItem","createElement","for","append","neworder","dettachedelements","createMethod","length","innerHTML","dndFakeActivity","itemid","currentitem","children","insertBefore","lastchild","lastChild","_lastchild$classList","_lastchild$dataset","removeChild"],"mappings":"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"} \ No newline at end of file diff --git a/course/format/amd/src/local/content.js b/course/format/amd/src/local/content.js index c26c9c709550d..b945f80ce7761 100644 --- a/course/format/amd/src/local/content.js +++ b/course/format/amd/src/local/content.js @@ -63,6 +63,7 @@ export default class Component extends BaseComponent { }; this.selectorGenerators = { cmNameFor: (id) => `[data-cm-name-for='${id}']`, + sectionNameFor: (id) => `[data-section-name-for='${id}']`, }; // Default classes to toggle on refresh. this.classes = { @@ -236,6 +237,7 @@ export default class Component extends BaseComponent { {watch: `cm.name:updated`, handler: this._refreshCmName}, // Update section number and title. {watch: `section.number:updated`, handler: this._refreshSectionNumber}, + {watch: `section.title:updated`, handler: this._refreshSectionTitle}, // Collapse and expand sections. {watch: `section.contentcollapsed:updated`, handler: this._refreshSectionCollapsed}, // Sections and cm sorting. @@ -429,6 +431,22 @@ export default class Component extends BaseComponent { } } + /** + * Update a course section name on the whole page. + * + * @param {object} param + * @param {Object} param.element details the update details. + */ + _refreshSectionTitle({element}) { + // Replace the text content of the section name in the whole page. + const allSectionNamesFor = document.querySelectorAll( + this.selectorGenerators.sectionNameFor(element.id) + ); + allSectionNamesFor.forEach((sectionNameFor) => { + sectionNameFor.textContent = element.title; + }); + } + /** * Refresh a section cm list. * diff --git a/course/format/classes/base.php b/course/format/classes/base.php index 96d5f051fb83c..f7650aa06cca7 100644 --- a/course/format/classes/base.php +++ b/course/format/classes/base.php @@ -972,16 +972,15 @@ public function get_editor_custom_strings(): array { * core_courseformat will be user as the component. * * @param string $key the string key - * @param string|object|array $data extra data that can be used within translation strings - * @param string|null $lang moodle translation language, null means use current + * @param string|object|array|int $data extra data that can be used within translation strings * @return string the get_string result */ - public function get_format_string(string $key, $data = null, $lang = null): string { + public function get_format_string(string $key, $data = null): string { $component = 'format_' . $this->get_format(); if (!get_string_manager()->string_exists($key, $component)) { $component = 'core_courseformat'; } - return get_string($key, $component, $data, $lang); + return get_string($key, $component, $data); } /** diff --git a/course/format/classes/hook/after_cm_name_edited.php b/course/format/classes/hook/after_cm_name_edited.php new file mode 100644 index 0000000000000..50330908392bc --- /dev/null +++ b/course/format/classes/hook/after_cm_name_edited.php @@ -0,0 +1,77 @@ +. + +namespace core_courseformat\hook; + +use core\hook\described_hook; +use cm_info; + +/** + * Hook for course-module name edited. + * + * @package core_courseformat + * @copyright 2024 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class after_cm_name_edited implements described_hook { + /** + * Constructor. + * + * @param cm_info $cm the course module + * @param string $newname the new name + */ + public function __construct( + protected cm_info $cm, + protected string $newname, + ) { + } + + + /** + * Describes the hook purpose. + * + * @return string + */ + public static function get_hook_description(): string { + return 'This hook is triggered when a course module name is edited.'; + } + + /** + * List of tags that describe this hook. + * + * @return string[] + */ + public static function get_hook_tags(): array { + return ['cm_name_edited']; + } + + /** + * Get course module instance. + * + * @return cm_info + */ + public function get_cm(): cm_info { + return $this->cm; + } + + /** + * Get new name. + * @return string + */ + public function get_newname(): string { + return $this->newname; + } +} diff --git a/course/format/classes/local/cmactions.php b/course/format/classes/local/cmactions.php index 3bfd71845e80a..347b2a31a5ea6 100644 --- a/course/format/classes/local/cmactions.php +++ b/course/format/classes/local/cmactions.php @@ -16,6 +16,8 @@ namespace core_courseformat\local; + +use course_modinfo; /** * Course module course format actions. * @@ -24,5 +26,64 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class cmactions extends baseactions { - // All course module actions will go here. + /** + * Rename a course module. + * @param int $cmid the course module id. + * @param string $name the new name. + * @return bool true if the course module was renamed, false otherwise. + */ + public function rename(int $cmid, string $name): bool { + global $CFG, $DB; + require_once($CFG->libdir . '/gradelib.php'); + + $paramcleaning = empty($CFG->formatstringstriptags) ? PARAM_CLEANHTML : PARAM_TEXT; + $name = clean_param($name, $paramcleaning); + + if (empty($name)) { + return false; + } + if (\core_text::strlen($name) > 255) { + throw new \moodle_exception('maximumchars', 'moodle', '', 255); + } + + // The name is stored in the activity instance record. + // However, events, gradebook and calendar API uses a legacy + // course module data extraction from the DB instead of a section_info. + $cm = get_coursemodule_from_id('', $cmid, 0, false, MUST_EXIST); + + if ($name === $cm->name) { + return false; + } + + $DB->update_record( + $cm->modname, + (object)[ + 'id' => $cm->instance, + 'name' => $name, + 'timemodified' => time(), + ] + ); + $cm->name = $name; + + \core\event\course_module_updated::create_from_cm($cm)->trigger(); + + course_modinfo::purge_course_module_cache($cm->course, $cm->id); + rebuild_course_cache($cm->course, false, true); + + // Modules may add some logic to renaming. + $modinfo = get_fast_modinfo($cm->course); + $hook = new \core_courseformat\hook\after_cm_name_edited($modinfo->get_cm($cm->id), $name); + \core\hook\manager::get_instance()->dispatch($hook); + + // Attempt to update the grade item if relevant. + $grademodule = $DB->get_record($cm->modname, ['id' => $cm->instance]); + $grademodule->cmidnumber = $cm->idnumber; + $grademodule->modname = $cm->modname; + grade_update_mod_grades($grademodule); + + // Update calendar events with the new name. + course_module_update_calendar_events($cm->modname, $grademodule, $cm); + + return true; + } } diff --git a/course/format/classes/local/sectionactions.php b/course/format/classes/local/sectionactions.php index 23ad84ef0038d..f0e5657a9e137 100644 --- a/course/format/classes/local/sectionactions.php +++ b/course/format/classes/local/sectionactions.php @@ -18,6 +18,7 @@ use section_info; use stdClass; +use core\event\course_module_updated; use core\event\course_section_deleted; /** @@ -336,4 +337,116 @@ protected function delete_async(section_info $sectioninfo, bool $forcedeleteifno $sectioninfo = $this->get_section_info($sectioninfo->id); return $this->delete_format_data($sectioninfo, $forcedeleteifnotempty, $event); } + + /** + * Update a course section. + * + * @param section_info $sectioninfo the section info or database record to update. + * @param array|stdClass $fields the fields to update. + * @return bool whether section was updated + */ + public function update(section_info $sectioninfo, array|stdClass $fields): bool { + global $DB; + + $courseid = $this->course->id; + + // Some fields can not be updated using this method. + $fields = array_diff_key((array) $fields, array_flip(['id', 'course', 'section', 'sequence'])); + if (array_key_exists('name', $fields) && \core_text::strlen($fields['name']) > 255) { + throw new \moodle_exception('maximumchars', 'moodle', '', 255); + } + + // If the section is delegated to a component, it may control some section values. + $fields = $this->preprocess_delegated_section_fields($sectioninfo, $fields); + + if (empty($fields)) { + return false; + } + + $fields['id'] = $sectioninfo->id; + $fields['timemodified'] = time(); + $DB->update_record('course_sections', $fields); + + // We need to update the section cache before the format options are updated. + \course_modinfo::purge_course_section_cache_by_id($courseid, $sectioninfo->id); + rebuild_course_cache($courseid, false, true); + + course_get_format($courseid)->update_section_format_options($fields); + + $event = \core\event\course_section_updated::create( + [ + 'objectid' => $sectioninfo->id, + 'courseid' => $courseid, + 'context' => \context_course::instance($courseid), + 'other' => ['sectionnum' => $sectioninfo->section], + ] + ); + $event->trigger(); + + if (isset($fields['visible'])) { + $this->transfer_visibility_to_cms($sectioninfo, (bool) $fields['visible']); + } + return true; + } + + /** + * Transfer the visibility of the section to the course modules. + * + * @param section_info $sectioninfo the section info or database record to update. + * @param bool $visibility the new visibility of the section. + */ + protected function transfer_visibility_to_cms(section_info $sectioninfo, bool $visibility): void { + global $DB; + + if (empty($sectioninfo->sequence) || $visibility == (bool) $sectioninfo->visible) { + return; + } + + $modules = explode(',', $sectioninfo->sequence); + $cmids = []; + foreach ($modules as $moduleid) { + $cm = get_coursemodule_from_id(null, $moduleid, $this->course->id); + if (!$cm) { + continue; + } + + $modupdated = false; + if ($visibility) { + // As we unhide the section, we use the previously saved visibility stored in visibleold. + $modupdated = set_coursemodule_visible($moduleid, $cm->visibleold, $cm->visibleoncoursepage, false); + } else { + // We hide the section, so we hide the module but we store the original state in visibleold. + $modupdated = set_coursemodule_visible($moduleid, 0, $cm->visibleoncoursepage, false); + if ($modupdated) { + $DB->set_field('course_modules', 'visibleold', $cm->visible, ['id' => $moduleid]); + } + } + + if ($modupdated) { + $cmids[] = $cm->id; + course_module_updated::create_from_cm($cm)->trigger(); + } + } + + \course_modinfo::purge_course_modules_cache($this->course->id, $cmids); + rebuild_course_cache($this->course->id, false, true); + } + + /** + * Preprocess the section fields before updating a delegated section. + * + * @param section_info $sectioninfo the section info or database record to update. + * @param array $fields the fields to update. + * @return array the updated fields + */ + protected function preprocess_delegated_section_fields(section_info $sectioninfo, array $fields): array { + $delegated = $sectioninfo->get_component_instance(); + if (!$delegated) { + return $fields; + } + if (array_key_exists('name', $fields)) { + $fields['name'] = $delegated->preprocess_section_name($sectioninfo, $fields['name']); + } + return $fields; + } } diff --git a/course/format/classes/output/local/content/cm/cmicon.php b/course/format/classes/output/local/content/cm/cmicon.php index c2d5bd13577c0..784b96eb1e6e0 100644 --- a/course/format/classes/output/local/content/cm/cmicon.php +++ b/course/format/classes/output/local/content/cm/cmicon.php @@ -78,8 +78,8 @@ public function export_for_template(\renderer_base $output): array { $iconurl = $mod->get_icon_url(); $iconclass = $iconurl->get_param('filtericon') ? '' : 'nofilter'; - $isbrandedfunction = $mod->modname.'_is_branded'; - $isbranded = function_exists($isbrandedfunction) ? $isbrandedfunction() : false; + $isbranded = component_callback('mod_' . $mod->modname, 'is_branded', [], false); + $data = [ 'uservisible' => $mod->uservisible, 'url' => $mod->url, diff --git a/course/format/classes/sectiondelegate.php b/course/format/classes/sectiondelegate.php index 44faea6328df0..ef7772af38ec5 100644 --- a/course/format/classes/sectiondelegate.php +++ b/course/format/classes/sectiondelegate.php @@ -17,6 +17,7 @@ namespace core_courseformat; use section_info; +use core_courseformat\stateupdates; /** * Section delegate base class. @@ -77,4 +78,30 @@ protected static function get_delegate_class_name(string $pluginname): ?string { public static function has_delegate_class(string $pluginname): bool { return self::get_delegate_class_name($pluginname) !== null; } + + /** + * Define the section final name. + * + * This method can process the section name and return the validated new name. + * + * @param section_info $section + * @param string|null $newname the new name value to store in the database + * @return string|null the name value to store in the database + */ + public function preprocess_section_name(section_info $section, ?string $newname): ?string { + return $newname; + } + + /** + * Add extra state updates when put or create a section. + * + * This method is called every time the backend sends a delegated section + * state update to the UI. + * + * @param section_info $section the affected section. + * @param stateupdates $updates the state updates object to notify the UI. + */ + public function put_section_state_extra_updates(section_info $section, stateupdates $updates): void { + // By default, do nothing. + } } diff --git a/course/format/classes/stateupdates.php b/course/format/classes/stateupdates.php index eb2bfe2acb935..feed784ed5272 100644 --- a/course/format/classes/stateupdates.php +++ b/course/format/classes/stateupdates.php @@ -123,6 +123,12 @@ protected function create_or_put_section(int $sectionid, string $action): void { $currentstate = new $sectionclass($this->format, $section); $this->add_update('section', $action, $currentstate->export_for_template($this->output)); + + // If the section is delegated to a component, give the component oportunity to add updates. + $delegated = $section->get_component_instance(); + if ($delegated) { + $delegated->put_section_state_extra_updates($section, $this); + } } /** diff --git a/course/format/tests/behat/course_courseindex.feature b/course/format/tests/behat/course_courseindex.feature index aec8d5527fd14..63cbfffbf05e7 100644 --- a/course/format/tests/behat/course_courseindex.feature +++ b/course/format/tests/behat/course_courseindex.feature @@ -374,3 +374,10 @@ Feature: Course index depending on role And I should see "Activity sample 5" in the "courseindex-content" "region" # Label intro text should be displayed if label name is not set. And I should see "Test label 2" in the "courseindex-content" "region" + + @javascript + Scenario: Change the section name inline in section page + When I am on the "Course 1 > Section 2" "course > section" page logged in as "teacher1" + And I turn editing mode on + When I set the field "Edit section name" in the "page-header" "region" to "Custom section name" + Then I should see "Custom section name" in the "courseindex-content" "region" diff --git a/course/format/tests/behat/section_page.feature b/course/format/tests/behat/section_page.feature index 4b87c8e5b5d42..12a3dc07ab82a 100644 --- a/course/format/tests/behat/section_page.feature +++ b/course/format/tests/behat/section_page.feature @@ -90,3 +90,13 @@ Feature: Single section course page Given I turn editing mode on When I click on "View" "link" in the "Section 1" "section" Then "Add section" "link" should not exist in the "region-main" "region" + + @javascript + Scenario: Change the section name inline + # The course index is hidden by default in small devices. + Given I change window size to "large" + And I turn editing mode on + And I open section "1" edit menu + And I click on "View" "link" in the "Section 1" "section" + When I set the field "Edit section name" in the "page-header" "region" to "Custom section name" + Then "Custom section name" "text" should exist in the ".breadcrumb" "css_element" diff --git a/course/format/tests/local/cmactions_test.php b/course/format/tests/local/cmactions_test.php new file mode 100644 index 0000000000000..53057795240e3 --- /dev/null +++ b/course/format/tests/local/cmactions_test.php @@ -0,0 +1,224 @@ +. + +namespace core_courseformat\local; + +use core_courseformat\hook\after_cm_name_edited; + +/** + * Course module format actions class tests. + * + * @package core_courseformat + * @copyright 2024 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \core_courseformat\cmactions + */ +final class cmactions_test extends \advanced_testcase { + /** + * Setup to ensure that fixtures are loaded. + */ + public static function setUpBeforeClass(): void { + global $CFG; + require_once($CFG->dirroot . '/course/lib.php'); + } + + /** + * Test renaming a course module. + * + * @dataProvider provider_test_rename + * @covers ::rename + * @param string $newname The new name for the course module. + * @param bool $expected Whether the course module was renamed. + * @param bool $expectexception Whether an exception is expected. + */ + public function test_rename(string $newname, bool $expected, bool $expectexception): void { + global $DB; + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(['format' => 'topics']); + $activity = $this->getDataGenerator()->create_module( + 'assign', + ['course' => $course->id, 'name' => 'Old name'] + ); + + $cmactions = new cmactions($course); + + if ($expectexception) { + $this->expectException(\moodle_exception::class); + } + $result = $cmactions->rename($activity->cmid, $newname); + $this->assertEquals($expected, $result); + + $cminfo = get_fast_modinfo($course)->get_cm($activity->cmid); + if ($result) { + $this->assertEquals('New name', $cminfo->name); + } else { + $this->assertEquals('Old name', $cminfo->name); + } + } + + /** + * Data provider for test_rename. + * + * @return array + */ + public static function provider_test_rename(): array { + return [ + 'Empty name' => [ + 'newname' => '', + 'expected' => false, + 'expectexception' => false, + ], + 'Maximum length' => [ + 'newname' => str_repeat('a', 256), + 'expected' => false, + 'expectexception' => true, + ], + 'Valid name' => [ + 'newname' => 'New name', + 'expected' => true, + 'expectexception' => false, + ], + ]; + } + + /** + * Test rename an activity also rename the calendar events. + * + * @covers ::rename + */ + public function test_rename_calendar_events(): void { + global $DB; + $this->resetAfterTest(); + + $this->setAdminUser(); + set_config('enablecompletion', 1); + + $course = $this->getDataGenerator()->create_course(['enablecompletion' => COMPLETION_ENABLED]); + $activity = $this->getDataGenerator()->create_module( + 'assign', + [ + 'name' => 'Old name', + 'course' => $course, + 'completionexpected' => time(), + 'duedate' => time(), + ] + ); + $cm = get_coursemodule_from_instance('assign', $activity->id, $course->id); + + // Validate course events naming. + $this->assertEquals(2, $DB->count_records('event')); + + $event = $DB->get_record( + 'event', + ['modulename' => 'assign', 'instance' => $activity->id, 'eventtype' => 'due'] + ); + $this->assertEquals( + get_string('calendardue', 'assign', 'Old name'), + $event->name + ); + + $event = $DB->get_record( + 'event', + ['modulename' => 'assign', 'instance' => $activity->id, 'eventtype' => 'expectcompletionon'] + ); + $this->assertEquals( + get_string('completionexpectedfor', 'completion', (object) ['instancename' => 'Old name']), + $event->name + ); + + // Rename activity. + $cmactions = new cmactions($course); + $result = $cmactions->rename($activity->cmid, 'New name'); + $this->assertTrue($result); + + // Validate event renaming. + $event = $DB->get_record( + 'event', + ['modulename' => 'assign', 'instance' => $activity->id, 'eventtype' => 'due'] + ); + $this->assertEquals( + get_string('calendardue', 'assign', 'New name'), + $event->name + ); + + $event = $DB->get_record( + 'event', + ['modulename' => 'assign', 'instance' => $activity->id, 'eventtype' => 'expectcompletionon'] + ); + $this->assertEquals( + get_string('completionexpectedfor', 'completion', (object) ['instancename' => 'New name']), + $event->name + ); + } + + /** + * Test renaming an activity trigger a course update log event. + * + * @covers ::rename + */ + public function test_rename_course_module_updated_event(): void { + global $DB; + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $activity = $this->getDataGenerator()->create_module( + 'assign', + ['course' => $course->id, 'name' => 'Old name'] + ); + + $sink = $this->redirectEvents(); + + $cmactions = new cmactions($course); + $result = $cmactions->rename($activity->cmid, 'New name'); + $this->assertTrue($result); + + $events = $sink->get_events(); + $event = reset($events); + + // Check that the event data is valid. + $this->assertInstanceOf('\core\event\course_module_updated', $event); + $this->assertEquals(\context_module::instance($activity->cmid), $event->get_context()); + } + + /** + * Test renaming an activity triggers the after_cm_name_edited hook. + * @covers ::rename + */ + public function test_rename_after_cm_name_edited_hook(): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $activity = $this->getDataGenerator()->create_module( + 'assign', + ['course' => $course->id, 'name' => 'Old name'] + ); + + $executedhook = null; + + $testcallback = function(after_cm_name_edited $hook) use (&$executedhook): void { + $executedhook = $hook; + }; + $this->redirectHook(after_cm_name_edited::class, $testcallback); + + $cmactions = new cmactions($course); + $result = $cmactions->rename($activity->cmid, 'New name'); + $this->assertTrue($result); + + $this->assertEquals($activity->cmid, $executedhook->get_cm()->id); + $this->assertEquals('New name', $executedhook->get_newname()); + } +} diff --git a/course/format/tests/local/sectionactions_test.php b/course/format/tests/local/sectionactions_test.php index adac5fc0762b6..5c6cee521438d 100644 --- a/course/format/tests/local/sectionactions_test.php +++ b/course/format/tests/local/sectionactions_test.php @@ -541,4 +541,364 @@ public function test_async_section_deletion_hook_implemented(): void { } $this->assertEquals(3, $count); } + + /** + * Test section update method. + * + * @covers ::update + * @dataProvider update_provider + * @param string $fieldname the name of the field to update + * @param int|string $value the value to set + * @param int|string $expected the expected value after the update ('=' to specify the same value as original field) + * @param bool $expectexception if the method should throw an exception + */ + public function test_update( + string $fieldname, + int|string $value, + int|string $expected, + bool $expectexception + ): void { + global $DB; + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course( + ['format' => 'topics', 'numsections' => 1], + ['createsections' => true] + ); + $section = get_fast_modinfo($course)->get_section_info(1); + + $sectionrecord = $DB->get_record('course_sections', ['id' => $section->id]); + $this->assertNotEquals($value, $sectionrecord->$fieldname); + $this->assertNotEquals($value, $section->$fieldname); + + if ($expectexception) { + $this->expectException(\moodle_exception::class); + } + + if ($expected === '=') { + $expected = $section->$fieldname; + } + + $sectionactions = new sectionactions($course); + $sectionactions->update($section, [$fieldname => $value]); + + $sectionrecord = $DB->get_record('course_sections', ['id' => $section->id]); + $this->assertEquals($expected, $sectionrecord->$fieldname); + + $section = get_fast_modinfo($course)->get_section_info(1); + $this->assertEquals($expected, $section->$fieldname); + } + + /** + * Data provider for test_update. + * @return array + */ + public static function update_provider(): array { + return [ + 'Id will not be updated' => [ + 'fieldname' => 'id', + 'value' => -1, + 'expected' => '=', + 'expectexception' => false, + ], + 'Course will not be updated' => [ + 'fieldname' => 'course', + 'value' => -1, + 'expected' => '=', + 'expectexception' => false, + ], + 'Section number will not be updated' => [ + 'fieldname' => 'section', + 'value' => -1, + 'expected' => '=', + 'expectexception' => false, + ], + 'Sequence will be updated' => [ + 'fieldname' => 'name', + 'value' => 'new name', + 'expected' => 'new name', + 'expectexception' => false, + ], + 'Summary can be updated' => [ + 'fieldname' => 'summary', + 'value' => 'new summary', + 'expected' => 'new summary', + 'expectexception' => false, + ], + 'Visible can be updated' => [ + 'fieldname' => 'visible', + 'value' => 0, + 'expected' => 0, + 'expectexception' => false, + ], + 'component can be updated' => [ + 'fieldname' => 'component', + 'value' => 'mod_assign', + 'expected' => 'mod_assign', + 'expectexception' => false, + ], + 'itemid can be updated' => [ + 'fieldname' => 'itemid', + 'value' => 1, + 'expected' => 1, + 'expectexception' => false, + ], + 'Long names throws and exception' => [ + 'fieldname' => 'name', + 'value' => str_repeat('a', 256), + 'expected' => '=', + 'expectexception' => true, + ], + ]; + } + + /** + * Test section update method updating several values at once. + * + * @covers ::update + */ + public function test_update_multiple_fields(): void { + global $DB; + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course( + ['format' => 'topics', 'numsections' => 1], + ['createsections' => true] + ); + $section = get_fast_modinfo($course)->get_section_info(1); + + $sectionrecord = $DB->get_record('course_sections', ['id' => $section->id]); + $this->assertEquals(1, $sectionrecord->visible); + $this->assertNull($section->name); + + $sectionactions = new sectionactions($course); + $sectionactions->update($section, ['name' => 'New name', 'visible' => 0]); + + $sectionrecord = $DB->get_record('course_sections', ['id' => $section->id]); + $this->assertEquals('New name', $sectionrecord->name); + $this->assertEquals(0, $sectionrecord->visible); + + $section = get_fast_modinfo($course)->get_section_info(1); + $this->assertEquals('New name', $section->name); + $this->assertEquals(0, $section->visible); + } + + /** + * Test updating a section trigger a course section update log event. + * + * @covers ::update + */ + public function test_course_section_updated_event(): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course( + ['format' => 'topics', 'numsections' => 1], + ['createsections' => true] + ); + $section = get_fast_modinfo($course)->get_section_info(1); + + $sink = $this->redirectEvents(); + + $sectionactions = new sectionactions($course); + $sectionactions->update($section, ['name' => 'New name', 'visible' => 0]); + + $events = $sink->get_events(); + $event = reset($events); + + // Check that the event data is valid. + $this->assertInstanceOf('\core\event\course_section_updated', $event); + $data = $event->get_data(); + $this->assertEquals(\context_course::instance($course->id), $event->get_context()); + $this->assertEquals($section->id, $data['objectid']); + } + + /** + * Test section update change the modified date. + * + * @covers ::update + */ + public function test_update_time_modified(): void { + global $DB; + $this->resetAfterTest(); + + // Create the course with sections. + $course = $this->getDataGenerator()->create_course( + ['format' => 'topics', 'numsections' => 1], + ['createsections' => true] + ); + $section = get_fast_modinfo($course)->get_section_info(1); + + $sectionrecord = $DB->get_record('course_sections', ['id' => $section->id]); + $oldtimemodified = $sectionrecord->timemodified; + + $sectionactions = new sectionactions($course); + + // Ensuring that the section update occurs at a different timestamp. + $this->waitForSecond(); + + // The timemodified should only be updated if the section is actually updated. + $result = $sectionactions->update($section, []); + $this->assertFalse($result); + $sectionrecord = $DB->get_record('course_sections', ['id' => $section->id]); + $this->assertEquals($oldtimemodified, $sectionrecord->timemodified); + + // Now update something to prove timemodified changes. + $result = $sectionactions->update($section, ['name' => 'New name']); + $this->assertTrue($result); + $sectionrecord = $DB->get_record('course_sections', ['id' => $section->id]); + $this->assertGreaterThan($oldtimemodified, $sectionrecord->timemodified); + } + + /** + * Test section updating visibility will hide or show section activities. + * + * @covers ::update + */ + public function test_update_hide_section_activities(): void { + global $DB; + $this->resetAfterTest(); + + // Create 4 activities (visible, visible, hidden, hidden). + $course = $this->getDataGenerator()->create_course( + ['format' => 'topics', 'numsections' => 1], + ['createsections' => true] + ); + $activity1 = $this->getDataGenerator()->create_module( + 'assign', + ['course' => $course->id, 'section' => 1] + ); + $activity2 = $this->getDataGenerator()->create_module( + 'assign', + ['course' => $course->id, 'section' => 1] + ); + $activity3 = $this->getDataGenerator()->create_module( + 'assign', + ['course' => $course->id, 'section' => 1, 'visible' => 0] + ); + $activity4 = $this->getDataGenerator()->create_module( + 'assign', + ['course' => $course->id, 'section' => 1, 'visible' => 0] + ); + + $modinfo = get_fast_modinfo($course); + $cm1 = $modinfo->get_cm($activity1->cmid); + $cm2 = $modinfo->get_cm($activity2->cmid); + $cm3 = $modinfo->get_cm($activity3->cmid); + $cm4 = $modinfo->get_cm($activity4->cmid); + $this->assertEquals(1, $cm1->visible); + $this->assertEquals(1, $cm2->visible); + $this->assertEquals(0, $cm3->visible); + $this->assertEquals(0, $cm4->visible); + + $sectionactions = new sectionactions($course); + + // Validate hidding section hides all activities. + $section = $modinfo->get_section_info(1); + $sectionactions->update($section, ['visible' => 0]); + + $modinfo = get_fast_modinfo($course); + $cm1 = $modinfo->get_cm($activity1->cmid); + $cm2 = $modinfo->get_cm($activity2->cmid); + $cm3 = $modinfo->get_cm($activity3->cmid); + $cm4 = $modinfo->get_cm($activity4->cmid); + $this->assertEquals(0, $cm1->visible); + $this->assertEquals(0, $cm2->visible); + $this->assertEquals(0, $cm3->visible); + $this->assertEquals(0, $cm4->visible); + + // Validate showing the section restores the previous visibility. + $section = $modinfo->get_section_info(1); + $sectionactions->update($section, ['visible' => 1]); + + $modinfo = get_fast_modinfo($course); + $cm1 = $modinfo->get_cm($activity1->cmid); + $cm2 = $modinfo->get_cm($activity2->cmid); + $cm3 = $modinfo->get_cm($activity3->cmid); + $cm4 = $modinfo->get_cm($activity4->cmid); + $this->assertEquals(1, $cm1->visible); + $this->assertEquals(1, $cm2->visible); + $this->assertEquals(0, $cm3->visible); + $this->assertEquals(0, $cm4->visible); + + // Swap two activities visibility to alter visible values. + set_coursemodule_visible($cm2->id, 0, 0, true); + set_coursemodule_visible($cm4->id, 1, 1, true); + + $modinfo = get_fast_modinfo($course); + $cm1 = $modinfo->get_cm($activity1->cmid); + $cm2 = $modinfo->get_cm($activity2->cmid); + $cm3 = $modinfo->get_cm($activity3->cmid); + $cm4 = $modinfo->get_cm($activity4->cmid); + $this->assertEquals(1, $cm1->visible); + $this->assertEquals(0, $cm2->visible); + $this->assertEquals(0, $cm3->visible); + $this->assertEquals(1, $cm4->visible); + + // Validate hidding the section again. + $section = $modinfo->get_section_info(1); + $sectionactions->update($section, ['visible' => 0]); + + $modinfo = get_fast_modinfo($course); + $cm1 = $modinfo->get_cm($activity1->cmid); + $cm2 = $modinfo->get_cm($activity2->cmid); + $cm3 = $modinfo->get_cm($activity3->cmid); + $cm4 = $modinfo->get_cm($activity4->cmid); + $this->assertEquals(0, $cm1->visible); + $this->assertEquals(0, $cm2->visible); + $this->assertEquals(0, $cm3->visible); + $this->assertEquals(0, $cm4->visible); + + // Validate showing the section once more to check previous state is restored. + $section = $modinfo->get_section_info(1); + $sectionactions->update($section, ['visible' => 1]); + + $modinfo = get_fast_modinfo($course); + $cm1 = $modinfo->get_cm($activity1->cmid); + $cm2 = $modinfo->get_cm($activity2->cmid); + $cm3 = $modinfo->get_cm($activity3->cmid); + $cm4 = $modinfo->get_cm($activity4->cmid); + $this->assertEquals(1, $cm1->visible); + $this->assertEquals(0, $cm2->visible); + $this->assertEquals(0, $cm3->visible); + $this->assertEquals(1, $cm4->visible); + } + + /** + * Test that the preprocess_section_name method can alter the section rename value. + * + * @covers ::update + * @covers ::preprocess_delegated_section_fields + */ + public function test_preprocess_section_name(): void { + global $DB, $CFG; + $this->resetAfterTest(); + + require_once($CFG->libdir . '/tests/fixtures/sectiondelegatetest.php'); + + $course = $this->getDataGenerator()->create_course(); + + $sectionactions = new sectionactions($course); + $section = $sectionactions->create_delegated('test_component', 1); + + $result = $sectionactions->update($section, ['name' => 'new_name']); + $this->assertTrue($result); + + $section = $DB->get_record('course_sections', ['id' => $section->id]); + $this->assertEquals('new_name_suffix', $section->name); + + $sectioninfo = get_fast_modinfo($course->id)->get_section_info_by_id($section->id); + $this->assertEquals('new_name_suffix', $sectioninfo->name); + + // Validate null name. + $section = $sectionactions->create_delegated('test_component', 1, (object)['name' => 'sample']); + + $result = $sectionactions->update($section, ['name' => null]); + $this->assertTrue($result); + + $section = $DB->get_record('course_sections', ['id' => $section->id]); + $this->assertEquals('null_name', $section->name); + + $sectioninfo = get_fast_modinfo($course->id)->get_section_info_by_id($section->id); + $this->assertEquals('null_name', $sectioninfo->name); + } } diff --git a/course/format/tests/stateupdates_test.php b/course/format/tests/stateupdates_test.php index 714d123fadba2..a5206b3ff7ce1 100644 --- a/course/format/tests/stateupdates_test.php +++ b/course/format/tests/stateupdates_test.php @@ -383,4 +383,43 @@ private function add_cm_provider_helper(string $action): array { ], ]; } + + /** + * Test components can add data to delegated section state updates. + * @covers ::add_section_put + */ + public function test_put_section_state_extra_updates(): void { + global $DB, $CFG; + $this->resetAfterTest(); + + require_once($CFG->libdir . '/tests/fixtures/sectiondelegatetest.php'); + + $course = $this->getDataGenerator()->create_course(); + $activity = $this->getDataGenerator()->create_module( + 'assign', + ['course' => $course->id] + ); + + // The test component section delegate will add the activity cm info into the state. + $section = formatactions::section($course)->create_delegated('test_component', $activity->cmid); + + $format = course_get_format($course); + $updates = new \core_courseformat\stateupdates($format); + + $updates->add_section_put($section->id); + + $data = $updates->jsonSerialize(); + + $this->assertCount(2, $data); + + $sectiondata = $data[0]; + $this->assertEquals('section', $sectiondata->name); + $this->assertEquals('put', $sectiondata->action); + $this->assertEquals($section->id, $sectiondata->fields->id); + + $cmdata = $data[1]; + $this->assertEquals('cm', $cmdata->name); + $this->assertEquals('put', $cmdata->action); + $this->assertEquals($activity->cmid, $cmdata->fields->id); + } } diff --git a/course/format/topics/tests/behat/edit_delete_sections.feature b/course/format/topics/tests/behat/edit_delete_sections.feature index 5c5e0edd5bfc9..2626dba42348f 100644 --- a/course/format/topics/tests/behat/edit_delete_sections.feature +++ b/course/format/topics/tests/behat/edit_delete_sections.feature @@ -27,22 +27,21 @@ Feature: Sections can be edited and deleted in custom sections format Scenario: View the default name of the general section in custom sections format Given I am on "Course 1" course homepage with editing mode on When I edit the section "0" - Then the field "Custom" matches value "0" - And the field "New value for Section name" matches value "General" + Then the field "Section name" matches value "" + And I should see "General" Scenario: Edit the default name of the general section in custom sections format Given I am on "Course 1" course homepage with editing mode on And I should see "General" in the "General" "section" When I edit the section "0" and I fill the form with: - | Custom | 1 | - | New value for Section name | This is the general section | + | Section name | This is the general section | Then I should see "This is the general section" in the "page" "region" Scenario: View the default name of the second section in custom sections format Given I am on "Course 2" course homepage with editing mode on When I edit the section "1" - Then the field "Custom" matches value "0" - And the field "New value for Section name" matches value "New section" + Then the field "Section name" matches value "" + And I should see "New section" Scenario: Edit section summary in custom sections format Given I am on "Course 1" course homepage with editing mode on @@ -53,8 +52,7 @@ Feature: Sections can be edited and deleted in custom sections format Scenario: Edit section default name in custom sections format Given I am on "Course 1" course homepage with editing mode on When I edit the section "2" and I fill the form with: - | Custom | 1 | - | New value for Section name | This is the second section | + | Section name | This is the second section | Then I should see "This is the second section" in the "page" "region" And I should not see "Section 2" in the "region-main" "region" diff --git a/course/format/upgrade.txt b/course/format/upgrade.txt index b7c63e1510842..a5c1b1971ce11 100644 --- a/course/format/upgrade.txt +++ b/course/format/upgrade.txt @@ -54,6 +54,7 @@ value when all the sections must be displayed (instead of 0). That way, section plugins can now include the string 'plugin_description' to provide a description of the course format. * A new item, initsections, has been added to the testing_data_generator::create_course() function, to let the generator rename the sections to "Section X". +* The core_courseformat\base::get_format_string last parameter has been removed because it was erroneous. === 4.3 === * New core_courseformat\output\activitybadge class that can be extended by any module to display content near the activity name. diff --git a/course/format/weeks/tests/behat/edit_delete_sections.feature b/course/format/weeks/tests/behat/edit_delete_sections.feature index da7ae8868a17c..96061da9948e2 100644 --- a/course/format/weeks/tests/behat/edit_delete_sections.feature +++ b/course/format/weeks/tests/behat/edit_delete_sections.feature @@ -25,19 +25,18 @@ Feature: Sections can be edited and deleted in weekly sections format Scenario: View the default name of the general section in weeks format When I edit the section "0" - Then the field "Custom" matches value "0" - And the field "New value for Section name" matches value "General" + Then the field "Section name" matches value "" + And I should see "General" Scenario: Edit the default name of the general section in weeks format When I edit the section "0" and I fill the form with: - | Custom | 1 | - | New value for Section name | This is the general section | + | Section name | This is the general section | Then I should see "This is the general section" in the "page" "region" Scenario: View the default name of the second section in weeks format When I edit the section "2" - Then the field "Custom" matches value "0" - And the field "New value for Section name" matches value "8 May - 14 May" + Then the field "Section name" matches value "" + And I should see "8 May - 14 May" Scenario: Edit section summary in weeks format When I edit the section "2" and I fill the form with: @@ -47,8 +46,7 @@ Feature: Sections can be edited and deleted in weekly sections format Scenario: Edit section default name in weeks format Given I should see "8 May - 14 May" in the "8 May - 14 May" "section" When I edit the section "2" and I fill the form with: - | Custom | 1 | - | New value for Section name | This is the second week | + | Section name | This is the second week | Then I should see "This is the second week" in the "page" "region" And I should not see "8 May - 14 May" diff --git a/course/lib.php b/course/lib.php index a809df14c61c8..40010f4f5f9f9 100644 --- a/course/lib.php +++ b/course/lib.php @@ -749,49 +749,13 @@ function set_coursemodule_visible($id, $visible, $visibleoncoursepage = 1, bool /** * Changes the course module name * - * @param int $id course module id + * @param int $cmid course module id * @param string $name new value for a name * @return bool whether a change was made */ -function set_coursemodule_name($id, $name) { - global $CFG, $DB; - require_once($CFG->libdir . '/gradelib.php'); - - $cm = get_coursemodule_from_id('', $id, 0, false, MUST_EXIST); - - $module = new \stdClass(); - $module->id = $cm->instance; - - // Escape strings as they would be by mform. - if (!empty($CFG->formatstringstriptags)) { - $module->name = clean_param($name, PARAM_TEXT); - } else { - $module->name = clean_param($name, PARAM_CLEANHTML); - } - if ($module->name === $cm->name || strval($module->name) === '') { - return false; - } - if (\core_text::strlen($module->name) > 255) { - throw new \moodle_exception('maximumchars', 'moodle', '', 255); - } - - $module->timemodified = time(); - $DB->update_record($cm->modname, $module); - $cm->name = $module->name; - \core\event\course_module_updated::create_from_cm($cm)->trigger(); - \course_modinfo::purge_course_module_cache($cm->course, $cm->id); - rebuild_course_cache($cm->course, false, true); - - // Attempt to update the grade item if relevant. - $grademodule = $DB->get_record($cm->modname, array('id' => $cm->instance)); - $grademodule->cmidnumber = $cm->idnumber; - $grademodule->modname = $cm->modname; - grade_update_mod_grades($grademodule); - - // Update calendar events with the new name. - course_module_update_calendar_events($cm->modname, $grademodule, $cm); - - return true; +function set_coursemodule_name($cmid, $name) { + $coursecontext = context_module::instance($cmid)->get_course_context(); + return formatactions::cm($coursecontext->instanceid)->rename($cmid, $name); } /** @@ -1277,70 +1241,21 @@ function course_delete_section_async($section, $forcedeleteifnotempty = true) { * * This function does not check permissions or clean values - this has to be done prior to calling it. * - * @param int|stdClass $course + * @param int|stdClass $courseorid * @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; - $data['timemodified'] = time(); - $DB->update_record('course_sections', $data); - // Invalidate the section cache by given section id. - course_modinfo::purge_course_section_cache_by_id($courseid, $section->id); - rebuild_course_cache($courseid, false, true); - course_get_format($courseid)->update_section_format_options($data); +function course_update_section($courseorid, $section, $data) { + $sectioninfo = get_fast_modinfo($courseorid)->get_section_info_by_id($section->id); + formatactions::section($courseorid)->update($sectioninfo, $data); - // Update fields of the $section object. + // Update $section object fields (for legacy compatibility). + $data = array_diff_key((array) $data, array_flip(['id', 'course', 'section', 'sequence'])); 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); - $cmids = []; - foreach ($modules as $moduleid) { - if ($cm = get_coursemodule_from_id(null, $moduleid, $courseid)) { - $cmids[] = $cm->id; - if ($data['visible']) { - // As we unhide the section, we use the previously saved visibility stored in visibleold. - set_coursemodule_visible($moduleid, $cm->visibleold, $cm->visibleoncoursepage, false); - } else { - // We hide the section, so we hide the module but we store the original state in visibleold. - set_coursemodule_visible($moduleid, 0, $cm->visibleoncoursepage, false); - $DB->set_field('course_modules', 'visibleold', $cm->visible, ['id' => $moduleid]); - } - \core\event\course_module_updated::create_from_cm($cm)->trigger(); - } - } - \course_modinfo::purge_course_modules_cache($courseid, $cmids); - rebuild_course_cache($courseid, false, true); - } } /** diff --git a/course/modlib.php b/course/modlib.php index 5ad339cbe1006..8b99058b60e35 100644 --- a/course/modlib.php +++ b/course/modlib.php @@ -723,6 +723,15 @@ function update_moduleinfo($cm, $moduleinfo, $course, $mform = null) { $cminfo = cm_info::create($cm); $completion->reset_all_state($cminfo); } + + if ($cm->name != $moduleinfo->name) { + $hook = new \core_courseformat\hook\after_cm_name_edited( + get_fast_modinfo($course)->get_cm($cm->id), + $moduleinfo->name + ); + \core\hook\manager::get_instance()->dispatch($hook); + } + $cm->name = $moduleinfo->name; \core\event\course_module_updated::create_from_cm($cm, $modcontext)->trigger(); diff --git a/course/section.php b/course/section.php index 7e6b5ffa72b03..90de6483de368 100644 --- a/course/section.php +++ b/course/section.php @@ -148,7 +148,10 @@ $renderable = $sectionclass->export_for_template($renderer); $controlmenuhtml = $renderable->controlmenu->menu; $PAGE->add_header_action($controlmenuhtml); - $sectionheading = $OUTPUT->render($format->inplace_editable_render_section_name($sectioninfo, false)); + $sectionheading = $OUTPUT->container( + $OUTPUT->render($format->inplace_editable_render_section_name($sectioninfo, false)), + attributes: ['data-for' => 'section_title'], + ); $PAGE->set_heading($sectionheading, false, false); } else { $PAGE->set_heading($sectiontitle); diff --git a/course/tests/behat/behat_course_deprecated.php b/course/tests/behat/behat_course_deprecated.php new file mode 100644 index 0000000000000..f3612acd7910d --- /dev/null +++ b/course/tests/behat/behat_course_deprecated.php @@ -0,0 +1,117 @@ +. + +// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. + +require_once(__DIR__ . '/../../../lib/behat/behat_deprecated_base.php'); + +use Behat\Gherkin\Node\TableNode as TableNode; + +/** + * Steps definitions that are now deprecated and will be removed in the next releases. + * + * This file only contains the steps that previously were in the behat_*.php files in the SAME DIRECTORY. + * When deprecating steps from other components or plugins, create a behat_COMPONENT_deprecated.php + * file in the same directory where the steps were defined. + * + * @package core_course + * @category test + * @copyright 2024 Amaia Anabitarte + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_course_deprecated extends behat_deprecated_base { + /** + * Opens the activity chooser and opens the activity/resource form page. Sections 0 and 1 are also allowed on frontpage. + * + * @Given /^I add a "(?P(?:[^"]|\\")*)" to section "(?P\d+)"$/ + * @param string $activity + * @param int $section + * @throws \Behat\Mink\Exception\ElementNotFoundException Thrown by behat_base::find + * @deprecated Since Moodle 4.4 + */ + public function i_add_to_section($activity, $section) { + $this->deprecated_message([ + 'behat_course::i_add_to_course_section', + 'behat_course::i_add_to_section_using_the_activity_chooser', + ]); + + $this->require_javascript('Please use the \'the following "activity" exists:\' data generator instead.'); + + if ($this->getSession()->getPage()->find('css', 'body#page-site-index') && (int)$section <= 1) { + // We are on the frontpage. + if ($section) { + // Section 1 represents the contents on the frontpage. + $sectionxpath = "//body[@id='page-site-index']" . + "/descendant::div[contains(concat(' ',normalize-space(@class),' '),' sitetopic ')]"; + } else { + // Section 0 represents "Site main menu" block. + $sectionxpath = "//*[contains(concat(' ',normalize-space(@class),' '),' block_site_main_menu ')]"; + } + } else { + // We are inside the course. + $sectionxpath = "//li[@id='section-" . $section . "']"; + } + + // Clicks add activity or resource section link. + $sectionnode = $this->find('xpath', $sectionxpath); + $this->execute('behat_general::i_click_on_in_the', [ + "//button[@data-action='open-chooser' and not(@data-beforemod)]", + 'xpath', + $sectionnode, + 'NodeElement', + ]); + + // Clicks the selected activity if it exists. + $activityliteral = behat_context_helper::escape(ucfirst($activity)); + $activityxpath = "//div[contains(concat(' ', normalize-space(@class), ' '), ' modchooser ')]" . + "/descendant::div[contains(concat(' ', normalize-space(@class), ' '), ' optioninfo ')]" . + "/descendant::div[contains(concat(' ', normalize-space(@class), ' '), ' optionname ')]" . + "[normalize-space(.)=$activityliteral]" . + "/parent::a"; + + $this->execute('behat_general::i_click_on', [$activityxpath, 'xpath']); + } + + /** + * Adds the selected activity/resource filling the form data with the specified field/value pairs. + * + * Sections 0 and 1 are also allowed on frontpage. + * + * @When /^I add a "(?P(?:[^"]|\\")*)" to section "(?P\d+)" and I fill the form with:$/ + * @param string $activity The activity name + * @param int $section The section number + * @param TableNode $data The activity field/value data + * @deprecated Since Moodle 4.4 + */ + public function i_add_to_section_and_i_fill_the_form_with($activity, $section, TableNode $data) { + $this->deprecated_message(['behat_course::i_add_to_course_section_and_i_fill_the_form_with']); + + // Add activity to section. + $this->execute( + "behat_course::i_add_to_section", + [$this->escape($activity), $this->escape($section)] + ); + + // Wait to be redirected. + $this->execute('behat_general::wait_until_the_page_is_ready'); + + // Set form fields. + $this->execute("behat_forms::i_set_the_following_fields_to_these_values", $data); + + // Save course settings. + $this->execute("behat_forms::press_button", get_string('savechangesandreturntocourse')); + } +} diff --git a/course/tests/behat/course_collapse_sections.feature b/course/tests/behat/course_collapse_sections.feature index d2515fac967fe..3bc4f824605bd 100644 --- a/course/tests/behat/course_collapse_sections.feature +++ b/course/tests/behat/course_collapse_sections.feature @@ -64,8 +64,7 @@ Feature: Collapse course sections And I am on site homepage And I turn editing mode on And I click on "Edit" "link" in the "region-main" "region" - And I click on "Custom" "checkbox" - And I set the field "New value for Section name" to "New section name" + And I set the field "Section name" to "New section name" When I press "Save changes" Then "[data-toggle=collapse]" "css_element" should not exist in the "region-main" "region" diff --git a/course/tests/behat/frontpage_topic_section.feature b/course/tests/behat/frontpage_topic_section.feature index 2b1f7cfa8138a..646bb1d1c8fd2 100644 --- a/course/tests/behat/frontpage_topic_section.feature +++ b/course/tests/behat/frontpage_topic_section.feature @@ -25,8 +25,7 @@ Feature: Site home activities section And I am on site homepage And I turn editing mode on And I click on "Edit" "link" in the "region-main" "region" - And I click on "Custom" "checkbox" - And I set the field "New value for Section name" to "New section name" + And I set the field "Section name" to "New section name" When I press "Save changes" And I should see "New section name" in the "region-main" "region" Then I turn editing mode off diff --git a/course/tests/behat/sectionzero_title.feature b/course/tests/behat/sectionzero_title.feature index 11935e8384650..fdbc7313043f1 100644 --- a/course/tests/behat/sectionzero_title.feature +++ b/course/tests/behat/sectionzero_title.feature @@ -36,7 +36,6 @@ Feature: Section 0 default/custom title And "New name for section" "field" should not exist And I set the field "Edit section name" in the "li#section-0" "css_element" to "Edited section 0" And I should see "Edited section 0" in the "page" "region" - And I edit the section "0" and I fill the form with: - | Custom | 0 | + And I set the field "Edit section name" in the "li#section-0" "css_element" to "" And I should not see "Edited section 0" in the "page" "region" And I should see "General" in the "page" "region" diff --git a/course/tests/courselib_test.php b/course/tests/courselib_test.php index 4dcc420907ae3..0fa63e9af942d 100644 --- a/course/tests/courselib_test.php +++ b/course/tests/courselib_test.php @@ -782,19 +782,27 @@ public function test_update_course_section_time_modified() { $this->resetAfterTest(); // Create the course with sections. - $course = $this->getDataGenerator()->create_course(array('numsections' => 10), array('createsections' => true)); - $sections = $DB->get_records('course_sections', array('course' => $course->id)); + $course = $this->getDataGenerator()->create_course( + ['numsections' => 10], + ['createsections' => true] + ); + $sections = $DB->get_records('course_sections', ['course' => $course->id]); // Get the last section's time modified value. $section = array_pop($sections); $oldtimemodified = $section->timemodified; - // Update the section. - $this->waitForSecond(); // Ensuring that the section update occurs at a different timestamp. - course_update_section($course, $section, array()); + // Ensuring that the section update occurs at a different timestamp. + $this->waitForSecond(); + + // The timemodified should only be updated if the section is actually updated. + course_update_section($course, $section, []); + $sectionrecord = $DB->get_record('course_sections', ['id' => $section->id]); + $this->assertEquals($oldtimemodified, $sectionrecord->timemodified); - // Check that the time has changed. - $section = $DB->get_record('course_sections', array('id' => $section->id)); + // Now update something to prove timemodified changes. + course_update_section($course, $section, ['name' => 'New name']); + $section = $DB->get_record('course_sections', ['id' => $section->id]); $newtimemodified = $section->timemodified; $this->assertGreaterThan($oldtimemodified, $newtimemodified); } diff --git a/course/tests/externallib_test.php b/course/tests/externallib_test.php index ce77094a314b3..167204aec4701 100644 --- a/course/tests/externallib_test.php +++ b/course/tests/externallib_test.php @@ -1197,6 +1197,7 @@ public function test_get_course_contents() { ), $module['purpose'] ); $this->assertFalse($module['branded']); + $this->assertStringContainsString('trackingtype', $module['customdata']); // The customdata is JSON encoded. $testexecuted = $testexecuted + 2; } else if ($module['id'] == $labelcm->id and $module['modname'] == 'label') { $cm = $modinfo->cms[$labelcm->id]; diff --git a/enrol/lti/classes/local/ltiadvantage/lib/lti_cookie.php b/enrol/lti/classes/local/ltiadvantage/lib/lti_cookie.php index 1a6b11fe55246..585fdad252325 100644 --- a/enrol/lti/classes/local/ltiadvantage/lib/lti_cookie.php +++ b/enrol/lti/classes/local/ltiadvantage/lib/lti_cookie.php @@ -16,6 +16,7 @@ namespace enrol_lti\local\ltiadvantage\lib; +use auth_lti\local\ltiadvantage\utility\cookie_helper; use Packback\Lti1p3\Interfaces\ICookie; /** @@ -55,6 +56,9 @@ public function setCookie(string $name, string $value, int $exp = 3600, array $o setcookie($name, $value, array_merge($cookieoptions, $samesiteoptions, $options)); + // Necessary, since partitioned can't be set via setcookie yet. + cookie_helper::add_attributes_to_cookie_response_header($name, ['Partitioned']); + // Set a second fallback cookie in the event that "SameSite" is not supported. setcookie('LEGACY_'.$name, $value, array_merge($cookieoptions, $options)); } diff --git a/enrol/lti/classes/output/renderer.php b/enrol/lti/classes/output/renderer.php index 7c17e6e82affc..57e4427ce75b7 100644 --- a/enrol/lti/classes/output/renderer.php +++ b/enrol/lti/classes/output/renderer.php @@ -263,4 +263,19 @@ public function render_registration_view(int $registrationid, string $activetab return parent::render_from_template('enrol_lti/local/ltiadvantage/registration_view', $tcontext); } + + /** + * Render a warning, indicating to the user that cookies are require but couldn't be set. + * + * @return string the html. + */ + public function render_cookies_required_notice(): string { + $notification = new notification(get_string('cookiesarerequiredinfo', 'enrol_lti'), notification::NOTIFY_WARNING, false); + $tcontext = [ + 'heading' => get_string('cookiesarerequired', 'enrol_lti'), + 'notification' => $notification->export_for_template($this), + ]; + + return parent::render_from_template('enrol_lti/local/ltiadvantage/cookies_required_notice', $tcontext); + } } diff --git a/enrol/lti/lang/en/enrol_lti.php b/enrol/lti/lang/en/enrol_lti.php index dce5d50a21a07..64d282374c447 100644 --- a/enrol/lti/lang/en/enrol_lti.php +++ b/enrol/lti/lang/en/enrol_lti.php @@ -31,6 +31,10 @@ $string['addtogradebook'] = 'Add to gradebook'; $string['allowframeembedding'] = 'Note: It is recommended that the site administration setting \'Allow frame embedding\' is enabled, so that tools are displayed within a frame rather than in a new window.'; $string['authltimustbeenabled'] = 'Note: This plugin requires the LTI authentication plugin to be enabled too.'; +$string['cookiesarerequired'] = 'Cookies are blocked by your browser'; +$string['cookiesarerequiredinfo'] = 'This tool can\'t be launched because your browser seems to be blocking third-party cookies. +

+To use this tool, try changing your browser cookie settings or using a different browser.'; $string['copiedtoclipboard'] = '{$a} copied to clipboard'; $string['copytoclipboard'] = 'Copy to clipboard'; $string['couldnotestablishproxy'] = 'Could not establish proxy with consumer.'; diff --git a/enrol/lti/login.php b/enrol/lti/login.php index 03fea0bdee3a2..935be0fe351bd 100644 --- a/enrol/lti/login.php +++ b/enrol/lti/login.php @@ -26,6 +26,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use auth_lti\local\ltiadvantage\utility\cookie_helper; use enrol_lti\local\ltiadvantage\lib\lti_cookie; use enrol_lti\local\ltiadvantage\lib\issuer_database; use enrol_lti\local\ltiadvantage\lib\launch_cache_session; @@ -76,6 +77,30 @@ $_REQUEST['client_id'] = $_REQUEST['id']; } +// Before beginning the OIDC authentication, ensure the MoodleSession cookie can be used. Browser-specific steps may need to be +// taken to set cookies in 3rd party contexts. Skip the check if the user is already auth'd. This means that either cookies aren't +// an issue in the current browser/launch context. +if (!isloggedin()) { + cookie_helper::do_cookie_check(new moodle_url('/enrol/lti/login.php', [ + 'iss' => $iss, + 'login_hint' => $loginhint, + 'target_link_uri' => $targetlinkuri, + 'lti_message_hint' => $ltimessagehint, + 'client_id' => $_REQUEST['client_id'], + ])); + if (!cookie_helper::cookies_supported()) { + global $OUTPUT, $PAGE; + $PAGE->set_context(context_system::instance()); + $PAGE->set_url(new moodle_url('/enrol/lti/login.php')); + $PAGE->set_pagelayout('popup'); + echo $OUTPUT->header(); + $renderer = $PAGE->get_renderer('enrol_lti'); + echo $renderer->render_cookies_required_notice(); + echo $OUTPUT->footer(); + die(); + } +} + // Now, do the OIDC login. $redirecturl = LtiOidcLogin::new( new issuer_database(new application_registration_repository(), new deployment_repository()), diff --git a/enrol/lti/templates/local/ltiadvantage/cookies_required_notice.mustache b/enrol/lti/templates/local/ltiadvantage/cookies_required_notice.mustache new file mode 100644 index 0000000000000..2b14840d4af0e --- /dev/null +++ b/enrol/lti/templates/local/ltiadvantage/cookies_required_notice.mustache @@ -0,0 +1,50 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template enrol_lti/local/ltiadvantage/cookies_required_notice + + Displays a notice, reporting that cookies are required but couldn't be set. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * heading + * notification + + Example context (json): + { + "heading": "Cookies are required", + "notification": { + "message": "You appear to be using an unsupported browser...", + "extraclasses": "", + "announce": true, + "closebutton": false, + "issuccess": false, + "isinfo": false, + "iswarning": true, + "iserror": false + } + } +}} +

{{heading}}

+{{#notification}} + {{> core/notification}} +{{/notification}} diff --git a/grade/lib.php b/grade/lib.php index 8878dbd3dd76f..81f77e8eca620 100644 --- a/grade/lib.php +++ b/grade/lib.php @@ -985,7 +985,8 @@ function print_grade_page_head(int $courseid, string $active_type, ?string $acti $renderer = $PAGE->get_renderer('core_grades'); // If the user is viewing their own grade report, no need to show the "Message" // and "Add to contact" buttons in the user heading. - $showuserbuttons = $user->id != $USER->id; + $showuserbuttons = $user->id != $USER->id && !empty($CFG->messaging) && + has_capability('moodle/site:sendmessage', $PAGE->context); $output = $renderer->user_heading($user, $courseid, $showuserbuttons); } else if (!empty($heading)) { $output = $OUTPUT->heading($heading); diff --git a/grade/report/grader/lib.php b/grade/report/grader/lib.php index dbe4a93f2fdb3..09c6d7cedf3e4 100644 --- a/grade/report/grader/lib.php +++ b/grade/report/grader/lib.php @@ -740,11 +740,17 @@ public function get_left_rows($displayaverages) { $userrow->cells[] = $usercell; foreach ($extrafields as $field) { + $fieldcellcontent = s($user->$field); + if ($field === 'country') { + $countries = get_string_manager()->get_list_of_countries(); + $fieldcellcontent = $countries[$user->$field] ?? $fieldcellcontent; + } + $fieldcell = new html_table_cell(); $fieldcell->attributes['class'] = 'userfield user' . $field; $fieldcell->attributes['data-col'] = $field; $fieldcell->header = false; - $fieldcell->text = html_writer::tag('div', s($user->{$field}), [ + $fieldcell->text = html_writer::tag('div', $fieldcellcontent, [ 'data-collapse' => 'content' ]); diff --git a/lang/en/admin.php b/lang/en/admin.php index d6d570dac53a6..0ce05488c4e05 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -1622,11 +1622,6 @@ $string['cacheapplication'] = 'Application cache'; $string['cacheapplicationhelp'] = 'Cached items are shared among all users and expire by a determined time to live (ttl).'; -// Deprecated since Moodle 4.0. -$string['coursepage'] = 'Course page'; -$string['mediapluginswf'] = 'Enable .swf filter'; -$string['mediapluginswfnote'] = 'As a default security measure, normal users should not be allowed to embed swf flash files.'; - // Deprecated since Moodle 4.1. $string['multilangforceold'] = 'Force old multilang syntax: <span> without the class="multilang" and <lang>'; diff --git a/lang/en/badges.php b/lang/en/badges.php index aa06edae44ea4..04f87eecc42eb 100644 --- a/lang/en/badges.php +++ b/lang/en/badges.php @@ -592,11 +592,6 @@ $string['year'] = 'Year(s)'; $string['includeauthdetails'] = "Include authentication details with the backpack"; -// Deprecated since Moodle 4.0. -$string['evidence'] = 'Evidence'; -$string['recipientdetails'] = 'Recipient details'; -$string['recipientidentificationproblem'] = 'Cannot find a recipient of this badge among the existing users.'; - // Deprecated since Moodle 4.3. $string['backpackemail'] = 'Email address'; $string['backpackemail_help'] = 'The email address associated with your backpack. While you are connected, any badges earned on this site will be associated with this email address.'; diff --git a/lang/en/calendar.php b/lang/en/calendar.php index 2239d62397343..ebff78538aa61 100644 --- a/lang/en/calendar.php +++ b/lang/en/calendar.php @@ -192,6 +192,7 @@ $string['namewithsource'] = '{$a->name} ({$a->source})'; $string['never'] = 'Never'; $string['newevent'] = 'New event'; +$string['newmonthannouncement'] = 'Calendar is now set to {$a}.'; $string['notitle'] = 'no title'; $string['noupcomingevents'] = 'There are no upcoming events'; $string['nocalendarsubscriptions'] = 'No calendar subscriptions yet. Do you want to {$a}'; @@ -283,8 +284,3 @@ $string['yesterday'] = 'Yesterday'; $string['youcandeleteallrepeats'] = 'This event is part of a repeating event series. You can delete this event only, or all {$a} events in the series at once.'; $string['yoursubscriptions'] = 'Imported calendars'; - -// Deprecated since Moodle 4.0. -$string['calendarurl'] = 'Calendar URL: {$a}'; -$string['importfrominstructions'] = 'Please provide either a URL to a remote calendar, or upload a file.'; -$string['monthlyview'] = 'Monthly view'; diff --git a/lang/en/competency.php b/lang/en/competency.php index 55fc9b76ff39a..0c1ca6e7c9966 100644 --- a/lang/en/competency.php +++ b/lang/en/competency.php @@ -201,5 +201,3 @@ $string['usercompetencystatus_waitingforreview'] = 'Waiting for review'; $string['userplans'] = 'Learning plans'; -// Deprecated since Moodle 4.0. -$string['invalidpersistenterror'] = 'Error: {$a}'; diff --git a/lang/en/completion.php b/lang/en/completion.php index 8d924d9f05911..5a148022ef59e 100644 --- a/lang/en/completion.php +++ b/lang/en/completion.php @@ -255,9 +255,6 @@ $string['xdays'] = '{$a} days'; $string['youmust'] = 'You must'; -// Deprecated since Moodle 4.0. -$string['yourprogress'] = 'Your progress'; - // Deprecated since Moodle 4.3. $string['editcoursecompletionsettings'] = 'Edit course completion settings'; $string['completiondefault'] = 'Default completion tracking'; diff --git a/lang/en/contentbank.php b/lang/en/contentbank.php index 06a010e09bc61..a5395a7804ac1 100644 --- a/lang/en/contentbank.php +++ b/lang/en/contentbank.php @@ -98,6 +98,3 @@ $string['visibilitysetpublic'] = 'Make public'; $string['visibilitysetunlisted'] = 'Make unlisted'; $string['visibilitytitleunlisted'] = '{$a} (Unlisted)'; - -// Deprecated since 4.0. -$string['close'] = 'Close'; diff --git a/lang/en/deprecated.txt b/lang/en/deprecated.txt index 79d40637f4ca5..e716cbf526e78 100644 --- a/lang/en/deprecated.txt +++ b/lang/en/deprecated.txt @@ -7,33 +7,6 @@ myprofile,core viewallmyentries,core_blog searchallavailablecourses_desc,core_admin search:mycourse,core_search -calendarurl,core_calendar -yourprogress,core_completion -importfrominstructions,core_calendar -proceedtocourse,core_enrol -coursepage,core_admin -invalidpersistenterror,core_competency -mediapluginswf,core_admin -mediapluginswfnote,core_admin -createuserandpass,core -supplyinfo,core -monthlyview,core_calendar -evidence,core_badges -recipientdetails,core_badges -recipientidentificationproblem,core_badges -navmethod,core_grades -dropdown,core_grades -tabs,core_grades -combo,core_grades -defaults,core_message -loggedin_help,core_message -loggedindescription,core_message -loggedoff_help,core_message -loggedoffdescription,core_message -sendingvia,core_message -sendingviawhen,core_message -close,core_contentbank -notflagged,core_question cannotswitcheditmodeon,core_error multilangforceold,core_admin nopermissionmove,core_question diff --git a/lang/en/enrol.php b/lang/en/enrol.php index f3243be8736d6..014e59d32c45c 100644 --- a/lang/en/enrol.php +++ b/lang/en/enrol.php @@ -171,6 +171,3 @@ $string['privacy:metadata:user_enrolments:userid'] = 'The ID of the user'; $string['youenrolledincourse'] = 'You are enrolled in the course.'; $string['youunenrolledfromcourse'] = 'You are unenrolled from the course "{$a}".'; - -// Deprecated since Moodle 4.0. -$string['proceedtocourse'] = 'Proceed to course content'; diff --git a/lang/en/grades.php b/lang/en/grades.php index 9649e94b6c9d4..4c7f7819a588c 100644 --- a/lang/en/grades.php +++ b/lang/en/grades.php @@ -892,12 +892,6 @@ $string['aria-toggledropdown'] = 'Toggle the following dropdown'; $string['aria:dropdowngrades'] = 'Grade items found'; -// Deprecated since Moodle 4.0. -$string['navmethod'] = 'Navigation method'; -$string['dropdown'] = 'Drop-down menu'; -$string['tabs'] = 'Tabs'; -$string['combo'] = 'Tabs and drop-down menu'; - // Deprecated since Moodle 4.2. $string['showanalysisicon'] = 'Show grade analysis icon'; $string['showanalysisicon_desc'] = 'Whether to show grade analysis icon by default. If the activity module supports it, the grade analysis icon links to a page with more detailed explanation of the grade and how it was obtained.'; diff --git a/lang/en/message.php b/lang/en/message.php index 967a2d60a6676..aac4d96aded41 100644 --- a/lang/en/message.php +++ b/lang/en/message.php @@ -274,12 +274,3 @@ $string['you'] = 'You:'; $string['youhaveblockeduser'] = 'You have blocked this user.'; $string['yourcontactrequestpending'] = 'Your contact request is pending with {$a}'; - -// Deprecated since Moodle 4.0. -$string['defaults'] = 'Defaults'; -$string['loggedin_help'] = 'Configure how you would like to receive notifications when you are logged into Moodle'; -$string['loggedindescription'] = 'When you are logged into Moodle'; -$string['loggedoff_help'] = 'Configure how you would like to receive notifications when you are not logged into Moodle'; -$string['loggedoffdescription'] = 'When you are not logged into Moodle'; -$string['sendingvia'] = 'Sending "{$a->provider}" via "{$a->processor}"'; -$string['sendingviawhen'] = 'Sending "{$a->provider}" via "{$a->processor}" when {$a->state}'; diff --git a/lang/en/moodle.php b/lang/en/moodle.php index 21bfc94f34d81..072a9f4f8718c 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -62,6 +62,7 @@ $string['addedtogroupnot'] = 'Not added to group "{$a}"'; $string['addedtogroupnotenrolled'] = 'Not added to group "{$a}", because not enrolled in course'; $string['addfilehere'] = 'Drop files here to add them at the bottom of this section'; +$string['addfilesdrop'] = 'You can drag and drop files here to upload or click to select.'; $string['addinganew'] = 'New {$a}'; $string['additionalcustomnav'] = 'Additional custom navigation'; $string['addnew'] = 'Add a new {$a}'; @@ -2459,11 +2460,6 @@ $string['zippingbackup'] = 'Zipping backup'; $string['deprecatedeventname'] = '{$a} (no longer in use)'; -// Deprecated since Moodle 4.0. -$string['createuserandpass'] = 'Choose your username and password'; -$string['descriptiona'] = 'Description: {$a}'; -$string['supplyinfo'] = 'More details'; - // Deprecated since Moodle 4.3. $string['clicktochangeinbrackets'] = '{$a} (Click to change)'; $string['modshowcmtitle'] = 'Show activity'; diff --git a/lang/en/question.php b/lang/en/question.php index cef4b112288c6..70f91188fb3ad 100644 --- a/lang/en/question.php +++ b/lang/en/question.php @@ -520,8 +520,5 @@ $string['versioninfolatest'] = 'Version {$a->version} (latest)'; $string['question_version'] = 'Question version'; -// Deprecated since Moodle 4.0. -$string['notflagged'] = 'Not flagged'; - // Deprecated since Moodle 4.1. $string['nopermissionmove'] = 'You don\'t have permission to move questions from here. You must save the question in this category or save it as a new question.'; diff --git a/lang/en/reportbuilder.php b/lang/en/reportbuilder.php index ae2cd8ac617d7..2ba2d394d87f4 100644 --- a/lang/en/reportbuilder.php +++ b/lang/en/reportbuilder.php @@ -81,6 +81,7 @@ $string['courseshortnamewithlink'] = 'Course short name with link'; $string['courseselect'] = 'Select course'; $string['customfieldcolumn'] = '{$a}'; +$string['customreport'] = 'Custom report'; $string['customreports'] = 'Custom reports'; $string['customreportslimit'] = 'Custom reports limit'; $string['customreportslimit_desc'] = 'The number of custom reports may be limited for performance reasons. If set to zero, then there is no limit.'; @@ -263,6 +264,7 @@ $string['sorting_help'] = 'You can set the initial sort order of columns in the report, which can then be changed by users by clicking on column names.'; $string['switchedit'] = 'Switch to edit mode'; $string['switchpreview'] = 'Switch to preview mode'; +$string['tagarea_reportbuilder_report'] = 'Custom reports'; $string['tasksendschedule'] = 'Send report schedule'; $string['tasksendschedules'] = 'Send report schedules'; $string['timeadded'] = 'Time added'; diff --git a/lib/amd/build/dropzone.min.js b/lib/amd/build/dropzone.min.js new file mode 100644 index 0000000000000..1024dbc737700 --- /dev/null +++ b/lib/amd/build/dropzone.min.js @@ -0,0 +1,11 @@ +define("core/dropzone",["exports","core/log","core/templates"],(function(_exports,_log,_templates){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} +/** + * JavaScript to handle dropzone. + * + * @module core/dropzone + * @copyright 2024 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @since 4.4 + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_log=_interopRequireDefault(_log),_templates=_interopRequireDefault(_templates);var _default=class{constructor(dropZoneElement,fileTypes,callback){this.init(dropZoneElement,fileTypes,callback)}init(dropZoneElement,fileTypes,callback){return dropZoneElement.addEventListener("dragover",(e=>{const dropZone=this.getDropZoneFromEvent(e);dropZone&&(e.preventDefault(),dropZone.classList.add("dragover"))})),dropZoneElement.addEventListener("dragleave",(e=>{const dropZone=this.getDropZoneFromEvent(e);dropZone&&(e.preventDefault(),dropZone.classList.remove("dragover"))})),dropZoneElement.addEventListener("drop",(e=>{const dropZone=this.getDropZoneFromEvent(e);dropZone&&(e.preventDefault(),dropZone.classList.remove("dragover"),callback(e.dataTransfer.files))})),dropZoneElement.addEventListener("click",(e=>{this.getDropZoneContainerFromEvent(e)&&this.getFileElementFromEvent(e).click()})),dropZoneElement.addEventListener("click",(e=>{e.target.closest(".dropzone-sr-only-focusable")&&this.getFileElementFromEvent(e).click()})),dropZoneElement.addEventListener("change",(e=>{const fileInput=this.getFileElementFromEvent(e);fileInput&&(e.preventDefault(),callback(fileInput.files))})),this.renderDropZone(dropZoneElement,fileTypes),_log.default.info("Dropzone has been initialized!"),this}getDropZoneFromEvent(e){return e.target.closest(".dropzone")}getDropZoneContainerFromEvent(e){return e.target.closest(".dropzone-container")}getFileElementFromEvent(e){return e.target.closest(".dropzone-container").querySelector(".drop-zone-fileinput")}async renderDropZone(dropZoneElement,fileTypes){dropZoneElement.innerHTML=await _templates.default.render("core/dropzone",{fileTypes:fileTypes})}};return _exports.default=_default,_exports.default})); + +//# sourceMappingURL=dropzone.min.js.map \ No newline at end of file diff --git a/lib/amd/build/dropzone.min.js.map b/lib/amd/build/dropzone.min.js.map new file mode 100644 index 0000000000000..bbef7dda7204f --- /dev/null +++ b/lib/amd/build/dropzone.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"dropzone.min.js","sources":["../src/dropzone.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * JavaScript to handle dropzone.\n *\n * @module core/dropzone\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 4.4\n */\n\nimport Log from 'core/log';\nimport Templates from 'core/templates';\n\n/**\n * A dropzone.\n *\n * @class core/dropzone\n */\nconst DropZone = class {\n\n /**\n * Constructor.\n *\n * @param {Element} dropZoneElement The element to render the dropzone.\n * @param {String} fileTypes The file types that are allowed to be uploaded. Example: image/*\n * @param {CallableFunction} callback The function to call when a file is dropped.\n */\n constructor(dropZoneElement, fileTypes, callback) {\n this.init(dropZoneElement, fileTypes, callback);\n }\n\n /**\n * Initialise the dropzone.\n *\n * @param {Element} dropZoneElement The element to render the dropzone.\n * @param {String} fileTypes The file types that are allowed to be uploaded. Example: image/*\n * @param {CallableFunction} callback The function to call when a file is dropped.\n * @returns {DropZone}\n */\n init(dropZoneElement, fileTypes, callback) {\n dropZoneElement.addEventListener('dragover', (e) => {\n const dropZone = this.getDropZoneFromEvent(e);\n if (!dropZone) {\n return;\n }\n e.preventDefault();\n dropZone.classList.add('dragover');\n });\n dropZoneElement.addEventListener('dragleave', (e) => {\n const dropZone = this.getDropZoneFromEvent(e);\n if (!dropZone) {\n return;\n }\n e.preventDefault();\n dropZone.classList.remove('dragover');\n });\n dropZoneElement.addEventListener('drop', (e) => {\n const dropZone = this.getDropZoneFromEvent(e);\n if (!dropZone) {\n return;\n }\n e.preventDefault();\n dropZone.classList.remove('dragover');\n callback(e.dataTransfer.files);\n });\n dropZoneElement.addEventListener('click', (e) => {\n const dropZoneContainer = this.getDropZoneContainerFromEvent(e);\n if (!dropZoneContainer) {\n return;\n }\n this.getFileElementFromEvent(e).click();\n });\n dropZoneElement.addEventListener('click', (e) => {\n const dropZoneLabel = e.target.closest('.dropzone-sr-only-focusable');\n if (!dropZoneLabel) {\n return;\n }\n this.getFileElementFromEvent(e).click();\n });\n dropZoneElement.addEventListener('change', (e) => {\n const fileInput = this.getFileElementFromEvent(e);\n if (fileInput) {\n e.preventDefault();\n callback(fileInput.files);\n }\n });\n this.renderDropZone(dropZoneElement, fileTypes);\n Log.info('Dropzone has been initialized!');\n return this;\n }\n\n /**\n * Get the dropzone.\n *\n * @param {Event} e The event.\n * @returns {HTMLElement|bool}\n */\n getDropZoneFromEvent(e) {\n return e.target.closest('.dropzone');\n }\n\n /**\n * Get the dropzone container.\n *\n * @param {Event} e The event.\n * @returns {HTMLElement|bool}\n */\n getDropZoneContainerFromEvent(e) {\n return e.target.closest('.dropzone-container');\n }\n\n /**\n * Get the file element.\n *\n * @param {Event} e The event.\n * @returns {HTMLElement|bool}\n */\n getFileElementFromEvent(e) {\n return e.target.closest('.dropzone-container').querySelector('.drop-zone-fileinput');\n }\n\n /**\n * Render the dropzone.\n *\n * @param {Element} dropZoneElement The element to render the dropzone.\n * @param {String} fileTypes The file types that are allowed to be uploaded.\n * @returns {Promise}\n */\n async renderDropZone(dropZoneElement, fileTypes) {\n dropZoneElement.innerHTML = await Templates.render('core/dropzone', {\n fileTypes,\n });\n }\n};\n\nexport default DropZone;\n"],"names":["constructor","dropZoneElement","fileTypes","callback","init","addEventListener","e","dropZone","this","getDropZoneFromEvent","preventDefault","classList","add","remove","dataTransfer","files","getDropZoneContainerFromEvent","getFileElementFromEvent","click","target","closest","fileInput","renderDropZone","info","querySelector","innerHTML","Templates","render"],"mappings":";;;;;;;;kLAgCiB,MASbA,YAAYC,gBAAiBC,UAAWC,eAC/BC,KAAKH,gBAAiBC,UAAWC,UAW1CC,KAAKH,gBAAiBC,UAAWC,iBAC7BF,gBAAgBI,iBAAiB,YAAaC,UACpCC,SAAWC,KAAKC,qBAAqBH,GACtCC,WAGLD,EAAEI,iBACFH,SAASI,UAAUC,IAAI,gBAE3BX,gBAAgBI,iBAAiB,aAAcC,UACrCC,SAAWC,KAAKC,qBAAqBH,GACtCC,WAGLD,EAAEI,iBACFH,SAASI,UAAUE,OAAO,gBAE9BZ,gBAAgBI,iBAAiB,QAASC,UAChCC,SAAWC,KAAKC,qBAAqBH,GACtCC,WAGLD,EAAEI,iBACFH,SAASI,UAAUE,OAAO,YAC1BV,SAASG,EAAEQ,aAAaC,WAE5Bd,gBAAgBI,iBAAiB,SAAUC,IACbE,KAAKQ,8BAA8BV,SAIxDW,wBAAwBX,GAAGY,WAEpCjB,gBAAgBI,iBAAiB,SAAUC,IACjBA,EAAEa,OAAOC,QAAQ,qCAIlCH,wBAAwBX,GAAGY,WAEpCjB,gBAAgBI,iBAAiB,UAAWC,UAClCe,UAAYb,KAAKS,wBAAwBX,GAC3Ce,YACAf,EAAEI,iBACFP,SAASkB,UAAUN,gBAGtBO,eAAerB,gBAAiBC,wBACjCqB,KAAK,kCACFf,KASXC,qBAAqBH,UACVA,EAAEa,OAAOC,QAAQ,aAS5BJ,8BAA8BV,UACnBA,EAAEa,OAAOC,QAAQ,uBAS5BH,wBAAwBX,UACbA,EAAEa,OAAOC,QAAQ,uBAAuBI,cAAc,6CAU5CvB,gBAAiBC,WAClCD,gBAAgBwB,gBAAkBC,mBAAUC,OAAO,gBAAiB,CAChEzB,UAAAA"} \ No newline at end of file diff --git a/lib/amd/src/dropzone.js b/lib/amd/src/dropzone.js new file mode 100644 index 0000000000000..5425bcd41c69a --- /dev/null +++ b/lib/amd/src/dropzone.js @@ -0,0 +1,150 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * JavaScript to handle dropzone. + * + * @module core/dropzone + * @copyright 2024 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @since 4.4 + */ + +import Log from 'core/log'; +import Templates from 'core/templates'; + +/** + * A dropzone. + * + * @class core/dropzone + */ +const DropZone = class { + + /** + * Constructor. + * + * @param {Element} dropZoneElement The element to render the dropzone. + * @param {String} fileTypes The file types that are allowed to be uploaded. Example: image/* + * @param {CallableFunction} callback The function to call when a file is dropped. + */ + constructor(dropZoneElement, fileTypes, callback) { + this.init(dropZoneElement, fileTypes, callback); + } + + /** + * Initialise the dropzone. + * + * @param {Element} dropZoneElement The element to render the dropzone. + * @param {String} fileTypes The file types that are allowed to be uploaded. Example: image/* + * @param {CallableFunction} callback The function to call when a file is dropped. + * @returns {DropZone} + */ + init(dropZoneElement, fileTypes, callback) { + dropZoneElement.addEventListener('dragover', (e) => { + const dropZone = this.getDropZoneFromEvent(e); + if (!dropZone) { + return; + } + e.preventDefault(); + dropZone.classList.add('dragover'); + }); + dropZoneElement.addEventListener('dragleave', (e) => { + const dropZone = this.getDropZoneFromEvent(e); + if (!dropZone) { + return; + } + e.preventDefault(); + dropZone.classList.remove('dragover'); + }); + dropZoneElement.addEventListener('drop', (e) => { + const dropZone = this.getDropZoneFromEvent(e); + if (!dropZone) { + return; + } + e.preventDefault(); + dropZone.classList.remove('dragover'); + callback(e.dataTransfer.files); + }); + dropZoneElement.addEventListener('click', (e) => { + const dropZoneContainer = this.getDropZoneContainerFromEvent(e); + if (!dropZoneContainer) { + return; + } + this.getFileElementFromEvent(e).click(); + }); + dropZoneElement.addEventListener('click', (e) => { + const dropZoneLabel = e.target.closest('.dropzone-sr-only-focusable'); + if (!dropZoneLabel) { + return; + } + this.getFileElementFromEvent(e).click(); + }); + dropZoneElement.addEventListener('change', (e) => { + const fileInput = this.getFileElementFromEvent(e); + if (fileInput) { + e.preventDefault(); + callback(fileInput.files); + } + }); + this.renderDropZone(dropZoneElement, fileTypes); + Log.info('Dropzone has been initialized!'); + return this; + } + + /** + * Get the dropzone. + * + * @param {Event} e The event. + * @returns {HTMLElement|bool} + */ + getDropZoneFromEvent(e) { + return e.target.closest('.dropzone'); + } + + /** + * Get the dropzone container. + * + * @param {Event} e The event. + * @returns {HTMLElement|bool} + */ + getDropZoneContainerFromEvent(e) { + return e.target.closest('.dropzone-container'); + } + + /** + * Get the file element. + * + * @param {Event} e The event. + * @returns {HTMLElement|bool} + */ + getFileElementFromEvent(e) { + return e.target.closest('.dropzone-container').querySelector('.drop-zone-fileinput'); + } + + /** + * Render the dropzone. + * + * @param {Element} dropZoneElement The element to render the dropzone. + * @param {String} fileTypes The file types that are allowed to be uploaded. + * @returns {Promise} + */ + async renderDropZone(dropZoneElement, fileTypes) { + dropZoneElement.innerHTML = await Templates.render('core/dropzone', { + fileTypes, + }); + } +}; + +export default DropZone; diff --git a/lib/classes/hook/manager.php b/lib/classes/hook/manager.php index 68475fa670733..259a7a27539c4 100644 --- a/lib/classes/hook/manager.php +++ b/lib/classes/hook/manager.php @@ -82,17 +82,34 @@ public static function get_instance(): manager { * Factory method for testing of hook manager in PHPUnit tests. * * @param array $componentfiles list of hook callback files for each component. + * @param bool $persist If true, the test instance will be stored in self::$instance. Be sure to call $this->resetAfterTest() + * in your test if you use this. * @return self */ - public static function phpunit_get_instance(array $componentfiles): manager { + public static function phpunit_get_instance(array $componentfiles, bool $persist = false): manager { if (!PHPUNIT_TEST) { throw new \coding_exception('Invalid call of manager::phpunit_get_instance() outside of tests'); } $instance = new self(); $instance->load_callbacks($componentfiles); + if ($persist) { + self::$instance = $instance; + } return $instance; } + /** + * Reset self::$instance so that future calls to ::get_instance() will return a regular instance. + * + * @return void + */ + public static function phpunit_reset_instance(): void { + if (!PHPUNIT_TEST) { + throw new \coding_exception('Invalid call of manager::phpunit_reset_instance() outside of tests'); + } + self::$instance = null; + } + /** * Override hook callbacks for testing purposes. * @@ -576,9 +593,25 @@ private function normalise_callback(string $component, array $callback): ?string * * @param string $plugincallback short callback name without the component prefix * @return bool + * @deprecated in favour of get_hooks_deprecating_plugin_callback since Moodle 4.4. + * @todo Remove in Moodle 4.8 (MDL-80327). */ public function is_deprecated_plugin_callback(string $plugincallback): bool { - return isset($this->alldeprecations[$plugincallback]); + debugging( + 'is_deprecated_plugin_callback method is deprecated, use get_hooks_deprecating_plugin_callback instead.', + DEBUG_DEVELOPER + ); + return (bool)$this->get_hooks_deprecating_plugin_callback($plugincallback); + } + + /** + * If the plugin callback from lib.php is deprecated by any hooks, return the hooks' classnames. + * + * @param string $plugincallback short callback name without the component prefix + * @return ?array + */ + public function get_hooks_deprecating_plugin_callback(string $plugincallback): ?array { + return $this->alldeprecations[$plugincallback] ?? null; } /** diff --git a/lib/classes/output/icon_system_fontawesome.php b/lib/classes/output/icon_system_fontawesome.php index 95f4ee4d412ce..180d15def4001 100644 --- a/lib/classes/output/icon_system_fontawesome.php +++ b/lib/classes/output/icon_system_fontawesome.php @@ -218,6 +218,7 @@ public function get_core_icon_map() { 'core:i/chartbar' => 'fa-chart-bar', 'core:i/course' => 'fa-graduation-cap', 'core:i/courseevent' => 'fa-graduation-cap', + 'core:i/cloudupload' => 'fa-cloud-upload', 'core:i/customfield' => 'fa-hand-o-right', 'core:i/db' => 'fa-database', 'core:i/delete' => 'fa-trash', diff --git a/lib/db/tag.php b/lib/db/tag.php index 22e125ecbba3c..834e53c2f8496 100644 --- a/lib/db/tag.php +++ b/lib/db/tag.php @@ -93,4 +93,10 @@ 'callback' => 'badge_get_tagged_badges', 'callbackfile' => '/badges/lib.php', ], + [ + 'itemtype' => 'reportbuilder_report', + 'component' => 'core_reportbuilder', + 'callback' => 'core_reportbuilder_get_tagged_reports', + 'callbackfile' => '/reportbuilder/lib.php', + ], ]; diff --git a/lib/editor/atto/plugins/recordrtc/lang/en/atto_recordrtc.php b/lib/editor/atto/plugins/recordrtc/lang/en/atto_recordrtc.php index 56afcedc33767..8d7cd19080f77 100644 --- a/lib/editor/atto/plugins/recordrtc/lang/en/atto_recordrtc.php +++ b/lib/editor/atto/plugins/recordrtc/lang/en/atto_recordrtc.php @@ -78,7 +78,3 @@ $string['videobitrate'] = 'Video bitrate'; $string['videobitrate_desc'] = 'Quality of video recording (larger number means higher quality)'; $string['videortc'] = 'Record video'; - -// Deprecated since Moodle 4.0. -$string['timelimit'] = 'Time limit in seconds'; -$string['timelimit_desc'] = 'Maximum recording length allowed for the audio/video clips'; diff --git a/lib/editor/atto/plugins/recordrtc/lang/en/deprecated.txt b/lib/editor/atto/plugins/recordrtc/lang/en/deprecated.txt deleted file mode 100644 index c27b09f66838c..0000000000000 --- a/lib/editor/atto/plugins/recordrtc/lang/en/deprecated.txt +++ /dev/null @@ -1,2 +0,0 @@ -timelimit,atto_recordrtc -timelimit_desc,atto_recordrtc diff --git a/lib/fonts/fa-brands-400.ttf b/lib/fonts/fa-brands-400.ttf index 989f323b1e32b..5efb1d4f96407 100644 Binary files a/lib/fonts/fa-brands-400.ttf and b/lib/fonts/fa-brands-400.ttf differ diff --git a/lib/fonts/fa-brands-400.woff2 b/lib/fonts/fa-brands-400.woff2 index 19f04b901ede7..36fbda7d334c3 100644 Binary files a/lib/fonts/fa-brands-400.woff2 and b/lib/fonts/fa-brands-400.woff2 differ diff --git a/lib/fonts/fa-regular-400.ttf b/lib/fonts/fa-regular-400.ttf index 201cc58b85387..838b4e2cfec17 100644 Binary files a/lib/fonts/fa-regular-400.ttf and b/lib/fonts/fa-regular-400.ttf differ diff --git a/lib/fonts/fa-regular-400.woff2 b/lib/fonts/fa-regular-400.woff2 index a395e91bbff38..b6cabbacb67f4 100644 Binary files a/lib/fonts/fa-regular-400.woff2 and b/lib/fonts/fa-regular-400.woff2 differ diff --git a/lib/fonts/fa-solid-900.ttf b/lib/fonts/fa-solid-900.ttf index 1920af1e526a2..ec24749db906d 100644 Binary files a/lib/fonts/fa-solid-900.ttf and b/lib/fonts/fa-solid-900.ttf differ diff --git a/lib/fonts/fa-solid-900.woff2 b/lib/fonts/fa-solid-900.woff2 index a9f37fd1364e9..824d518eb4cbb 100644 Binary files a/lib/fonts/fa-solid-900.woff2 and b/lib/fonts/fa-solid-900.woff2 differ diff --git a/lib/fonts/fa-v4compatibility.ttf b/lib/fonts/fa-v4compatibility.ttf index 4c4c5b3ee3214..b175aa8ece8b1 100644 Binary files a/lib/fonts/fa-v4compatibility.ttf and b/lib/fonts/fa-v4compatibility.ttf differ diff --git a/lib/fonts/fa-v4compatibility.woff2 b/lib/fonts/fa-v4compatibility.woff2 index 507a2ff51cc47..e09b5a55009f8 100644 Binary files a/lib/fonts/fa-v4compatibility.woff2 and b/lib/fonts/fa-v4compatibility.woff2 differ diff --git a/lib/htmlpurifier/HTMLPurifier.php b/lib/htmlpurifier/HTMLPurifier.php index 26f061276f7cf..5c14a335d65cf 100644 --- a/lib/htmlpurifier/HTMLPurifier.php +++ b/lib/htmlpurifier/HTMLPurifier.php @@ -19,7 +19,7 @@ */ /* - HTML Purifier 4.15.0 - Standards Compliant HTML Filtering + HTML Purifier 4.17.0 - Standards Compliant HTML Filtering Copyright (C) 2006-2008 Edward Z. Yang This library is free software; you can redistribute it and/or @@ -58,12 +58,12 @@ class HTMLPurifier * Version of HTML Purifier. * @type string */ - public $version = '4.15.0'; + public $version = '4.17.0'; /** * Constant with version of HTML Purifier. */ - const VERSION = '4.15.0'; + const VERSION = '4.17.0'; /** * Global configuration object. diff --git a/lib/htmlpurifier/HTMLPurifier/AttrDef/CSS/FontFamily.php b/lib/htmlpurifier/HTMLPurifier/AttrDef/CSS/FontFamily.php index 74e24c8816ec9..f1ff11636168f 100644 --- a/lib/htmlpurifier/HTMLPurifier/AttrDef/CSS/FontFamily.php +++ b/lib/htmlpurifier/HTMLPurifier/AttrDef/CSS/FontFamily.php @@ -10,23 +10,21 @@ class HTMLPurifier_AttrDef_CSS_FontFamily extends HTMLPurifier_AttrDef public function __construct() { - $this->mask = '_- '; - for ($c = 'a'; $c <= 'z'; $c++) { - $this->mask .= $c; - } - for ($c = 'A'; $c <= 'Z'; $c++) { - $this->mask .= $c; - } - for ($c = '0'; $c <= '9'; $c++) { - $this->mask .= $c; - } // cast-y, but should be fine - // special bytes used by UTF-8 - for ($i = 0x80; $i <= 0xFF; $i++) { - // We don't bother excluding invalid bytes in this range, - // because the our restriction of well-formed UTF-8 will - // prevent these from ever occurring. - $this->mask .= chr($i); - } + // Lowercase letters + $l = range('a', 'z'); + // Uppercase letters + $u = range('A', 'Z'); + // Digits + $d = range('0', '9'); + // Special bytes used by UTF-8 + $b = array_map('chr', range(0x80, 0xFF)); + // All valid characters for the mask + $c = array_merge($l, $u, $d, $b); + // Concatenate all valid characters into a string + // Use '_- ' as an initial value + $this->mask = array_reduce($c, function ($carry, $value) { + return $carry . $value; + }, '_- '); /* PHP's internal strcspn implementation is diff --git a/lib/htmlpurifier/HTMLPurifier/AttrDef/URI/Host.php b/lib/htmlpurifier/HTMLPurifier/AttrDef/URI/Host.php index 1beeaa5d22e3b..ddc5dfbeaf9ed 100644 --- a/lib/htmlpurifier/HTMLPurifier/AttrDef/URI/Host.php +++ b/lib/htmlpurifier/HTMLPurifier/AttrDef/URI/Host.php @@ -106,7 +106,7 @@ public function validate($string, $config, $context) // If we have Net_IDNA2 support, we can support IRIs by // punycoding them. (This is the most portable thing to do, // since otherwise we have to assume browsers support - } elseif ($config->get('Core.EnableIDNA')) { + } elseif ($config->get('Core.EnableIDNA') && class_exists('Net_IDNA2')) { $idna = new Net_IDNA2(array('encoding' => 'utf8', 'overlong' => false, 'strict' => true)); // we need to encode each period separately $parts = explode('.', $string); diff --git a/lib/htmlpurifier/HTMLPurifier/AttrTransform/TargetBlank.php b/lib/htmlpurifier/HTMLPurifier/AttrTransform/TargetBlank.php index dd63ea89cb605..cc30ab8c38cbf 100644 --- a/lib/htmlpurifier/HTMLPurifier/AttrTransform/TargetBlank.php +++ b/lib/htmlpurifier/HTMLPurifier/AttrTransform/TargetBlank.php @@ -33,7 +33,11 @@ public function transform($attr, $config, $context) // XXX Kind of inefficient $url = $this->parser->parse($attr['href']); - $scheme = $url->getSchemeObj($config, $context); + + // Ignore invalid schemes (e.g. `javascript:`) + if (!($scheme = $url->getSchemeObj($config, $context))) { + return $attr; + } if ($scheme->browsable && !$url->isBenign($config, $context)) { $attr['target'] = '_blank'; diff --git a/lib/htmlpurifier/HTMLPurifier/Bootstrap.php b/lib/htmlpurifier/HTMLPurifier/Bootstrap.php index 707122bb29604..bd8f9984f8913 100644 --- a/lib/htmlpurifier/HTMLPurifier/Bootstrap.php +++ b/lib/htmlpurifier/HTMLPurifier/Bootstrap.php @@ -79,44 +79,11 @@ public static function getPath($class) public static function registerAutoload() { $autoload = array('HTMLPurifier_Bootstrap', 'autoload'); - if (($funcs = spl_autoload_functions()) === false) { + if (spl_autoload_functions() === false) { spl_autoload_register($autoload); - } elseif (function_exists('spl_autoload_unregister')) { - if (version_compare(PHP_VERSION, '5.3.0', '>=')) { - // prepend flag exists, no need for shenanigans - spl_autoload_register($autoload, true, true); - } else { - $buggy = version_compare(PHP_VERSION, '5.2.11', '<'); - $compat = version_compare(PHP_VERSION, '5.1.2', '<=') && - version_compare(PHP_VERSION, '5.1.0', '>='); - foreach ($funcs as $func) { - if ($buggy && is_array($func)) { - // :TRICKY: There are some compatibility issues and some - // places where we need to error out - $reflector = new ReflectionMethod($func[0], $func[1]); - if (!$reflector->isStatic()) { - throw new Exception( - 'HTML Purifier autoloader registrar is not compatible - with non-static object methods due to PHP Bug #44144; - Please do not use HTMLPurifier.autoload.php (or any - file that includes this file); instead, place the code: - spl_autoload_register(array(\'HTMLPurifier_Bootstrap\', \'autoload\')) - after your own autoloaders.' - ); - } - // Suprisingly, spl_autoload_register supports the - // Class::staticMethod callback format, although call_user_func doesn't - if ($compat) { - $func = implode('::', $func); - } - } - spl_autoload_unregister($func); - } - spl_autoload_register($autoload); - foreach ($funcs as $func) { - spl_autoload_register($func); - } - } + } else { + // prepend flag exists, no need for shenanigans + spl_autoload_register($autoload, true, true); } } } diff --git a/lib/htmlpurifier/HTMLPurifier/CSSDefinition.php b/lib/htmlpurifier/HTMLPurifier/CSSDefinition.php index 3f08b81c545b2..1bc419c534260 100644 --- a/lib/htmlpurifier/HTMLPurifier/CSSDefinition.php +++ b/lib/htmlpurifier/HTMLPurifier/CSSDefinition.php @@ -13,7 +13,7 @@ class HTMLPurifier_CSSDefinition extends HTMLPurifier_Definition * Assoc array of attribute name to definition object. * @type HTMLPurifier_AttrDef[] */ - public $info = array(); + public $info = []; /** * Constructs the info array. The meat of this class. @@ -22,7 +22,7 @@ class HTMLPurifier_CSSDefinition extends HTMLPurifier_Definition protected function doSetup($config) { $this->info['text-align'] = new HTMLPurifier_AttrDef_Enum( - array('left', 'right', 'center', 'justify'), + ['left', 'right', 'center', 'justify'], false ); @@ -31,7 +31,7 @@ protected function doSetup($config) $this->info['border-right-style'] = $this->info['border-left-style'] = $this->info['border-top-style'] = new HTMLPurifier_AttrDef_Enum( - array( + [ 'none', 'hidden', 'dotted', @@ -42,42 +42,42 @@ protected function doSetup($config) 'ridge', 'inset', 'outset' - ), + ], false ); $this->info['border-style'] = new HTMLPurifier_AttrDef_CSS_Multiple($border_style); $this->info['clear'] = new HTMLPurifier_AttrDef_Enum( - array('none', 'left', 'right', 'both'), + ['none', 'left', 'right', 'both'], false ); $this->info['float'] = new HTMLPurifier_AttrDef_Enum( - array('none', 'left', 'right'), + ['none', 'left', 'right'], false ); $this->info['font-style'] = new HTMLPurifier_AttrDef_Enum( - array('normal', 'italic', 'oblique'), + ['normal', 'italic', 'oblique'], false ); $this->info['font-variant'] = new HTMLPurifier_AttrDef_Enum( - array('normal', 'small-caps'), + ['normal', 'small-caps'], false ); $uri_or_none = new HTMLPurifier_AttrDef_CSS_Composite( - array( - new HTMLPurifier_AttrDef_Enum(array('none')), + [ + new HTMLPurifier_AttrDef_Enum(['none']), new HTMLPurifier_AttrDef_CSS_URI() - ) + ] ); $this->info['list-style-position'] = new HTMLPurifier_AttrDef_Enum( - array('inside', 'outside'), + ['inside', 'outside'], false ); $this->info['list-style-type'] = new HTMLPurifier_AttrDef_Enum( - array( + [ 'disc', 'circle', 'square', @@ -87,7 +87,7 @@ protected function doSetup($config) 'lower-alpha', 'upper-alpha', 'none' - ), + ], false ); $this->info['list-style-image'] = $uri_or_none; @@ -95,34 +95,34 @@ protected function doSetup($config) $this->info['list-style'] = new HTMLPurifier_AttrDef_CSS_ListStyle($config); $this->info['text-transform'] = new HTMLPurifier_AttrDef_Enum( - array('capitalize', 'uppercase', 'lowercase', 'none'), + ['capitalize', 'uppercase', 'lowercase', 'none'], false ); $this->info['color'] = new HTMLPurifier_AttrDef_CSS_Color(); $this->info['background-image'] = $uri_or_none; $this->info['background-repeat'] = new HTMLPurifier_AttrDef_Enum( - array('repeat', 'repeat-x', 'repeat-y', 'no-repeat') + ['repeat', 'repeat-x', 'repeat-y', 'no-repeat'] ); $this->info['background-attachment'] = new HTMLPurifier_AttrDef_Enum( - array('scroll', 'fixed') + ['scroll', 'fixed'] ); $this->info['background-position'] = new HTMLPurifier_AttrDef_CSS_BackgroundPosition(); $this->info['background-size'] = new HTMLPurifier_AttrDef_CSS_Composite( - array( + [ new HTMLPurifier_AttrDef_Enum( - array( + [ 'auto', 'cover', 'contain', 'initial', 'inherit', - ) + ] ), new HTMLPurifier_AttrDef_CSS_Percentage(), new HTMLPurifier_AttrDef_CSS_Length() - ) + ] ); $border_color = @@ -131,10 +131,10 @@ protected function doSetup($config) $this->info['border-left-color'] = $this->info['border-right-color'] = $this->info['background-color'] = new HTMLPurifier_AttrDef_CSS_Composite( - array( - new HTMLPurifier_AttrDef_Enum(array('transparent')), + [ + new HTMLPurifier_AttrDef_Enum(['transparent']), new HTMLPurifier_AttrDef_CSS_Color() - ) + ] ); $this->info['background'] = new HTMLPurifier_AttrDef_CSS_Background($config); @@ -146,32 +146,32 @@ protected function doSetup($config) $this->info['border-bottom-width'] = $this->info['border-left-width'] = $this->info['border-right-width'] = new HTMLPurifier_AttrDef_CSS_Composite( - array( - new HTMLPurifier_AttrDef_Enum(array('thin', 'medium', 'thick')), + [ + new HTMLPurifier_AttrDef_Enum(['thin', 'medium', 'thick']), new HTMLPurifier_AttrDef_CSS_Length('0') //disallow negative - ) + ] ); $this->info['border-width'] = new HTMLPurifier_AttrDef_CSS_Multiple($border_width); $this->info['letter-spacing'] = new HTMLPurifier_AttrDef_CSS_Composite( - array( - new HTMLPurifier_AttrDef_Enum(array('normal')), + [ + new HTMLPurifier_AttrDef_Enum(['normal']), new HTMLPurifier_AttrDef_CSS_Length() - ) + ] ); $this->info['word-spacing'] = new HTMLPurifier_AttrDef_CSS_Composite( - array( - new HTMLPurifier_AttrDef_Enum(array('normal')), + [ + new HTMLPurifier_AttrDef_Enum(['normal']), new HTMLPurifier_AttrDef_CSS_Length() - ) + ] ); $this->info['font-size'] = new HTMLPurifier_AttrDef_CSS_Composite( - array( + [ new HTMLPurifier_AttrDef_Enum( - array( + [ 'xx-small', 'x-small', 'small', @@ -181,20 +181,20 @@ protected function doSetup($config) 'xx-large', 'larger', 'smaller' - ) + ] ), new HTMLPurifier_AttrDef_CSS_Percentage(), new HTMLPurifier_AttrDef_CSS_Length() - ) + ] ); $this->info['line-height'] = new HTMLPurifier_AttrDef_CSS_Composite( - array( - new HTMLPurifier_AttrDef_Enum(array('normal')), + [ + new HTMLPurifier_AttrDef_Enum(['normal']), new HTMLPurifier_AttrDef_CSS_Number(true), // no negatives new HTMLPurifier_AttrDef_CSS_Length('0'), new HTMLPurifier_AttrDef_CSS_Percentage(true) - ) + ] ); $margin = @@ -202,11 +202,11 @@ protected function doSetup($config) $this->info['margin-bottom'] = $this->info['margin-left'] = $this->info['margin-right'] = new HTMLPurifier_AttrDef_CSS_Composite( - array( + [ new HTMLPurifier_AttrDef_CSS_Length(), new HTMLPurifier_AttrDef_CSS_Percentage(), - new HTMLPurifier_AttrDef_Enum(array('auto')) - ) + new HTMLPurifier_AttrDef_Enum(['auto']) + ] ); $this->info['margin'] = new HTMLPurifier_AttrDef_CSS_Multiple($margin); @@ -217,41 +217,41 @@ protected function doSetup($config) $this->info['padding-bottom'] = $this->info['padding-left'] = $this->info['padding-right'] = new HTMLPurifier_AttrDef_CSS_Composite( - array( + [ new HTMLPurifier_AttrDef_CSS_Length('0'), new HTMLPurifier_AttrDef_CSS_Percentage(true) - ) + ] ); $this->info['padding'] = new HTMLPurifier_AttrDef_CSS_Multiple($padding); $this->info['text-indent'] = new HTMLPurifier_AttrDef_CSS_Composite( - array( + [ new HTMLPurifier_AttrDef_CSS_Length(), new HTMLPurifier_AttrDef_CSS_Percentage() - ) + ] ); $trusted_wh = new HTMLPurifier_AttrDef_CSS_Composite( - array( + [ new HTMLPurifier_AttrDef_CSS_Length('0'), new HTMLPurifier_AttrDef_CSS_Percentage(true), - new HTMLPurifier_AttrDef_Enum(array('auto', 'initial', 'inherit')) - ) + new HTMLPurifier_AttrDef_Enum(['auto', 'initial', 'inherit']) + ] ); $trusted_min_wh = new HTMLPurifier_AttrDef_CSS_Composite( - array( + [ new HTMLPurifier_AttrDef_CSS_Length('0'), new HTMLPurifier_AttrDef_CSS_Percentage(true), - new HTMLPurifier_AttrDef_Enum(array('initial', 'inherit')) - ) + new HTMLPurifier_AttrDef_Enum(['initial', 'inherit']) + ] ); $trusted_max_wh = new HTMLPurifier_AttrDef_CSS_Composite( - array( + [ new HTMLPurifier_AttrDef_CSS_Length('0'), new HTMLPurifier_AttrDef_CSS_Percentage(true), - new HTMLPurifier_AttrDef_Enum(array('none', 'initial', 'inherit')) - ) + new HTMLPurifier_AttrDef_Enum(['none', 'initial', 'inherit']) + ] ); $max = $config->get('CSS.MaxImgLength'); @@ -263,10 +263,10 @@ protected function doSetup($config) 'img', // For img tags: new HTMLPurifier_AttrDef_CSS_Composite( - array( + [ new HTMLPurifier_AttrDef_CSS_Length('0', $max), - new HTMLPurifier_AttrDef_Enum(array('auto')) - ) + new HTMLPurifier_AttrDef_Enum(['auto']) + ] ), // For everyone else: $trusted_wh @@ -279,10 +279,10 @@ protected function doSetup($config) 'img', // For img tags: new HTMLPurifier_AttrDef_CSS_Composite( - array( + [ new HTMLPurifier_AttrDef_CSS_Length('0', $max), - new HTMLPurifier_AttrDef_Enum(array('initial', 'inherit')) - ) + new HTMLPurifier_AttrDef_Enum(['initial', 'inherit']) + ] ), // For everyone else: $trusted_min_wh @@ -295,22 +295,39 @@ protected function doSetup($config) 'img', // For img tags: new HTMLPurifier_AttrDef_CSS_Composite( - array( + [ new HTMLPurifier_AttrDef_CSS_Length('0', $max), - new HTMLPurifier_AttrDef_Enum(array('none', 'initial', 'inherit')) - ) + new HTMLPurifier_AttrDef_Enum(['none', 'initial', 'inherit']) + ] ), // For everyone else: $trusted_max_wh ); + // text-decoration and related shorthands $this->info['text-decoration'] = new HTMLPurifier_AttrDef_CSS_TextDecoration(); + $this->info['text-decoration-line'] = new HTMLPurifier_AttrDef_Enum( + ['none', 'underline', 'overline', 'line-through', 'initial', 'inherit'] + ); + + $this->info['text-decoration-style'] = new HTMLPurifier_AttrDef_Enum( + ['solid', 'double', 'dotted', 'dashed', 'wavy', 'initial', 'inherit'] + ); + + $this->info['text-decoration-color'] = new HTMLPurifier_AttrDef_CSS_Color(); + + $this->info['text-decoration-thickness'] = new HTMLPurifier_AttrDef_CSS_Composite([ + new HTMLPurifier_AttrDef_CSS_Length(), + new HTMLPurifier_AttrDef_CSS_Percentage(), + new HTMLPurifier_AttrDef_Enum(['auto', 'from-font', 'initial', 'inherit']) + ]); + $this->info['font-family'] = new HTMLPurifier_AttrDef_CSS_FontFamily(); // this could use specialized code $this->info['font-weight'] = new HTMLPurifier_AttrDef_Enum( - array( + [ 'normal', 'bold', 'bolder', @@ -324,7 +341,7 @@ protected function doSetup($config) '700', '800', '900' - ), + ], false ); @@ -340,21 +357,21 @@ protected function doSetup($config) $this->info['border-right'] = new HTMLPurifier_AttrDef_CSS_Border($config); $this->info['border-collapse'] = new HTMLPurifier_AttrDef_Enum( - array('collapse', 'separate') + ['collapse', 'separate'] ); $this->info['caption-side'] = new HTMLPurifier_AttrDef_Enum( - array('top', 'bottom') + ['top', 'bottom'] ); $this->info['table-layout'] = new HTMLPurifier_AttrDef_Enum( - array('auto', 'fixed') + ['auto', 'fixed'] ); $this->info['vertical-align'] = new HTMLPurifier_AttrDef_CSS_Composite( - array( + [ new HTMLPurifier_AttrDef_Enum( - array( + [ 'baseline', 'sub', 'super', @@ -363,11 +380,11 @@ protected function doSetup($config) 'middle', 'bottom', 'text-bottom' - ) + ] ), new HTMLPurifier_AttrDef_CSS_Length(), new HTMLPurifier_AttrDef_CSS_Percentage() - ) + ] ); $this->info['border-spacing'] = new HTMLPurifier_AttrDef_CSS_Multiple(new HTMLPurifier_AttrDef_CSS_Length(), 2); @@ -375,7 +392,7 @@ protected function doSetup($config) // These CSS properties don't work on many browsers, but we live // in THE FUTURE! $this->info['white-space'] = new HTMLPurifier_AttrDef_Enum( - array('nowrap', 'normal', 'pre', 'pre-wrap', 'pre-line') + ['nowrap', 'normal', 'pre', 'pre-wrap', 'pre-line'] ); if ($config->get('CSS.Proprietary')) { @@ -422,21 +439,21 @@ protected function doSetupProprietary($config) // more CSS3 $this->info['page-break-after'] = $this->info['page-break-before'] = new HTMLPurifier_AttrDef_Enum( - array( + [ 'auto', 'always', 'avoid', 'left', 'right' - ) + ] ); - $this->info['page-break-inside'] = new HTMLPurifier_AttrDef_Enum(array('auto', 'avoid')); + $this->info['page-break-inside'] = new HTMLPurifier_AttrDef_Enum(['auto', 'avoid']); $border_radius = new HTMLPurifier_AttrDef_CSS_Composite( - array( + [ new HTMLPurifier_AttrDef_CSS_Percentage(true), // disallow negative new HTMLPurifier_AttrDef_CSS_Length('0') // disallow negative - )); + ]); $this->info['border-top-left-radius'] = $this->info['border-top-right-radius'] = @@ -453,7 +470,7 @@ protected function doSetupProprietary($config) protected function doSetupTricky($config) { $this->info['display'] = new HTMLPurifier_AttrDef_Enum( - array( + [ 'inline', 'block', 'list-item', @@ -472,12 +489,12 @@ protected function doSetupTricky($config) 'table-cell', 'table-caption', 'none' - ) + ] ); $this->info['visibility'] = new HTMLPurifier_AttrDef_Enum( - array('visible', 'hidden', 'collapse') + ['visible', 'hidden', 'collapse'] ); - $this->info['overflow'] = new HTMLPurifier_AttrDef_Enum(array('visible', 'hidden', 'auto', 'scroll')); + $this->info['overflow'] = new HTMLPurifier_AttrDef_Enum(['visible', 'hidden', 'auto', 'scroll']); $this->info['opacity'] = new HTMLPurifier_AttrDef_CSS_AlphaValue(); } @@ -487,23 +504,23 @@ protected function doSetupTricky($config) protected function doSetupTrusted($config) { $this->info['position'] = new HTMLPurifier_AttrDef_Enum( - array('static', 'relative', 'absolute', 'fixed') + ['static', 'relative', 'absolute', 'fixed'] ); $this->info['top'] = $this->info['left'] = $this->info['right'] = $this->info['bottom'] = new HTMLPurifier_AttrDef_CSS_Composite( - array( + [ new HTMLPurifier_AttrDef_CSS_Length(), new HTMLPurifier_AttrDef_CSS_Percentage(), - new HTMLPurifier_AttrDef_Enum(array('auto')), - ) + new HTMLPurifier_AttrDef_Enum(['auto']), + ] ); $this->info['z-index'] = new HTMLPurifier_AttrDef_CSS_Composite( - array( + [ new HTMLPurifier_AttrDef_Integer(), - new HTMLPurifier_AttrDef_Enum(array('auto')), - ) + new HTMLPurifier_AttrDef_Enum(['auto']), + ] ); } diff --git a/lib/htmlpurifier/HTMLPurifier/Config.php b/lib/htmlpurifier/HTMLPurifier/Config.php index 797d2687792d0..f7511ca4166ca 100644 --- a/lib/htmlpurifier/HTMLPurifier/Config.php +++ b/lib/htmlpurifier/HTMLPurifier/Config.php @@ -21,7 +21,7 @@ class HTMLPurifier_Config * HTML Purifier's version * @type string */ - public $version = '4.15.0'; + public $version = '4.17.0'; /** * Whether or not to automatically finalize diff --git a/lib/htmlpurifier/HTMLPurifier/DefinitionCache/Serializer.php b/lib/htmlpurifier/HTMLPurifier/DefinitionCache/Serializer.php index b82c6bb2013e6..bfad967fb177d 100644 --- a/lib/htmlpurifier/HTMLPurifier/DefinitionCache/Serializer.php +++ b/lib/htmlpurifier/HTMLPurifier/DefinitionCache/Serializer.php @@ -287,13 +287,14 @@ private function _testPermissions($dir, $chmod) } elseif (filegroup($dir) === posix_getgid()) { $chmod = $chmod | 0070; } else { - // PHP's probably running as nobody, so we'll - // need to give global permissions - $chmod = $chmod | 0777; + // PHP's probably running as nobody, it is + // not obvious how to fix this (777 is probably + // bad if you are multi-user), let the user figure it out + $chmod = null; } trigger_error( - 'Directory ' . $dir . ' not writable, ' . - 'please chmod to ' . decoct($chmod), + 'Directory ' . $dir . ' not writable. ' . + ($chmod === null ? '' : 'Please chmod to ' . decoct($chmod)), E_USER_WARNING ); } else { diff --git a/lib/htmlpurifier/HTMLPurifier/DefinitionCacheFactory.php b/lib/htmlpurifier/HTMLPurifier/DefinitionCacheFactory.php index fd1cc9be46a73..3a0f4616a9427 100644 --- a/lib/htmlpurifier/HTMLPurifier/DefinitionCacheFactory.php +++ b/lib/htmlpurifier/HTMLPurifier/DefinitionCacheFactory.php @@ -71,7 +71,7 @@ public function create($type, $config) return $this->caches[$method][$type]; } if (isset($this->implementations[$method]) && - class_exists($class = $this->implementations[$method], false)) { + class_exists($class = $this->implementations[$method])) { $cache = new $class($type); } else { if ($method != 'Serializer') { diff --git a/lib/htmlpurifier/HTMLPurifier/Filter/ExtractStyleBlocks.php b/lib/htmlpurifier/HTMLPurifier/Filter/ExtractStyleBlocks.php index 66f70b0fc00fd..6f8e7790ec3f4 100644 --- a/lib/htmlpurifier/HTMLPurifier/Filter/ExtractStyleBlocks.php +++ b/lib/htmlpurifier/HTMLPurifier/Filter/ExtractStyleBlocks.php @@ -146,175 +146,179 @@ public function cleanCSS($css, $config, $context) foreach ($this->_tidy->css as $k => $decls) { // $decls are all CSS declarations inside an @ selector $new_decls = array(); - foreach ($decls as $selector => $style) { - $selector = trim($selector); - if ($selector === '') { - continue; - } // should not happen - // Parse the selector - // Here is the relevant part of the CSS grammar: - // - // ruleset - // : selector [ ',' S* selector ]* '{' ... - // selector - // : simple_selector [ combinator selector | S+ [ combinator? selector ]? ]? - // combinator - // : '+' S* - // : '>' S* - // simple_selector - // : element_name [ HASH | class | attrib | pseudo ]* - // | [ HASH | class | attrib | pseudo ]+ - // element_name - // : IDENT | '*' - // ; - // class - // : '.' IDENT - // ; - // attrib - // : '[' S* IDENT S* [ [ '=' | INCLUDES | DASHMATCH ] S* - // [ IDENT | STRING ] S* ]? ']' - // ; - // pseudo - // : ':' [ IDENT | FUNCTION S* [IDENT S*]? ')' ] - // ; - // - // For reference, here are the relevant tokens: - // - // HASH #{name} - // IDENT {ident} - // INCLUDES == - // DASHMATCH |= - // STRING {string} - // FUNCTION {ident}\( - // - // And the lexical scanner tokens - // - // name {nmchar}+ - // nmchar [_a-z0-9-]|{nonascii}|{escape} - // nonascii [\240-\377] - // escape {unicode}|\\[^\r\n\f0-9a-f] - // unicode \\{h}}{1,6}(\r\n|[ \t\r\n\f])? - // ident -?{nmstart}{nmchar*} - // nmstart [_a-z]|{nonascii}|{escape} - // string {string1}|{string2} - // string1 \"([^\n\r\f\\"]|\\{nl}|{escape})*\" - // string2 \'([^\n\r\f\\"]|\\{nl}|{escape})*\' - // - // We'll implement a subset (in order to reduce attack - // surface); in particular: - // - // - No Unicode support - // - No escapes support - // - No string support (by proxy no attrib support) - // - element_name is matched against allowed - // elements (some people might find this - // annoying...) - // - Pseudo-elements one of :first-child, :link, - // :visited, :active, :hover, :focus + if (is_array($decls)) { + foreach ($decls as $selector => $style) { + $selector = trim($selector); + if ($selector === '') { + continue; + } // should not happen + // Parse the selector + // Here is the relevant part of the CSS grammar: + // + // ruleset + // : selector [ ',' S* selector ]* '{' ... + // selector + // : simple_selector [ combinator selector | S+ [ combinator? selector ]? ]? + // combinator + // : '+' S* + // : '>' S* + // simple_selector + // : element_name [ HASH | class | attrib | pseudo ]* + // | [ HASH | class | attrib | pseudo ]+ + // element_name + // : IDENT | '*' + // ; + // class + // : '.' IDENT + // ; + // attrib + // : '[' S* IDENT S* [ [ '=' | INCLUDES | DASHMATCH ] S* + // [ IDENT | STRING ] S* ]? ']' + // ; + // pseudo + // : ':' [ IDENT | FUNCTION S* [IDENT S*]? ')' ] + // ; + // + // For reference, here are the relevant tokens: + // + // HASH #{name} + // IDENT {ident} + // INCLUDES == + // DASHMATCH |= + // STRING {string} + // FUNCTION {ident}\( + // + // And the lexical scanner tokens + // + // name {nmchar}+ + // nmchar [_a-z0-9-]|{nonascii}|{escape} + // nonascii [\240-\377] + // escape {unicode}|\\[^\r\n\f0-9a-f] + // unicode \\{h}}{1,6}(\r\n|[ \t\r\n\f])? + // ident -?{nmstart}{nmchar*} + // nmstart [_a-z]|{nonascii}|{escape} + // string {string1}|{string2} + // string1 \"([^\n\r\f\\"]|\\{nl}|{escape})*\" + // string2 \'([^\n\r\f\\"]|\\{nl}|{escape})*\' + // + // We'll implement a subset (in order to reduce attack + // surface); in particular: + // + // - No Unicode support + // - No escapes support + // - No string support (by proxy no attrib support) + // - element_name is matched against allowed + // elements (some people might find this + // annoying...) + // - Pseudo-elements one of :first-child, :link, + // :visited, :active, :hover, :focus - // handle ruleset - $selectors = array_map('trim', explode(',', $selector)); - $new_selectors = array(); - foreach ($selectors as $sel) { - // split on +, > and spaces - $basic_selectors = preg_split('/\s*([+> ])\s*/', $sel, -1, PREG_SPLIT_DELIM_CAPTURE); - // even indices are chunks, odd indices are - // delimiters - $nsel = null; - $delim = null; // guaranteed to be non-null after - // two loop iterations - for ($i = 0, $c = count($basic_selectors); $i < $c; $i++) { - $x = $basic_selectors[$i]; - if ($i % 2) { - // delimiter - if ($x === ' ') { - $delim = ' '; - } else { - $delim = ' ' . $x . ' '; - } - } else { - // simple selector - $components = preg_split('/([#.:])/', $x, -1, PREG_SPLIT_DELIM_CAPTURE); - $sdelim = null; - $nx = null; - for ($j = 0, $cc = count($components); $j < $cc; $j++) { - $y = $components[$j]; - if ($j === 0) { - if ($y === '*' || isset($html_definition->info[$y = strtolower($y)])) { - $nx = $y; - } else { - // $nx stays null; this matters - // if we don't manage to find - // any valid selector content, - // in which case we ignore the - // outer $delim - } - } elseif ($j % 2) { - // set delimiter - $sdelim = $y; + // handle ruleset + $selectors = array_map('trim', explode(',', $selector)); + $new_selectors = array(); + foreach ($selectors as $sel) { + // split on +, > and spaces + $basic_selectors = preg_split('/\s*([+> ])\s*/', $sel, -1, PREG_SPLIT_DELIM_CAPTURE); + // even indices are chunks, odd indices are + // delimiters + $nsel = null; + $delim = null; // guaranteed to be non-null after + // two loop iterations + for ($i = 0, $c = count($basic_selectors); $i < $c; $i++) { + $x = $basic_selectors[$i]; + if ($i % 2) { + // delimiter + if ($x === ' ') { + $delim = ' '; } else { - $attrdef = null; - if ($sdelim === '#') { - $attrdef = $this->_id_attrdef; - } elseif ($sdelim === '.') { - $attrdef = $this->_class_attrdef; - } elseif ($sdelim === ':') { - $attrdef = $this->_enum_attrdef; + $delim = ' ' . $x . ' '; + } + } else { + // simple selector + $components = preg_split('/([#.:])/', $x, -1, PREG_SPLIT_DELIM_CAPTURE); + $sdelim = null; + $nx = null; + for ($j = 0, $cc = count($components); $j < $cc; $j++) { + $y = $components[$j]; + if ($j === 0) { + if ($y === '*' || isset($html_definition->info[$y = strtolower($y)])) { + $nx = $y; + } else { + // $nx stays null; this matters + // if we don't manage to find + // any valid selector content, + // in which case we ignore the + // outer $delim + } + } elseif ($j % 2) { + // set delimiter + $sdelim = $y; } else { - throw new HTMLPurifier_Exception('broken invariant sdelim and preg_split'); - } - $r = $attrdef->validate($y, $config, $context); - if ($r !== false) { - if ($r !== true) { - $y = $r; + $attrdef = null; + if ($sdelim === '#') { + $attrdef = $this->_id_attrdef; + } elseif ($sdelim === '.') { + $attrdef = $this->_class_attrdef; + } elseif ($sdelim === ':') { + $attrdef = $this->_enum_attrdef; + } else { + throw new HTMLPurifier_Exception('broken invariant sdelim and preg_split'); } - if ($nx === null) { - $nx = ''; + $r = $attrdef->validate($y, $config, $context); + if ($r !== false) { + if ($r !== true) { + $y = $r; + } + if ($nx === null) { + $nx = ''; + } + $nx .= $sdelim . $y; } - $nx .= $sdelim . $y; } } - } - if ($nx !== null) { - if ($nsel === null) { - $nsel = $nx; + if ($nx !== null) { + if ($nsel === null) { + $nsel = $nx; + } else { + $nsel .= $delim . $nx; + } } else { - $nsel .= $delim . $nx; + // delimiters to the left of invalid + // basic selector ignored } - } else { - // delimiters to the left of invalid - // basic selector ignored } } - } - if ($nsel !== null) { - if (!empty($scopes)) { - foreach ($scopes as $s) { - $new_selectors[] = "$s $nsel"; + if ($nsel !== null) { + if (!empty($scopes)) { + foreach ($scopes as $s) { + $new_selectors[] = "$s $nsel"; + } + } else { + $new_selectors[] = $nsel; } - } else { - $new_selectors[] = $nsel; } } - } - if (empty($new_selectors)) { - continue; - } - $selector = implode(', ', $new_selectors); - foreach ($style as $name => $value) { - if (!isset($css_definition->info[$name])) { - unset($style[$name]); + if (empty($new_selectors)) { continue; } - $def = $css_definition->info[$name]; - $ret = $def->validate($value, $config, $context); - if ($ret === false) { - unset($style[$name]); - } else { - $style[$name] = $ret; + $selector = implode(', ', $new_selectors); + foreach ($style as $name => $value) { + if (!isset($css_definition->info[$name])) { + unset($style[$name]); + continue; + } + $def = $css_definition->info[$name]; + $ret = $def->validate($value, $config, $context); + if ($ret === false) { + unset($style[$name]); + } else { + $style[$name] = $ret; + } } + $new_decls[$selector] = $style; } - $new_decls[$selector] = $style; + } else { + continue; } $new_css[$k] = $new_decls; } diff --git a/lib/htmlpurifier/HTMLPurifier/HTMLModule/Tidy.php b/lib/htmlpurifier/HTMLPurifier/HTMLModule/Tidy.php index 12173ba700a7a..76fd93a6cbf80 100644 --- a/lib/htmlpurifier/HTMLPurifier/HTMLModule/Tidy.php +++ b/lib/htmlpurifier/HTMLPurifier/HTMLModule/Tidy.php @@ -221,6 +221,7 @@ public function getFixType($name) */ public function makeFixes() { + return array(); } } diff --git a/lib/htmlpurifier/HTMLPurifier/LanguageFactory.php b/lib/htmlpurifier/HTMLPurifier/LanguageFactory.php index 4e35272d8732f..16a4f6932d4ec 100644 --- a/lib/htmlpurifier/HTMLPurifier/LanguageFactory.php +++ b/lib/htmlpurifier/HTMLPurifier/LanguageFactory.php @@ -109,7 +109,7 @@ public function create($config, $context, $code = false) } else { $class = 'HTMLPurifier_Language_' . $pcode; $file = $this->dir . '/Language/classes/' . $code . '.php'; - if (file_exists($file) || class_exists($class, false)) { + if (file_exists($file) || class_exists($class)) { $lang = new $class($config, $context); } else { // Go fallback diff --git a/lib/htmlpurifier/HTMLPurifier/Lexer.php b/lib/htmlpurifier/HTMLPurifier/Lexer.php index c21f364919c16..1f552a17a99b5 100644 --- a/lib/htmlpurifier/HTMLPurifier/Lexer.php +++ b/lib/htmlpurifier/HTMLPurifier/Lexer.php @@ -101,7 +101,7 @@ public static function create($config) break; } - if (class_exists('DOMDocument', false) && + if (class_exists('DOMDocument') && method_exists('DOMDocument', 'loadHTML') && !extension_loaded('domxml') ) { diff --git a/lib/htmlpurifier/HTMLPurifier/Lexer/DOMLex.php b/lib/htmlpurifier/HTMLPurifier/Lexer/DOMLex.php index ca5f25b849fdb..5e8104be91166 100644 --- a/lib/htmlpurifier/HTMLPurifier/Lexer/DOMLex.php +++ b/lib/htmlpurifier/HTMLPurifier/Lexer/DOMLex.php @@ -104,7 +104,6 @@ public function tokenizeHTML($html, $config, $context) * To iterate is human, to recurse divine - L. Peter Deutsch * @param DOMNode $node DOMNode to be tokenized. * @param HTMLPurifier_Token[] $tokens Array-list of already tokenized tokens. - * @return HTMLPurifier_Token of node appended to previously passed tokens. */ protected function tokenizeDOM($node, &$tokens, $config) { diff --git a/lib/htmlpurifier/HTMLPurifier/URIScheme/tel.php b/lib/htmlpurifier/HTMLPurifier/URIScheme/tel.php index 8cd1933527bdd..dfad8efcf9d26 100644 --- a/lib/htmlpurifier/HTMLPurifier/URIScheme/tel.php +++ b/lib/htmlpurifier/HTMLPurifier/URIScheme/tel.php @@ -33,11 +33,11 @@ public function doValidate(&$uri, $config, $context) $uri->host = null; $uri->port = null; - // Delete all non-numeric characters, non-x characters + // Delete all non-numeric characters, commas, and non-x characters // from phone number, EXCEPT for a leading plus sign. - $uri->path = preg_replace('/(?!^\+)[^\dx]/', '', + $uri->path = preg_replace('/(?!^\+)[^\dx,]/', '', // Normalize e(x)tension to lower-case - str_replace('X', 'x', $uri->path)); + str_replace('X', 'x', rawurldecode($uri->path))); return true; } diff --git a/lib/htmlpurifier/HTMLPurifier/UnitConverter.php b/lib/htmlpurifier/HTMLPurifier/UnitConverter.php index 166f3bf306b9f..b5a1eab5cabd9 100644 --- a/lib/htmlpurifier/HTMLPurifier/UnitConverter.php +++ b/lib/htmlpurifier/HTMLPurifier/UnitConverter.php @@ -261,7 +261,7 @@ private function div($s1, $s2, $scale) */ private function round($n, $sigfigs) { - $new_log = (int)floor(log(abs($n), 10)); // Number of digits left of decimal - 1 + $new_log = (int)floor(log(abs((float)$n), 10)); // Number of digits left of decimal - 1 $rp = $sigfigs - $new_log - 1; // Number of decimal places needed $neg = $n < 0 ? '-' : ''; // Negative sign if ($this->bcmath) { @@ -276,7 +276,7 @@ private function round($n, $sigfigs) } return $n; } else { - return $this->scale(round($n, $sigfigs - $new_log - 1), $rp + 1); + return $this->scale(round((float)$n, $sigfigs - $new_log - 1), $rp + 1); } } @@ -300,7 +300,7 @@ private function scale($r, $scale) // Now we return it, truncating the zero that was rounded off. return substr($precise, 0, -1) . str_repeat('0', -$scale + 1); } - return sprintf('%.' . $scale . 'f', (float)$r); + return number_format((float)$r, $scale, '.', ''); } } diff --git a/lib/htmlpurifier/readme_moodle.txt b/lib/htmlpurifier/readme_moodle.txt index d58010fb93bd5..59d0138a794a2 100644 --- a/lib/htmlpurifier/readme_moodle.txt +++ b/lib/htmlpurifier/readme_moodle.txt @@ -14,7 +14,3 @@ Description of HTML Purifier library import into Moodle HTMLPurifier.path.php * add locallib.php with Moodle specific extensions to /lib/htmlpurifier/ * add this readme_moodle.txt to /lib/htmlpurifier/ - -Local changes: -* 2023-06-06 Applied patch https://github.com/ezyang/htmlpurifier/pull/346 to avoid PHP 8.2 deprecations. - See MDL-78143 for more details. \ No newline at end of file diff --git a/lib/moodlelib.php b/lib/moodlelib.php index 049c8574daa0f..40e852d03ac67 100644 --- a/lib/moodlelib.php +++ b/lib/moodlelib.php @@ -7455,13 +7455,17 @@ function get_plugins_with_function($function, $file = 'lib.php', $include = true foreach ($pluginfunctions as $plugintype => $plugins) { foreach ($plugins as $plugin => $unusedfunction) { $component = $plugintype . '_' . $plugin; - if (\core\hook\manager::get_instance()->is_deprecated_plugin_callback($plugincallback)) { + if ($hooks = \core\hook\manager::get_instance()->get_hooks_deprecating_plugin_callback($plugincallback)) { if (\core\hook\manager::get_instance()->is_deprecating_hook_present($component, $plugincallback)) { // Ignore the old callback, it is there only for older Moodle versions. unset($pluginfunctions[$plugintype][$plugin]); } else { - debugging("Callback $plugincallback in $component component should be migrated to new hook callback", - DEBUG_DEVELOPER); + $hookmessage = count($hooks) == 1 ? reset($hooks) : 'one of ' . implode(', ', $hooks); + debugging( + "Callback $plugincallback in $component component should be migrated to new " . + "hook callback for $hookmessage", + DEBUG_DEVELOPER + ); } } } @@ -7690,13 +7694,15 @@ function component_callback($component, $function, array $params = array(), $def if ($functionname) { if ($migratedtohook) { - if (\core\hook\manager::get_instance()->is_deprecated_plugin_callback($function)) { + if ($hooks = \core\hook\manager::get_instance()->get_hooks_deprecating_plugin_callback($function)) { if (\core\hook\manager::get_instance()->is_deprecating_hook_present($component, $function)) { // Do not call the old lib.php callback, // it is there for compatibility with older Moodle versions only. return null; } else { - debugging("Callback $function in $component component should be migrated to new hook callback", + $hookmessage = count($hooks) == 1 ? reset($hooks) : 'one of ' . implode(', ', $hooks); + debugging( + "Callback $function in $component component should be migrated to new hook callback for $hookmessage", DEBUG_DEVELOPER); } } @@ -7771,9 +7777,10 @@ function component_callback_exists($component, $function) { * @param string $methodname The name of the staticically defined method on the class. * @param array $params The arguments to pass into the method. * @param mixed $default The default value. + * @param bool $migratedtohook True if the callback has been migrated to a hook. * @return mixed The return value. */ -function component_class_callback($classname, $methodname, array $params, $default = null) { +function component_class_callback($classname, $methodname, array $params, $default = null, bool $migratedtohook = false) { if (!class_exists($classname)) { return $default; } @@ -7783,6 +7790,24 @@ function component_class_callback($classname, $methodname, array $params, $defau } $fullfunction = $classname . '::' . $methodname; + + if ($migratedtohook) { + $functionparts = explode('\\', trim($fullfunction, '\\')); + $component = $functionparts[0]; + $callback = end($functionparts); + if ($hooks = \core\hook\manager::get_instance()->get_hooks_deprecating_plugin_callback($callback)) { + if (\core\hook\manager::get_instance()->is_deprecating_hook_present($component, $callback)) { + // Do not call the old class callback, + // it is there for compatibility with older Moodle versions only. + return null; + } else { + $hookmessage = count($hooks) == 1 ? reset($hooks) : 'one of ' . implode(', ', $hooks); + debugging("Callback $callback in $component component should be migrated to new hook callback for $hookmessage", + DEBUG_DEVELOPER); + } + } + } + $result = call_user_func_array($fullfunction, $params); if (null === $result) { diff --git a/lib/navigationlib.php b/lib/navigationlib.php index 45e63bfb6d943..3b03454d5acd7 100644 --- a/lib/navigationlib.php +++ b/lib/navigationlib.php @@ -117,6 +117,8 @@ class navigation_node implements renderable { public $forceopen = false; /** @var array An array of CSS classes for the node */ public $classes = array(); + /** @var array An array of HTML attributes for the node */ + public $attributes = []; /** @var navigation_node_collection An array of child nodes */ public $children = array(); /** @var bool If set to true the node will be recognised as active */ @@ -560,6 +562,16 @@ public function add_class($class) { return true; } + /** + * Adds an HTML attribute to this node. + * + * @param string $name + * @param string $value + */ + public function add_attribute(string $name, string $value): void { + $this->attributes[] = ['name' => $name, 'value' => $value]; + } + /** * Removes a CSS class from this node. * @@ -2291,6 +2303,7 @@ public function load_generic_course_sections(stdClass $course, navigation_node $ null, $section->id, new pix_icon('i/section', '')); $sectionnode->nodetype = navigation_node::NODETYPE_BRANCH; $sectionnode->hidden = (!$section->visible || !$section->available); + $sectionnode->add_attribute('data-section-name-for', $section->id); if ($this->includesectionnum !== false && $this->includesectionnum == $section->section) { $this->load_section_activities($sectionnode, $section->section, $activities); } diff --git a/lib/outputrenderers.php b/lib/outputrenderers.php index 47a42e8230c59..04c713d594764 100644 --- a/lib/outputrenderers.php +++ b/lib/outputrenderers.php @@ -3410,10 +3410,11 @@ public function sr_text(string $contents): string { * @param string $contents The contents of the box * @param string $classes A space-separated list of CSS classes * @param string $id An optional ID + * @param array $attributes Optional other attributes as array * @return string the HTML to output. */ - public function container($contents, $classes = null, $id = null) { - return $this->container_start($classes, $id) . $contents . $this->container_end(); + public function container($contents, $classes = null, $id = null, $attributes = []) { + return $this->container_start($classes, $id, $attributes) . $contents . $this->container_end(); } /** @@ -3421,12 +3422,13 @@ public function container($contents, $classes = null, $id = null) { * * @param string $classes A space-separated list of CSS classes * @param string $id An optional ID + * @param array $attributes Optional other attributes as array * @return string the HTML to output. */ - public function container_start($classes = null, $id = null) { + public function container_start($classes = null, $id = null, $attributes = []) { $this->opencontainers->push('container', html_writer::end_tag('div')); - return html_writer::start_tag('div', array('id' => $id, - 'class' => renderer_base::prepare_classes($classes))); + $attributes = array_merge(['id' => $id, 'class' => renderer_base::prepare_classes($classes)], $attributes); + return html_writer::start_tag('div', $attributes); } /** diff --git a/lib/phpunit/classes/advanced_testcase.php b/lib/phpunit/classes/advanced_testcase.php index 87d2f442c7ca3..9674a4801561c 100644 --- a/lib/phpunit/classes/advanced_testcase.php +++ b/lib/phpunit/classes/advanced_testcase.php @@ -390,7 +390,8 @@ public function assertEventLegacyLogData($expected, \core\event\base $event, $me } /** - * Assert that an event is not using event->contxet. + * Assert that various event methods are not using event->context + * * While restoring context might not be valid and it should not be used by event url * or description methods. * @@ -410,7 +411,7 @@ public function assertEventContextNotUsed(\core\event\base $event, $message = '' $event->get_url(); $event->get_description(); - // Restore event->context. + // Restore event->context (note that this is unreachable when the event uses context). But ok for correct events. phpunit_event_mock::testable_set_event_context($event, $eventcontext); } diff --git a/lib/phpunit/classes/util.php b/lib/phpunit/classes/util.php index 86e8fa47cbe27..1d2b5fd216eee 100644 --- a/lib/phpunit/classes/util.php +++ b/lib/phpunit/classes/util.php @@ -107,6 +107,9 @@ public static function reset_all_data($detectchanges = false) { // Stop all hook redirections. \core\hook\manager::get_instance()->phpunit_stop_redirections(); + // Reset the hook manager instance. + \core\hook\manager::phpunit_reset_instance(); + // Stop any message redirection. self::stop_message_redirection(); diff --git a/lib/phpunit/tests/advanced_test.php b/lib/phpunit/tests/advanced_test.php index 2088a4e7baafa..6aa4ba18ccbb3 100644 --- a/lib/phpunit/tests/advanced_test.php +++ b/lib/phpunit/tests/advanced_test.php @@ -339,6 +339,54 @@ public function test_assert_time_current() { } } + /** + * Test the assertEventContextNotUsed() assertion. + * + * Verify that events using the event context in some of their + * methods are detected properly (will throw a warning if they are). + * + * To do so, we'll be using some fixture events (context_used_in_event_xxxx), + * that, on purpose, use the event context (incorrectly) in their methods. + * + * Note that because we are using imported fixture classes, and because we + * are testing for warnings, better we run the tests in a separate process. + * + * @param string $fixture The fixture class to use. + * @param bool $phpwarn Whether a PHP warning is expected. + * + * @runInSeparateProcess + * @dataProvider assert_event_context_not_used_provider + * @covers ::assertEventContextNotUsed + */ + public function test_assert_event_context_not_used($fixture, $phpwarn): void { + require(__DIR__ . '/fixtures/event_fixtures.php'); + // Create an event that uses the event context in its get_url() and get_description() methods. + $event = $fixture::create([ + 'other' => [ + 'sample' => 1, + 'xx' => 10, + ], + ]); + + if ($phpwarn) { + $this->expectWarning(); + } + $this->assertEventContextNotUsed($event); + } + + /** + * Data provider for test_assert_event_context_not_used(). + * + * @return array + */ + public static function assert_event_context_not_used_provider(): array { + return [ + 'correct' => ['\core\event\context_used_in_event_correct', false], + 'wrong_get_url' => ['\core\event\context_used_in_event_get_url', true], + 'wrong_get_description' => ['\core\event\context_used_in_event_get_description', true], + ]; + } + public function test_message_processors_reset() { global $DB; diff --git a/lib/phpunit/tests/fixtures/event_fixtures.php b/lib/phpunit/tests/fixtures/event_fixtures.php new file mode 100644 index 0000000000000..7dfa6118211c4 --- /dev/null +++ b/lib/phpunit/tests/fixtures/event_fixtures.php @@ -0,0 +1,80 @@ +. + +/** + * Fixtures for advanced_testcase tests. + * + * @package core + * @category event + * @copyright 2024 onwards Eloy Lafuente (stronk7) {@link https://stronk7.com} + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\event; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Event to test that \advanced_testcase::assertEventContextNotUsed() passes ok when no context is used. + */ +class context_used_in_event_correct extends \core\event\base { + + protected function init() { + $this->data['crud'] = 'u'; + $this->data['edulevel'] = self::LEVEL_PARTICIPATING; + $this->context = \context_system::instance(); + } + + public function get_url() { + return new \moodle_url('/somepath/somefile.php'); // No context used. + } + + public function get_description() { + return 'Description'; // No context used. + } +} + +/** + * Event to test that \advanced_testcase::assertEventContextNotUsed() detects context usage on get_url(). + */ +class context_used_in_event_get_url extends \core\event\base { + + protected function init() { + $this->data['crud'] = 'u'; + $this->data['edulevel'] = self::LEVEL_PARTICIPATING; + $this->context = \context_system::instance(); + } + + public function get_url() { + return new \moodle_url('/somepath/somefile.php', ['id' => $this->context->instanceid]); // Causes a PHP Warning. + } +} + +/** + * Event to test that \advanced_testcase::assertEventContextNotUsed() detects context usage on get_description(). + */ +class context_used_in_event_get_description extends \core\event\base { + + protected function init() { + $this->data['crud'] = 'u'; + $this->data['edulevel'] = self::LEVEL_PARTICIPATING; + $this->context = \context_system::instance(); + } + + public function get_description() { + return $this->context->instanceid . " Description"; // Causes a PHP Warning. + } +} diff --git a/lib/templates/dropzone.mustache b/lib/templates/dropzone.mustache new file mode 100644 index 0000000000000..c7091833d4918 --- /dev/null +++ b/lib/templates/dropzone.mustache @@ -0,0 +1,35 @@ +{{! + This file is part of Moodle - http://moodle.org/ + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core/dropzone + + Render dropzone for file upload + + Example context (json): + { + "filetypes": "image/*,application/pdf" + } +}} +
+ {{# str }} addfiletext, repository {{/ str }} +
+
+ {{# pix }} i/cloudupload, core, {{# str }} addfilesdrop {{/ str }} {{/ pix }} +
+
+ {{# str }} addfilesdrop {{/ str }} +
+
+ +
diff --git a/lib/templates/navbar.mustache b/lib/templates/navbar.mustache index c1834d84c191d..6e94273bfe4ca 100644 --- a/lib/templates/navbar.mustache +++ b/lib/templates/navbar.mustache @@ -68,11 +68,21 @@ }}{{#get_items}} {{#has_action}} {{/has_action}} {{^has_action}} - + {{/has_action}} {{/get_items}}{{! }} diff --git a/lib/tests/behat/behat_deprecated.php b/lib/tests/behat/behat_deprecated.php index 62d655fe4685a..26adc0fa15658 100644 --- a/lib/tests/behat/behat_deprecated.php +++ b/lib/tests/behat/behat_deprecated.php @@ -17,7 +17,6 @@ // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. require_once(__DIR__ . '/../../../lib/behat/behat_deprecated_base.php'); -use Behat\Gherkin\Node\TableNode as TableNode; /** * Steps definitions that are now deprecated and will be removed in the next releases. @@ -32,85 +31,4 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_deprecated extends behat_deprecated_base { - /** - * Opens the activity chooser and opens the activity/resource form page. Sections 0 and 1 are also allowed on frontpage. - * - * @Given /^I add a "(?P(?:[^"]|\\")*)" to section "(?P\d+)"$/ - * @throws \Behat\Mink\Exception\ElementNotFoundException Thrown by behat_base::find - * @param string $activity - * @param int $section - * @deprecated Since Moodle 4.4 - */ - public function i_add_to_section($activity, $section) { - $this->deprecated_message([ - 'behat_course::i_add_to_course_section', - 'behat_course::i_add_to_section_using_the_activity_chooser', - ]); - - $this->require_javascript('Please use the \'the following "activity" exists:\' data generator instead.'); - - if ($this->getSession()->getPage()->find('css', 'body#page-site-index') && (int) $section <= 1) { - // We are on the frontpage. - if ($section) { - // Section 1 represents the contents on the frontpage. - $sectionxpath = "//body[@id='page-site-index']" . - "/descendant::div[contains(concat(' ',normalize-space(@class),' '),' sitetopic ')]"; - } else { - // Section 0 represents "Site main menu" block. - $sectionxpath = "//*[contains(concat(' ',normalize-space(@class),' '),' block_site_main_menu ')]"; - } - } else { - // We are inside the course. - $sectionxpath = "//li[@id='section-" . $section . "']"; - } - - // Clicks add activity or resource section link. - $sectionnode = $this->find('xpath', $sectionxpath); - $this->execute('behat_general::i_click_on_in_the', [ - "//button[@data-action='open-chooser' and not(@data-beforemod)]", - 'xpath', - $sectionnode, - 'NodeElement', - ]); - - // Clicks the selected activity if it exists. - $activityliteral = behat_context_helper::escape(ucfirst($activity)); - $activityxpath = "//div[contains(concat(' ', normalize-space(@class), ' '), ' modchooser ')]" . - "/descendant::div[contains(concat(' ', normalize-space(@class), ' '), ' optioninfo ')]" . - "/descendant::div[contains(concat(' ', normalize-space(@class), ' '), ' optionname ')]" . - "[normalize-space(.)=$activityliteral]" . - "/parent::a"; - - $this->execute('behat_general::i_click_on', [$activityxpath, 'xpath']); - } - - /** - * Adds the selected activity/resource filling the form data with the specified field/value pairs. - * - * Sections 0 and 1 are also allowed on frontpage. - * - * @When /^I add a "(?P(?:[^"]|\\")*)" to section "(?P\d+)" and I fill the form with:$/ - * @param string $activity The activity name - * @param int $section The section number - * @param TableNode $data The activity field/value data - * @deprecated Since Moodle 4.4 - */ - public function i_add_to_section_and_i_fill_the_form_with($activity, $section, TableNode $data) { - $this->deprecated_message(['behat_course::i_add_to_course_section_and_i_fill_the_form_with']); - - // Add activity to section. - $this->execute( - "behat_course::i_add_to_section", - [$this->escape($activity), $this->escape($section)] - ); - - // Wait to be redirected. - $this->execute('behat_general::wait_until_the_page_is_ready'); - - // Set form fields. - $this->execute("behat_forms::i_set_the_following_fields_to_these_values", $data); - - // Save course settings. - $this->execute("behat_forms::press_button", get_string('savechangesandreturntocourse')); - } } diff --git a/lib/tests/event/base_test.php b/lib/tests/event/base_test.php index d749c543d2ab9..30dc2c91cbef9 100644 --- a/lib/tests/event/base_test.php +++ b/lib/tests/event/base_test.php @@ -853,27 +853,6 @@ public function test_iteration() { $this->assertSame($event->get_data(), $data); } - public function test_context_not_used() { - // TODO: MDL-69688 - This test is far away from my understanding. It throws a - // "Trying to get property 'instanceid' of non-object" notice, so - // it's not clear for me what the test is doing. This was detected - // when preparing tests for PHPUnit 8 (MDL-67673) and, at the end - // all that was done is to move the annotation (deprecated) to - // explicit expectation. Still try commenting it out and you'll see - // the notice. - if (PHP_VERSION_ID >= 80000) { - $this->expectWarning(); - } else { - $this->expectNotice(); - } - $event = \core_tests\event\context_used_in_event::create(array('other' => array('sample' => 1, 'xx' => 10))); - $this->assertEventContextNotUsed($event); - - $eventcontext = phpunit_event_mock::testable_get_event_context($event); - phpunit_event_mock::testable_set_event_context($event, null); - $this->assertEventContextNotUsed($event); - } - /** * Test that all observer information is returned correctly. */ diff --git a/lib/tests/fixtures/event_fixtures.php b/lib/tests/fixtures/event_fixtures.php index 33d02ceb2ff09..a8c3bfd5a0fe1 100644 --- a/lib/tests/fixtures/event_fixtures.php +++ b/lib/tests/fixtures/event_fixtures.php @@ -244,25 +244,6 @@ class course_module_viewed_noinit extends \core\event\course_module_viewed { class grade_report_viewed extends \core\event\grade_report_viewed { } -/** - * Event to test context used in event functions - */ -class context_used_in_event extends \core\event\base { - public function get_description() { - return $this->context->instanceid . " Description"; - } - - protected function init() { - $this->data['crud'] = 'u'; - $this->data['edulevel'] = self::LEVEL_PARTICIPATING; - $this->context = \context_system::instance(); - } - - public function get_url() { - return new \moodle_url('/somepath/somefile.php', array('id' => $this->context->instanceid)); - } -} - /** * This is an explanation of the event. * - I'm making a point here. diff --git a/lib/tests/fixtures/fakeplugins/hooktest/classes/callbacks.php b/lib/tests/fixtures/fakeplugins/hooktest/classes/callbacks.php new file mode 100644 index 0000000000000..c0ac0d22cbcf2 --- /dev/null +++ b/lib/tests/fixtures/fakeplugins/hooktest/classes/callbacks.php @@ -0,0 +1,44 @@ +. +namespace fake_hooktest; + +/** + * Class callback container for fake_hooktest + * + * @package core + * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Mark Johnson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class callbacks { + /** + * Test callback that is not replaced by a hook. + * + * @return string + */ + public static function current_class_callback(): string { + return 'Called current class callback'; + } + + /** + * Test callback that is replaced by a hook. + * + * @return string + */ + public static function old_class_callback(): string { + return 'Called deprecated class callback'; + } +} diff --git a/lib/tests/fixtures/fakeplugins/hooktest/classes/hook/hook_replacing_callback.php b/lib/tests/fixtures/fakeplugins/hooktest/classes/hook/hook_replacing_callback.php new file mode 100644 index 0000000000000..921b8670af5e0 --- /dev/null +++ b/lib/tests/fixtures/fakeplugins/hooktest/classes/hook/hook_replacing_callback.php @@ -0,0 +1,53 @@ +. + +namespace fake_hooktest\hook; + +/** + * Fixture for testing of hooks. + * + * @package core + * @author Mark Johnson + * @copyright 2024 Catalyst IT Europe Ltd. + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class hook_replacing_callback implements + \core\hook\described_hook, + \core\hook\deprecated_callback_replacement { + + /** + * Hook description. + */ + public static function get_hook_description(): string { + return 'Test hook replacing a plugin callback function.'; + } + + /** + * Deprecation info. + */ + public static function get_deprecated_plugin_callbacks(): array { + return ['old_callback']; + } + + /** + * List of tags that describe this hook. + * + * @return string[] + */ + public static function get_hook_tags(): array { + return ['test']; + } +} diff --git a/lib/tests/fixtures/fakeplugins/hooktest/classes/hook/hook_replacing_class_callback.php b/lib/tests/fixtures/fakeplugins/hooktest/classes/hook/hook_replacing_class_callback.php new file mode 100644 index 0000000000000..7c96aaebd201d --- /dev/null +++ b/lib/tests/fixtures/fakeplugins/hooktest/classes/hook/hook_replacing_class_callback.php @@ -0,0 +1,33 @@ +. + +namespace fake_hooktest\hook; + +use core\attribute; + +/** + * Fixture for testing of hooks. + * + * @package core + * @author Mark Johnson + * @copyright 2024 Catalyst IT Europe Ltd. + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[attribute\label('Test hook replacing a class callback.')] +#[attribute\tags('test')] +#[attribute\hook\replaces_callbacks('callbacks::old_class_callback')] +final class hook_replacing_class_callback { +} diff --git a/lib/tests/fixtures/fakeplugins/hooktest/classes/hook_callbacks.php b/lib/tests/fixtures/fakeplugins/hooktest/classes/hook_callbacks.php new file mode 100644 index 0000000000000..c3991214f08c8 --- /dev/null +++ b/lib/tests/fixtures/fakeplugins/hooktest/classes/hook_callbacks.php @@ -0,0 +1,44 @@ +. +namespace fake_hooktest; + +/** + * Hook callbacks + * + * @package core + * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Mark Johnson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class hook_callbacks { + /** + * Test callback which replaces a plugin callback. + * + * @return string + */ + public function component_callback_replacement(): string { + return 'Called component callback replacement'; + } + + /** + * Test callback which replaced a plugin class callback. + * + * @return string + */ + public function component_class_callback_replacement(): string { + return 'Called component class callback replacement'; + } +} diff --git a/lib/tests/fixtures/fakeplugins/hooktest/classes/hooks.php b/lib/tests/fixtures/fakeplugins/hooktest/classes/hooks.php new file mode 100644 index 0000000000000..0e3c6c21fc45c --- /dev/null +++ b/lib/tests/fixtures/fakeplugins/hooktest/classes/hooks.php @@ -0,0 +1,41 @@ +. +namespace fake_hooktest; + +/** + * Hook discovery for fake plugin. + * + * @package core + * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Mark Johnson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class hooks implements \core\hook\discovery_agent { + public static function discover_hooks(): array { + return [ + 'fake_hooktest\hook\hook_replacing_callback' => [ + 'class' => 'fake_hooktest\hook\hook_replacing_callback', + 'description' => 'Hook replacing callback', + 'tags' => ['test'], + ], + 'fake_hooktest\hook\hook_replacing_class_callback' => [ + 'class' => 'fake_hooktest\hook\hook_replacing_class_callback', + 'description' => 'Hook replacing class callback', + 'tags' => ['test'], + ], + ]; + } +} diff --git a/lib/tests/fixtures/fakeplugins/hooktest/db/hooks.php b/lib/tests/fixtures/fakeplugins/hooktest/db/hooks.php new file mode 100644 index 0000000000000..c2d71c8ddbd78 --- /dev/null +++ b/lib/tests/fixtures/fakeplugins/hooktest/db/hooks.php @@ -0,0 +1,37 @@ +. +/** + * Hook callback definitions for core + * + * @package core + * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Mark Johnson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +defined('MOODLE_INTERNAL') || die(); + +$callbacks = [ + [ + 'hook' => 'fake_hooktest\hook\hook_replacing_callback', + 'callback' => 'fake_hooktest\hook_callbacks::component_callback_replacement', + 'priority' => 500, + ], + [ + 'hook' => 'fake_hooktest\hook\hook_replacing_class_callback', + 'callback' => 'fake_hooktest\hook_callbacks::component_class_callback_replacement', + 'priority' => 600, + ], +]; diff --git a/mod/quiz/report/statistics/db/events.php b/lib/tests/fixtures/fakeplugins/hooktest/db/hooks_nocallbacks.php similarity index 73% rename from mod/quiz/report/statistics/db/events.php rename to lib/tests/fixtures/fakeplugins/hooktest/db/hooks_nocallbacks.php index 9028cdfb1cb7c..12c4b0cb9921c 100644 --- a/mod/quiz/report/statistics/db/events.php +++ b/lib/tests/fixtures/fakeplugins/hooktest/db/hooks_nocallbacks.php @@ -13,21 +13,15 @@ // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . - /** - * Add event observers for quiz_statistics + * Hook callback definitions for core * - * @package quiz_statistics - * @copyright 2023 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @package core + * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ - defined('MOODLE_INTERNAL') || die(); -$observers = [ - [ - 'eventname' => '\mod_quiz\event\attempt_submitted', - 'callback' => '\quiz_statistics\event\observer\attempt_submitted::process', - ], +$callbacks = [ ]; diff --git a/lib/tests/fixtures/fakeplugins/hooktest/lib.php b/lib/tests/fixtures/fakeplugins/hooktest/lib.php new file mode 100644 index 0000000000000..c9f4e12e9526e --- /dev/null +++ b/lib/tests/fixtures/fakeplugins/hooktest/lib.php @@ -0,0 +1,41 @@ +. +/** + * Library functions for fake_hooktest + * + * @package core + * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Mark Johnson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Test callback that is not replaced by a hook. + * + * @return string + */ +function fake_hooktest_current_callback() { + return 'Called current callback'; +} + +/** + * Test callback that is replaced by a hook. + * + * @return string + */ +function fake_hooktest_old_callback() { + return 'Called deprecated callback'; +} diff --git a/lib/tests/fixtures/fakeplugins/hooktest/version.php b/lib/tests/fixtures/fakeplugins/hooktest/version.php new file mode 100644 index 0000000000000..dac28c28f6eb4 --- /dev/null +++ b/lib/tests/fixtures/fakeplugins/hooktest/version.php @@ -0,0 +1,30 @@ +. + +/** + * Fake plugin for testing hooks. + * + * @package core + * @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Mark Johnson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->version = 2024012200; +$plugin->requires = 2024011900; +$plugin->component = 'fake_hooktest'; diff --git a/lib/tests/fixtures/sectiondelegatetest.php b/lib/tests/fixtures/sectiondelegatetest.php index 0e9959ddf9d28..9d5f706633f48 100644 --- a/lib/tests/fixtures/sectiondelegatetest.php +++ b/lib/tests/fixtures/sectiondelegatetest.php @@ -17,6 +17,8 @@ namespace test_component\courseformat; use core_courseformat\sectiondelegate as sectiondelegatebase; +use core_courseformat\stateupdates; +use section_info; /** * Test class for section delegate. @@ -26,4 +28,28 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class sectiondelegate extends sectiondelegatebase { + /** + * Test method to fake preprocesses the section name by appending a suffix to it. + * + * @param section_info $section The section information. + * @param string|null $newname The new name for the section. + * @return string|null The preprocessed section name with the suffix appended. + */ + public function preprocess_section_name(section_info $section, ?string $newname): ?string { + if (empty($newname)) { + return 'null_name'; + } + return $newname . '_suffix'; + } + + /** + * Test method to add state updates of a section with additional information. + * + * @param section_info $section The section to update. + * @param stateupdates $updates The state updates to apply. + * @return void + */ + public function put_section_state_extra_updates(section_info $section, stateupdates $updates): void { + $updates->add_cm_put($section->itemid); + } } diff --git a/lib/tests/hook/manager_test.php b/lib/tests/hook/manager_test.php index 77a3f55033bca..076a8a2abccb6 100644 --- a/lib/tests/hook/manager_test.php +++ b/lib/tests/hook/manager_test.php @@ -49,8 +49,27 @@ public function test_phpunit_get_instance(): void { $componentfiles = [ 'test_plugin1' => __DIR__ . '/../fixtures/hook/hooks1_valid.php', ]; - $testmanager = manager::phpunit_get_instance($componentfiles); + $testmanager = manager::phpunit_get_instance($componentfiles, true); $this->assertSame(['test_plugin\\hook\\hook'], $testmanager->get_hooks_with_callbacks()); + // With $persist = true, get_instance() returns the test instance until reset. + $manager = manager::get_instance(); + $this->assertSame($testmanager, $manager); + } + + /** + * Test resetting the manager test instance. + * + * @covers ::phpunit_reset_instance + * @return void + */ + public function test_phpunit_reset_instance(): void { + $testmanager = manager::phpunit_get_instance([], true); + $manager = manager::get_instance(); + $this->assertSame($testmanager, $manager); + + manager::phpunit_reset_instance(); + $manager = manager::get_instance(); + $this->assertNotSame($testmanager, $manager); } /** @@ -295,6 +314,211 @@ public function test_callback_overriding(): void { $this->assertSame(['test1'], \test_plugin\callbacks::$calls); \test_plugin\callbacks::$calls = []; $this->assertDebuggingNotCalled(); + $CFG->hooks_callback_overrides = []; + } + + /** + * Register a fake plugin called hooktest in the component manager. + * + * @return void + */ + protected function setup_hooktest_plugin(): void { + global $CFG; + + $mockedcomponent = new \ReflectionClass(\core_component::class); + $mockedplugintypes = $mockedcomponent->getProperty('plugintypes'); + $mockedplugintypes->setAccessible(true); + $plugintypes = $mockedplugintypes->getValue(); + $plugintypes['fake'] = "{$CFG->dirroot}/lib/tests/fixtures/fakeplugins"; + $mockedplugintypes->setValue(null, $plugintypes); + $mockedplugins = $mockedcomponent->getProperty('plugins'); + $mockedplugins->setAccessible(true); + $plugins = $mockedplugins->getValue(); + $plugins['fake'] = ['hooktest' => "{$CFG->dirroot}/lib/tests/fixtures/fakeplugins/hooktest"]; + $mockedplugins->setValue(null, $plugins); + $this->resetDebugging(); + } + + /** + * Remove the fake plugin to avoid interference with other tests. + * + * @return void + */ + protected function remove_hooktest_plugin(): void { + $mockedcomponent = new \ReflectionClass(\core_component::class); + $mockedplugintypes = $mockedcomponent->getProperty('plugintypes'); + $mockedplugintypes->setAccessible(true); + $plugintypes = $mockedplugintypes->getValue(); + unset($plugintypes['fake']); + $mockedplugintypes->setValue(null, $plugintypes); + $mockedplugins = $mockedcomponent->getProperty('plugins'); + $mockedplugins->setAccessible(true); + $plugins = $mockedplugins->getValue(); + unset($plugins['fake']); + $mockedplugins->setValue(null, $plugins); + } + + /** + * Call a plugin callback that has been replaced by a hook, but has no hook callback. + * + * The original callback should be called, but a debugging message should be output. + * + * @covers ::get_hooks_deprecating_plugin_callback() + * @covers ::is_deprecating_hook_present() + * @return void + * @throws \coding_exception + */ + public function test_migrated_callback(): void { + $this->resetAfterTest(true); + // Include plugin hook discovery agent, and the hook that replaces the callback. + require_once(__DIR__ . '/../fixtures/fakeplugins/hooktest/classes/hooks.php'); + require_once(__DIR__ . '/../fixtures/fakeplugins/hooktest/classes/hook/hook_replacing_callback.php'); + // Register the fake plugin with the component manager. + $this->setup_hooktest_plugin(); + + // Register the fake plugin with the hook manager, but don't define any hook callbacks. + manager::phpunit_get_instance( + [ + 'fake_hooktest' => __DIR__ . '/../fixtures/fakeplugins/hooktest/db/hooks_nocallbacks.php', + ], + true + ); + + // Confirm a non-deprecated callback is called as expected. + $this->assertEquals('Called current callback', component_callback('fake_hooktest', 'current_callback')); + + // Confirm the deprecated callback is called as expected. + $this->assertEquals( + 'Called deprecated callback', + component_callback('fake_hooktest', 'old_callback', [], null, true) + ); + $this->assertDebuggingCalled( + 'Callback old_callback in fake_hooktest component should be migrated to new hook '. + 'callback for fake_hooktest\hook\hook_replacing_callback' + ); + $this->remove_hooktest_plugin(); + } + + /** + * Call a plugin callback that has been replaced by a hook, and has a hook callback. + * + * The original callback should not be called, and no debugging should be output. + * + * @covers ::get_hooks_deprecating_plugin_callback() + * @covers ::is_deprecating_hook_present() + * @return void + * @throws \coding_exception + */ + public function test_migrated_callback_with_replacement(): void { + $this->resetAfterTest(true); + // Include plugin hook discovery agent, and the hook that replaces the callback, and a hook callback for the hook. + require_once(__DIR__ . '/../fixtures/fakeplugins/hooktest/classes/hooks.php'); + require_once(__DIR__ . '/../fixtures/fakeplugins/hooktest/classes/hook/hook_replacing_callback.php'); + require_once(__DIR__ . '/../fixtures/fakeplugins/hooktest/classes/hook_callbacks.php'); + // Register the fake plugin with the component manager. + $this->setup_hooktest_plugin(); + + // Register the fake plugin with the hook manager, including the hook callback. + manager::phpunit_get_instance( + [ + 'fake_hooktest' => __DIR__ . '/../fixtures/fakeplugins/hooktest/db/hooks.php', + ], + true + ); + + // Confirm a non-deprecated callback is called as expected. + $this->assertEquals('Called current callback', component_callback('fake_hooktest', 'current_callback')); + + // Confirm the deprecated callback is not called, as expected. + $this->assertNull(component_callback('fake_hooktest', 'old_callback', [], null, true)); + $this->assertDebuggingNotCalled(); + $this->remove_hooktest_plugin(); + } + + /** + * Call a plugin class callback that has been replaced by a hook, but has no hook callback. + * + * The original class callback should be called, but a debugging message should be output. + * + * @covers ::get_hooks_deprecating_plugin_callback() + * @covers ::is_deprecating_hook_present() + * @return void + * @throws \coding_exception + */ + public function test_migrated_class_callback(): void { + $this->resetAfterTest(true); + // Include plugin hook discovery agent, the class containing callbacks, and the hook that replaces the class callback. + require_once(__DIR__ . '/../fixtures/fakeplugins/hooktest/classes/callbacks.php'); + require_once(__DIR__ . '/../fixtures/fakeplugins/hooktest/classes/hooks.php'); + require_once(__DIR__ . '/../fixtures/fakeplugins/hooktest/classes/hook/hook_replacing_class_callback.php'); + // Register the fake plugin with the component manager. + $this->setup_hooktest_plugin(); + + // Register the fake plugin with the hook manager, but don't define any hook callbacks. + manager::phpunit_get_instance( + [ + 'fake_hooktest' => __DIR__ . '/../fixtures/fakeplugins/hooktest/db/hooks_nocallbacks.php', + ], + true + ); + + // Confirm a non-deprecated class callback is called as expected. + $this->assertEquals( + 'Called current class callback', + component_class_callback('fake_hooktest\callbacks', 'current_class_callback', []) + ); + + // Confirm the deprecated class callback is called as expected. + $this->assertEquals( + 'Called deprecated class callback', + component_class_callback('fake_hooktest\callbacks', 'old_class_callback', [], null, true) + ); + $this->assertDebuggingCalled( + 'Callback callbacks::old_class_callback in fake_hooktest component should be migrated to new hook '. + 'callback for fake_hooktest\hook\hook_replacing_class_callback' + ); + $this->remove_hooktest_plugin(); + } + + /** + * Call a plugin class callback that has been replaced by a hook, and has a hook callback. + * + * The original callback should not be called, and no debugging should be output. + * + * @covers ::get_hooks_deprecating_plugin_callback() + * @covers ::is_deprecating_hook_present() + * @return void + * @throws \coding_exception + */ + public function test_migrated_class_callback_with_replacement(): void { + $this->resetAfterTest(true); + // Include plugin hook discovery agent, the class containing callbacks, the hook that replaces the class callback, + // and a hook callback for the new hook. + require_once(__DIR__ . '/../fixtures/fakeplugins/hooktest/classes/callbacks.php'); + require_once(__DIR__ . '/../fixtures/fakeplugins/hooktest/classes/hooks.php'); + require_once(__DIR__ . '/../fixtures/fakeplugins/hooktest/classes/hook/hook_replacing_class_callback.php'); + require_once(__DIR__ . '/../fixtures/fakeplugins/hooktest/classes/hook_callbacks.php'); + // Register the fake plugin with the component manager. + $this->setup_hooktest_plugin(); + + // Register the fake plugin with the hook manager, including the hook callback. + manager::phpunit_get_instance( + [ + 'fake_hooktest' => __DIR__ . '/../fixtures/fakeplugins/hooktest/db/hooks.php', + ], + true + ); + + // Confirm a non-deprecated class callback is called as expected. + $this->assertEquals( + 'Called current class callback', + component_class_callback('fake_hooktest\callbacks', 'current_class_callback', []) + ); + + // Confirm the deprecated class callback is not called, as expected. + $this->assertNull(component_class_callback('fake_hooktest\callbacks', 'old_class_callback', [], null, true)); + $this->assertDebuggingNotCalled(); + $this->remove_hooktest_plugin(); } /** diff --git a/lib/tests/moodle_url_test.php b/lib/tests/moodle_url_test.php index 4c36c81a31151..810f88334aa5b 100644 --- a/lib/tests/moodle_url_test.php +++ b/lib/tests/moodle_url_test.php @@ -52,9 +52,6 @@ public function test_moodle_url_constructor() { $url = new \moodle_url('/index.php', null, 'test'); $this->assertSame($CFG->wwwroot.'/index.php#test', $url->out()); - $url = new \moodle_url('/index.php', null, 'Long "Anchor"'); - $this->assertSame($CFG->wwwroot . '/index.php#Long%20%22Anchor%22', $url->out()); - $url = new \moodle_url('/index.php', array('id' => 2), 'test'); $this->assertSame($CFG->wwwroot.'/index.php?id=2#test', $url->out()); } @@ -141,18 +138,6 @@ public function test_moodle_url_round_trip_array_params() { $this->assertSame($strurl, $url->out(false)); } - /** - * Test returning URL without parameters - */ - public function test_out_omit_querystring(): void { - global $CFG; - - $url = new \moodle_url('/index.php', ['id' => 2], 'Long "Anchor"'); - - $this->assertSame($CFG->wwwroot . '/index.php', $url->out_omit_querystring()); - $this->assertSame($CFG->wwwroot . '/index.php#Long%20%22Anchor%22', $url->out_omit_querystring(true)); - } - public function test_compare_url() { $url1 = new \moodle_url('index.php', array('var1' => 1, 'var2' => 2)); $url2 = new \moodle_url('index2.php', array('var1' => 1, 'var2' => 2, 'var3' => 3)); diff --git a/lib/tests/navigationlib_test.php b/lib/tests/navigationlib_test.php index ed875a7a8e1dd..dc38ebfcfb961 100644 --- a/lib/tests/navigationlib_test.php +++ b/lib/tests/navigationlib_test.php @@ -152,6 +152,22 @@ public function test_node_add_class() { } } + /** + * Test the add_attribute method. + * @covers \navigation_node::add_attribute + */ + public function test_node_add_attribute(): void { + $this->setup_node(); + + $node = $this->node->get('demo1'); + $this->assertInstanceOf('navigation_node', $node); + if ($node !== false) { + $node->add_attribute('data-foo', 'bar'); + $attribute = reset($node->attributes); + $this->assertEqualsCanonicalizing(['name' => 'data-foo', 'value' => 'bar'], $attribute); + } + } + public function test_node_check_if_active() { $this->setup_node(); diff --git a/lib/tests/string_manager_standard_test.php b/lib/tests/string_manager_standard_test.php index 2e296bb46ca02..5f84cfee1a972 100644 --- a/lib/tests/string_manager_standard_test.php +++ b/lib/tests/string_manager_standard_test.php @@ -83,11 +83,11 @@ public function test_deprecated_strings() { $this->assertFalse($stringman->string_deprecated('hidden', 'grades')); // Check deprecated string, make sure to update once that chosen below is finally removed. - $this->assertTrue($stringman->string_deprecated('coursepage', 'core_admin')); - $this->assertTrue($stringman->string_exists('coursepage', 'core_admin')); + $this->assertTrue($stringman->string_deprecated('selectdevice', 'core_admin')); + $this->assertTrue($stringman->string_exists('selectdevice', 'core_admin')); $this->assertDebuggingNotCalled(); - $this->assertEquals('Course page', get_string('coursepage', 'core_admin')); - $this->assertDebuggingCalled('String [coursepage,core_admin] is deprecated. '. + $this->assertEquals('Select device', get_string('selectdevice', 'core_admin')); + $this->assertDebuggingCalled('String [selectdevice,core_admin] is deprecated. '. 'Either you should no longer be using that string, or the string has been incorrectly deprecated, in which case you should report this as a bug. '. 'Please refer to https://moodledev.io/general/projects/api/string-deprecation'); } diff --git a/lib/thirdpartylibs.xml b/lib/thirdpartylibs.xml index 2f91e7074a1bc..e96b97ac5647d 100644 --- a/lib/thirdpartylibs.xml +++ b/lib/thirdpartylibs.xml @@ -73,7 +73,7 @@ htmlpurifier HTML Purifier Filters HTML. - 4.16.0 + 4.17.0 LGPL 2.1+ https://github.com/ezyang/htmlpurifier @@ -446,7 +446,7 @@ All rights reserved. fonts Font Awesome - http://fontawesome.com The Font Awesome font. Font Awesome is the Internet's icon library and toolkit, used by millions of designers, developers, and content creators. - 6.4.0 + 6.5.1 SIL OFL 1.1 https://github.com/FortAwesome/Font-Awesome diff --git a/lib/upgrade.txt b/lib/upgrade.txt index 748413408eaf5..d94d71319819e 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -91,6 +91,14 @@ information provided here is intended especially for developers. - `question_category_options` - `question_add_context_in_key` - `question_fix_top_names` +* Added a new parameter to `core_renderer::container` and `core_renderer::container_start` to allow for the addition of + custom attributes. +* Added a new method `navigation_node::add_attribute()` to allow adding HTML attributes to the node. +* Deprecated core\hook\manager::is_deprecated_plugin_callback() in favour of ::get_hooks_deprecating_plugin_callback(), + which will return the classnames of hooks deprecating a callback, or null if it's not deprecated. The return value can be cast + to bool if the original functionality is desired. +* core\hook\manager::phpunit_get_instance() now sets self::$instance to the mocked instance if the optional $persist argument is + true, so future calls to ::get_instance() will return it. === 4.3 === diff --git a/lib/upgradelib.php b/lib/upgradelib.php index c2e1b17ac3e0d..6ebf38f193589 100644 --- a/lib/upgradelib.php +++ b/lib/upgradelib.php @@ -513,6 +513,13 @@ function upgrade_stale_php_files_present(): bool { global $CFG; $someexamplesofremovedfiles = [ + // Removed in 4.4. + '/README.txt', + '/lib/dataformatlib.php', + '/lib/horde/readme_moodle.txt', + '/lib/yui/src/formchangechecker/js/formchangechecker.js', + '/mod/forum/pix/monologo.png', + '/question/tests/behat/behat_question.php', // Removed in 4.3. '/badges/ajax.php', '/course/editdefaultcompletion.php', diff --git a/lib/weblib.php b/lib/weblib.php index c7ec6e278aa9b..cbaaeca17f7e5 100644 --- a/lib/weblib.php +++ b/lib/weblib.php @@ -621,7 +621,7 @@ public function raw_out($escaped = true, array $overrideparams = null) { $uri .= '?' . $querystring; } if (!is_null($this->anchor)) { - $uri .= '#' . rawurlencode($this->anchor); + $uri .= '#'.$this->anchor; } return $uri; @@ -641,7 +641,7 @@ public function out_omit_querystring($includeanchor = false) { $uri .= $this->port ? ':'.$this->port : ''; $uri .= $this->path ? $this->path : ''; if ($includeanchor and !is_null($this->anchor)) { - $uri .= '#' . rawurlencode($this->anchor); + $uri .= '#' . $this->anchor; } return $uri; diff --git a/media/player/videojs/lang/en/deprecated.txt b/media/player/videojs/lang/en/deprecated.txt deleted file mode 100644 index f3abc1bd83bb9..0000000000000 --- a/media/player/videojs/lang/en/deprecated.txt +++ /dev/null @@ -1,4 +0,0 @@ -configrtmp,media_videojs -configuseflash,media_videojs -rtmp,media_videojs -useflash,media_videojs diff --git a/media/player/videojs/lang/en/media_videojs.php b/media/player/videojs/lang/en/media_videojs.php index c608c2afe5709..35235c4635769 100644 --- a/media/player/videojs/lang/en/media_videojs.php +++ b/media/player/videojs/lang/en/media_videojs.php @@ -39,9 +39,3 @@ $string['videoextensions'] = 'Video file extensions'; $string['videocssclass'] = 'CSS class for video'; $string['youtube'] = 'YouTube videos'; - -// Deprecated since Moodle 4.0. -$string['configrtmp'] = 'If enabled, links that start with rtmp:// will be handled by the plugin, irrespective of whether the extension is enabled in the Video file extensions (videoextensions) setting. Flash fallback must be enabled for RTMP to work.'; -$string['configuseflash'] = 'Use Flash player if video format is not natively supported by the browser and/or natively by the VideoJS player. If enabled, VideoJS will be engaged for any file extension from the above list without browser check. Please note that Flash is not available in mobile browsers and discouraged in many desktop ones.'; -$string['rtmp'] = 'RTMP streams'; -$string['useflash'] = 'Use Flash fallback'; diff --git a/mod/book/deprecatedlib.php b/mod/book/deprecatedlib.php new file mode 100644 index 0000000000000..8c2c23ec10ee1 --- /dev/null +++ b/mod/book/deprecatedlib.php @@ -0,0 +1,38 @@ +. + +/** + * List of deprecated mod_book functions + * + * @package mod_book + * @copyright 2024 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * @deprecated since Moodle 3.8 + */ +function book_scale_used() { + throw new coding_exception('book_scale_used() can not be used anymore. Plugins can implement ' . + '_scale_used_anywhere, all implementations of _scale_used are now ignored'); +} + +/** + * @deprecated since Moodle 4.0 + */ +function book_get_nav_types() { + throw new coding_exception(__FUNCTION__ . '() has been removed.'); +} diff --git a/mod/book/lang/en/book.php b/mod/book/lang/en/book.php index 7f2e9055d04c3..a9aeda657b73a 100644 --- a/mod/book/lang/en/book.php +++ b/mod/book/lang/en/book.php @@ -72,14 +72,11 @@ $string['search:chapter'] = 'Book - chapters'; $string['showchapter'] = 'Show chapter "{$a}"'; $string['subchapter'] = 'Subchapter'; -$string['navimages'] = 'Images'; $string['navoptions'] = 'Available options for navigational links'; $string['navoptions_desc'] = 'Options for displaying navigation on the book pages'; $string['navstyle'] = 'Style of navigation'; $string['navstyle_help'] = '* Images - Icons are used for navigation * Text - Chapter titles are used for navigation'; -$string['navtext'] = 'Text'; -$string['navtoc'] = 'TOC Only'; $string['nocontent'] = 'No content has been added to this book yet.'; $string['numbering'] = 'Chapter formatting'; $string['numbering_help'] = '* None - Chapter and subchapter titles have no formatting @@ -117,3 +114,8 @@ $string['removeallbooktags'] = 'Remove all book tags'; $string['tagarea_book_chapters'] = 'Book chapters'; $string['tagsdeleted'] = 'Book tags have been deleted'; + +// Deprecated since Moodle 4.4. +$string['navimages'] = 'Images'; +$string['navtext'] = 'Text'; +$string['navtoc'] = 'TOC Only'; diff --git a/mod/book/lang/en/deprecated.txt b/mod/book/lang/en/deprecated.txt new file mode 100644 index 0000000000000..432f97ebbf027 --- /dev/null +++ b/mod/book/lang/en/deprecated.txt @@ -0,0 +1,3 @@ +navimages,mod_book +navtext,mod_book +navtoc,mod_book diff --git a/mod/book/lib.php b/mod/book/lib.php index d5a0d96486246..4ad547c73a39c 100644 --- a/mod/book/lib.php +++ b/mod/book/lib.php @@ -24,6 +24,8 @@ defined('MOODLE_INTERNAL') || die; +require_once(__DIR__ . '/deprecatedlib.php'); + /** * Returns list of available numbering types * @return array @@ -41,23 +43,6 @@ function book_get_numbering_types() { ); } -/** - * Returns list of available navigation link types. - * - * @deprecated since Moodle 4.0. MDL-72376. - * @return array - */ -function book_get_nav_types() { - debugging("book_get_nav_types() is deprecated. There is no replacement. Navigation is now only next and previous."); - require_once(__DIR__.'/locallib.php'); - - return array ( - BOOK_LINK_TOCONLY => get_string('navtoc', 'mod_book'), - BOOK_LINK_IMAGE => get_string('navimages', 'mod_book'), - BOOK_LINK_TEXT => get_string('navtext', 'mod_book'), - ); -} - /** * Returns list of available navigation link CSS classes. * @return array @@ -217,14 +202,6 @@ function book_grades($bookid) { return null; } -/** - * @deprecated since Moodle 3.8 - */ -function book_scale_used() { - throw new coding_exception('book_scale_used() can not be used anymore. Plugins can implement ' . - '_scale_used_anywhere, all implementations of _scale_used are now ignored'); -} - /** * Checks if scale is being used by any instance of book * diff --git a/mod/book/locallib.php b/mod/book/locallib.php index 84636d8311c53..7cf022b403094 100644 --- a/mod/book/locallib.php +++ b/mod/book/locallib.php @@ -39,16 +39,6 @@ define('BOOK_NUM_BULLETS', '2'); define('BOOK_NUM_INDENTED', '3'); -/** - * The following defines are used to define the navigation style used within a book. - * BOOK_LINK_TOCONLY Only the table of contents is shown, in a side region. - * BOOK_LINK_IMAGE Arrows link to previous/next/exit pages, in addition to the TOC. - * BOOK_LINK_TEXT Page names and arrows link to previous/next/exit pages, in addition to the TOC. - */ -define ('BOOK_LINK_TOCONLY', '0'); -define ('BOOK_LINK_IMAGE', '1'); -define ('BOOK_LINK_TEXT', '2'); - /** * Preload book chapters and fix toc structure if necessary. * diff --git a/mod/book/upgrade.txt b/mod/book/upgrade.txt index e3a7853754e39..dce1213b60600 100644 --- a/mod/book/upgrade.txt +++ b/mod/book/upgrade.txt @@ -1,5 +1,9 @@ This files describes API changes in the book code. +=== 4.4 === + +* The previously deprecated `book_get_nav_types` method has been removed, along with the `BOOK_LINK_*` constants + === 4.0 === * book_get_nav_types() has been deprecated. Related settings have been removed. The navigation is now set to only "next" and diff --git a/mod/chat/lang/en/chat.php b/mod/chat/lang/en/chat.php index 1cc170d5bd999..02a8ca0955c4d 100644 --- a/mod/chat/lang/en/chat.php +++ b/mod/chat/lang/en/chat.php @@ -175,6 +175,3 @@ * Beeps - You can send a sound to other participants by clicking the "beep" link next to their name. A useful shortcut to beep all the people in the chat at once is to type "beep all". * HTML - If you know some HTML code, you can use it in your text to do things like insert images, play sounds or create different coloured text'; $string['viewreport'] = 'Past sessions'; - -// Deprecated since Moodle 4.0. -$string['nextsession'] = 'Next scheduled session'; diff --git a/mod/chat/lang/en/deprecated.txt b/mod/chat/lang/en/deprecated.txt deleted file mode 100644 index 537cf2d30f1d7..0000000000000 --- a/mod/chat/lang/en/deprecated.txt +++ /dev/null @@ -1 +0,0 @@ -nextsession,mod_chat diff --git a/mod/data/db/upgrade.php b/mod/data/db/upgrade.php index efa615abe6ca5..32718e31e2e9d 100644 --- a/mod/data/db/upgrade.php +++ b/mod/data/db/upgrade.php @@ -67,5 +67,17 @@ function xmldb_data_upgrade($oldversion) { // Automatically generated Moodle v4.3.0 release upgrade line. // Put any upgrade step following this. + if ($oldversion < 2023100901) { + // Clean param1 for "text" fields because it was unused. + $DB->execute( + "UPDATE {data_fields} + SET param1 = '' + WHERE type = 'text'" + ); + + // Data savepoint reached. + upgrade_mod_savepoint(true, 2023100901, 'data'); + } + return true; } diff --git a/mod/data/field/text/mod.html b/mod/data/field/text/mod.html index 597e7639a0979..10962da8df2cd 100644 --- a/mod/data/field/text/mod.html +++ b/mod/data/field/text/mod.html @@ -11,8 +11,4 @@ field->required?"checked=\"checked\"":""); ?>/> - - - field->param1) {echo 'checked="checked"';} ?> value="1" /> - diff --git a/mod/data/lang/en/data.php b/mod/data/lang/en/data.php index 70205abf1bb8f..caeee04f10cf9 100644 --- a/mod/data/lang/en/data.php +++ b/mod/data/lang/en/data.php @@ -170,7 +170,6 @@ $string['exportpreset'] = 'Export preset'; $string['failedpresetdelete'] = 'An error was encountered while trying to delete the preset.'; $string['fieldadded'] = 'Field added'; -$string['fieldallowautolink'] = 'Allow autolink'; $string['fielddeleted'] = 'Field deleted'; $string['fielddelimiter'] = 'Field separator'; $string['fielddescription'] = 'Field description'; @@ -489,3 +488,6 @@ // Deprecated since Moodle 4.3. $string['completionentries'] = 'Require entries'; + +// Deprecated since Moodle 4.4. +$string['fieldallowautolink'] = 'Allow autolink'; diff --git a/mod/data/lang/en/deprecated.txt b/mod/data/lang/en/deprecated.txt index 2e5d4e50bc238..0c52e751bba25 100644 --- a/mod/data/lang/en/deprecated.txt +++ b/mod/data/lang/en/deprecated.txt @@ -9,3 +9,4 @@ addentries,mod_data todatabase,mod_data fieldids,mod_data completionentries,mod_data +fieldallowautolink,mod_data diff --git a/mod/data/tests/generator/lib.php b/mod/data/tests/generator/lib.php index 559f0f0b78082..eec22b7e01714 100644 --- a/mod/data/tests/generator/lib.php +++ b/mod/data/tests/generator/lib.php @@ -134,7 +134,7 @@ public function create_field(stdClass $record = null, $data = null) { $record['param1'] = implode("\n", array('menu1', 'menu2', 'menu3', 'menu4')); } else if ($record['type'] == 'multimenu') { $record['param1'] = implode("\n", array('multimenu1', 'multimenu2', 'multimenu3', 'multimenu4')); - } else if (($record['type'] === 'text') || ($record['type'] === 'url')) { + } else if ($record['type'] === 'url') { $record['param1'] = 1; } else if ($record['type'] == 'latlong') { $record['param1'] = 'Google Maps'; diff --git a/mod/data/version.php b/mod/data/version.php index 4ba8f64acf7b1..bc43b2baee131 100644 --- a/mod/data/version.php +++ b/mod/data/version.php @@ -24,7 +24,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2023100900; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2023100901; // The current module version (Date: YYYYMMDDXX). $plugin->requires = 2023100400; // Requires this Moodle version. $plugin->component = 'mod_data'; // Full name of the plugin (used for diagnostics) $plugin->cron = 0; diff --git a/mod/forum/lang/en/deprecated.txt b/mod/forum/lang/en/deprecated.txt index 2c694a40b32e8..6684b4153be8c 100644 --- a/mod/forum/lang/en/deprecated.txt +++ b/mod/forum/lang/en/deprecated.txt @@ -1 +1,5 @@ -postmailinfolink,mod_forum +completionpostsgroup,mod_forum +completiondiscussionsgroup,mod_forum +completiondiscussionshelp,mod_forum +completionrepliesgroup,mod_forum +completionreplieshelp,mod_forum diff --git a/mod/forum/lang/en/forum.php b/mod/forum/lang/en/forum.php index 0273cc257d3b5..3ac6fc73ac293 100644 --- a/mod/forum/lang/en/forum.php +++ b/mod/forum/lang/en/forum.php @@ -792,11 +792,6 @@ $string['viewconversation'] = 'View discussion'; $string['viewgrades'] = 'View grades'; -// Deprecated since Moodle 4.0. -$string['postmailinfolink'] = 'This is a copy of a message posted in {$a->coursename}. - -To reply click on this link: {$a->replylink}'; - // Deprecated since Moodle 4.3. $string['completionpostsgroup'] = 'Require posts'; $string['completiondiscussionsgroup'] = 'Require discussions'; diff --git a/mod/forum/lib.php b/mod/forum/lib.php index fae6620c12a61..f8746ba995f38 100644 --- a/mod/forum/lib.php +++ b/mod/forum/lib.php @@ -6478,7 +6478,7 @@ function forum_get_coursemodule_info($coursemodule) { global $DB; $dbparams = ['id' => $coursemodule->instance]; - $fields = 'id, name, intro, introformat, completionposts, completiondiscussions, completionreplies, duedate, cutoffdate'; + $fields = 'id, name, intro, introformat, completionposts, completiondiscussions, completionreplies, duedate, cutoffdate, trackingtype'; if (!$forum = $DB->get_record('forum', $dbparams, $fields)) { return false; } @@ -6505,6 +6505,8 @@ function forum_get_coursemodule_info($coursemodule) { if ($forum->cutoffdate) { $result->customdata['cutoffdate'] = $forum->cutoffdate; } + // Add the forum type to the custom data for Web Services (core_course_get_contents). + $result->customdata['trackingtype'] = $forum->trackingtype; return $result; } diff --git a/mod/glossary/lang/en/deprecated.txt b/mod/glossary/lang/en/deprecated.txt index 082184ce9b594..68e04c0abae87 100644 --- a/mod/glossary/lang/en/deprecated.txt +++ b/mod/glossary/lang/en/deprecated.txt @@ -1,2 +1 @@ -waitingapproval,mod_glossary completionentriesgroup,mod_glossary diff --git a/mod/glossary/lang/en/glossary.php b/mod/glossary/lang/en/glossary.php index eeec66f2cb999..5629fa8599507 100644 --- a/mod/glossary/lang/en/glossary.php +++ b/mod/glossary/lang/en/glossary.php @@ -339,8 +339,5 @@ $string['writtenby'] = 'by'; $string['youarenottheauthor'] = 'You are not the author of this comment, so you are not allowed to edit it.'; -// Deprecated since 4.0. -$string['waitingapproval'] = 'Waiting approval'; - // Deprecated since 4.3. $string['completionentriesgroup'] = 'Require entries'; diff --git a/mod/lesson/locallib.php b/mod/lesson/locallib.php index a780601b4e5d2..238e2ddddbd01 100644 --- a/mod/lesson/locallib.php +++ b/mod/lesson/locallib.php @@ -1355,8 +1355,9 @@ final public function definition() { $mform->addElement('hidden', 'qtype'); $mform->setType('qtype', PARAM_INT); - $mform->addElement('text', 'title', get_string('pagetitle', 'lesson'), array('size'=>70)); + $mform->addElement('text', 'title', get_string('pagetitle', 'lesson'), ['size' => 70, 'maxlength' => 255]); $mform->addRule('title', get_string('required'), 'required', null, 'client'); + $mform->addRule('title', get_string('maximumchars', '', 255), 'maxlength', 255, 'client'); if (!empty($CFG->formatstringstriptags)) { $mform->setType('title', PARAM_TEXT); } else { diff --git a/mod/lesson/pagetypes/branchtable.php b/mod/lesson/pagetypes/branchtable.php index 7ce0f2c8d1f44..6f0f5155d5914 100644 --- a/mod/lesson/pagetypes/branchtable.php +++ b/mod/lesson/pagetypes/branchtable.php @@ -339,8 +339,9 @@ public function custom_definition() { $mform->addElement('hidden', 'qtype'); $mform->setType('qtype', PARAM_INT); - $mform->addElement('text', 'title', get_string("pagetitle", "lesson"), array('size'=>70)); - $mform->addRule('title', null, 'required', null, 'server'); + $mform->addElement('text', 'title', get_string("pagetitle", "lesson"), ['size' => 70, 'maxlength' => 255]); + $mform->addRule('title', null, 'required', null, 'client'); + $mform->addRule('title', get_string('maximumchars', '', 255), 'maxlength', 255, 'client'); if (!empty($CFG->formatstringstriptags)) { $mform->setType('title', PARAM_TEXT); } else { diff --git a/mod/lesson/pagetypes/cluster.php b/mod/lesson/pagetypes/cluster.php index 74948d04730be..d3a315cd4e09c 100644 --- a/mod/lesson/pagetypes/cluster.php +++ b/mod/lesson/pagetypes/cluster.php @@ -131,7 +131,8 @@ public function custom_definition() { $mform->addElement('hidden', 'qtype'); $mform->setType('qtype', PARAM_TEXT); - $mform->addElement('text', 'title', get_string("pagetitle", "lesson"), array('size'=>70)); + $mform->addElement('text', 'title', get_string("pagetitle", "lesson"), ['size' => 70, 'maxlength' => 255]); + $mform->addRule('title', get_string('maximumchars', '', 255), 'maxlength', 255, 'client'); if (!empty($CFG->formatstringstriptags)) { $mform->setType('title', PARAM_TEXT); } else { diff --git a/mod/lesson/pagetypes/endofbranch.php b/mod/lesson/pagetypes/endofbranch.php index dc5409cee79ae..c8974a4dfd0e7 100644 --- a/mod/lesson/pagetypes/endofbranch.php +++ b/mod/lesson/pagetypes/endofbranch.php @@ -159,7 +159,8 @@ public function custom_definition() { $mform->addElement('hidden', 'qtype'); $mform->setType('qtype', PARAM_TEXT); - $mform->addElement('text', 'title', get_string("pagetitle", "lesson"), array('size'=>70)); + $mform->addElement('text', 'title', get_string("pagetitle", "lesson"), ['size' => 70, 'maxlength' => 255]); + $mform->addRule('title', get_string('maximumchars', '', 255), 'maxlength', 255, 'client'); if (!empty($CFG->formatstringstriptags)) { $mform->setType('title', PARAM_TEXT); } else { diff --git a/mod/lesson/pagetypes/endofcluster.php b/mod/lesson/pagetypes/endofcluster.php index 0505013956598..ba493535c02a6 100644 --- a/mod/lesson/pagetypes/endofcluster.php +++ b/mod/lesson/pagetypes/endofcluster.php @@ -139,7 +139,8 @@ public function custom_definition() { $mform->addElement('hidden', 'qtype'); $mform->setType('qtype', PARAM_TEXT); - $mform->addElement('text', 'title', get_string("pagetitle", "lesson"), array('size'=>70)); + $mform->addElement('text', 'title', get_string("pagetitle", "lesson"), ['size' => 70, 'maxlength' => 255]); + $mform->addRule('title', get_string('maximumchars', '', 255), 'maxlength', 255, 'client'); if (!empty($CFG->formatstringstriptags)) { $mform->setType('title', PARAM_TEXT); } else { diff --git a/mod/page/lang/en/deprecated.txt b/mod/page/lang/en/deprecated.txt deleted file mode 100644 index f591c2b101887..0000000000000 --- a/mod/page/lang/en/deprecated.txt +++ /dev/null @@ -1,2 +0,0 @@ -printheading,mod_page -printheadingexplain,mod_page diff --git a/mod/page/lang/en/page.php b/mod/page/lang/en/page.php index 3553ad2b2aaa1..8bff5f574d381 100644 --- a/mod/page/lang/en/page.php +++ b/mod/page/lang/en/page.php @@ -72,7 +72,3 @@ $string['printlastmodifiedexplain'] = 'Display last modified date below content?'; $string['privacy:metadata'] = 'The Page resource plugin does not store any personal data.'; $string['search:activity'] = 'Page'; - -// Deprecated since 4.0. -$string['printheading'] = 'Display page name'; -$string['printheadingexplain'] = 'Display page name above content?'; diff --git a/mod/quiz/classes/grade_calculator.php b/mod/quiz/classes/grade_calculator.php index f0d74f7735a0f..122088297af93 100644 --- a/mod/quiz/classes/grade_calculator.php +++ b/mod/quiz/classes/grade_calculator.php @@ -18,6 +18,7 @@ use coding_exception; use mod_quiz\event\quiz_grade_updated; +use mod_quiz\hook\structure_modified; use question_engine_data_mapper; use stdClass; @@ -92,10 +93,14 @@ public function recompute_quiz_sumgrades(): void { self::update_quiz_maximum_grade(0); } + // This class callback is deprecated, and will be removed in Moodle 4.8 (MDL-80327). + // Use the structure_modified hook instead. $callbackclasses = \core_component::get_plugin_list_with_class('quiz', 'quiz_structure_modified'); foreach ($callbackclasses as $callbackclass) { - component_class_callback($callbackclass, 'callback', [$quiz->id]); + component_class_callback($callbackclass, 'callback', [$quiz->id], null, true); } + + \core\hook\manager::get_instance()->dispatch(new structure_modified($this->quizobj->get_structure())); } /** diff --git a/mod/quiz/classes/hook/attempt_state_changed.php b/mod/quiz/classes/hook/attempt_state_changed.php new file mode 100644 index 0000000000000..0a97413645c75 --- /dev/null +++ b/mod/quiz/classes/hook/attempt_state_changed.php @@ -0,0 +1,71 @@ +. +namespace mod_quiz\hook; + +use core\attribute; + +/** + * A quiz attempt changed state. + * + * @package mod_quiz + * @copyright 2023 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Mark Johnson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[attribute\label('A quiz attempt changed state.')] +#[attribute\tags('quiz', 'attempt')] +#[attribute\hook\replaces_callbacks('quiz_attempt_deleted::callback')] +class attempt_state_changed { + /** + * Create a new hook instance. + * + * @param ?\stdClass $originalattempt The original database record for the attempt, null if it has just been created. + * @param ?\stdClass $updatedattempt The updated database record of the new attempt, null if it has just been deleted. + */ + public function __construct( + protected ?\stdClass $originalattempt, + protected ?\stdClass $updatedattempt, + ) { + if (is_null($this->originalattempt) && is_null($this->updatedattempt)) { + throw new \InvalidArgumentException('originalattempt and updatedattempt cannot both be null.'); + } + if ( + !is_null($this->originalattempt) + && !is_null($this->updatedattempt) + && $this->originalattempt->id != $this->updatedattempt->id + ) { + throw new \InvalidArgumentException('originalattempt and updatedattempt must have the same id.'); + } + } + + /** + * Get the original attempt, null if it has just been created. + * + * @return ?\stdClass + */ + public function get_original_attempt(): ?\stdClass { + return $this->originalattempt; + } + + /** + * Get the updated attempt, null if it has just been deleted. + * + * @return ?\stdClass + */ + public function get_updated_attempt(): ?\stdClass { + return $this->updatedattempt; + } +} diff --git a/mod/quiz/classes/hook/structure_modified.php b/mod/quiz/classes/hook/structure_modified.php new file mode 100644 index 0000000000000..e39bbf0c349b9 --- /dev/null +++ b/mod/quiz/classes/hook/structure_modified.php @@ -0,0 +1,51 @@ +. +namespace mod_quiz\hook; + +use core\attribute; +use mod_quiz\structure; + +/** + * The quiz structure has been modified + * + * @package mod_quiz + * @copyright 2023 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Mark Johnson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[attribute\label('The quiz structure has been modified')] +#[attribute\tags('quiz', 'structure')] +#[attribute\hook\replaces_callbacks('quiz_structure_modified::callback')] +class structure_modified { + /** + * Create a new hook with the modified structure. + * + * @param structure $structure The new structure. + */ + public function __construct( + protected structure $structure + ) { + } + + /** + * Returns the new structure of the quiz. + * + * @return structure The structure object. + */ + public function get_structure(): structure { + return $this->structure; + } +} diff --git a/mod/quiz/classes/quiz_attempt.php b/mod/quiz/classes/quiz_attempt.php index 9354959d04d6b..08c40dac12db0 100644 --- a/mod/quiz/classes/quiz_attempt.php +++ b/mod/quiz/classes/quiz_attempt.php @@ -23,6 +23,7 @@ use context_module; use Exception; use html_writer; +use mod_quiz\hook\attempt_state_changed; use mod_quiz\output\links_to_other_attempts; use mod_quiz\output\renderer; use mod_quiz\question\bank\qbank_helper; @@ -1763,6 +1764,8 @@ public function process_finish($timestamp, $processsubmitted, $timefinish = null question_engine::save_questions_usage_by_activity($this->quba); + $originalattempt = clone $this->attempt; + $this->attempt->timemodified = $timestamp; $this->attempt->timefinish = $timefinish ?? $timestamp; $this->attempt->sumgrades = $this->quba->get_total_mark(); @@ -1784,6 +1787,7 @@ public function process_finish($timestamp, $processsubmitted, $timefinish = null // Trigger event. $this->fire_state_transition_event('\mod_quiz\event\attempt_submitted', $timestamp, $studentisonline); + \core\hook\manager::get_instance()->dispatch(new attempt_state_changed($originalattempt, $this->attempt)); // Tell any access rules that care that the attempt is over. $this->get_access_manager($timestamp)->current_attempt_finished(); } @@ -1820,6 +1824,7 @@ protected function recompute_final_grade(): void { public function process_going_overdue($timestamp, $studentisonline) { global $DB; + $originalattempt = clone $this->attempt; $transaction = $DB->start_delegated_transaction(); $this->attempt->timemodified = $timestamp; $this->attempt->state = self::OVERDUE; @@ -1830,6 +1835,7 @@ public function process_going_overdue($timestamp, $studentisonline) { $this->fire_state_transition_event('\mod_quiz\event\attempt_becameoverdue', $timestamp, $studentisonline); + \core\hook\manager::get_instance()->dispatch(new attempt_state_changed($originalattempt, $this->attempt)); $transaction->allow_commit(); quiz_send_overdue_message($this); @@ -1844,6 +1850,7 @@ public function process_going_overdue($timestamp, $studentisonline) { public function process_abandon($timestamp, $studentisonline) { global $DB; + $originalattempt = clone $this->attempt; $transaction = $DB->start_delegated_transaction(); $this->attempt->timemodified = $timestamp; $this->attempt->state = self::ABANDONED; @@ -1852,6 +1859,8 @@ public function process_abandon($timestamp, $studentisonline) { $this->fire_state_transition_event('\mod_quiz\event\attempt_abandoned', $timestamp, $studentisonline); + \core\hook\manager::get_instance()->dispatch(new attempt_state_changed($originalattempt, $this->attempt)); + $transaction->allow_commit(); } @@ -1872,6 +1881,7 @@ public function process_reopen_abandoned($timestamp) { throw new coding_exception('Can only reopen an attempt that was never submitted.'); } + $originalattempt = clone $this->attempt; $transaction = $DB->start_delegated_transaction(); $this->attempt->timemodified = $timestamp; $this->attempt->state = self::IN_PROGRESS; @@ -1880,6 +1890,7 @@ public function process_reopen_abandoned($timestamp) { $this->fire_state_transition_event('\mod_quiz\event\attempt_reopened', $timestamp, false); + \core\hook\manager::get_instance()->dispatch(new attempt_state_changed($originalattempt, $this->attempt)); $timeclose = $this->get_access_manager($timestamp)->get_end_time($this->attempt); if ($timeclose && $timestamp > $timeclose) { $this->process_finish($timestamp, false, $timeclose); diff --git a/mod/quiz/lang/en/deprecated.txt b/mod/quiz/lang/en/deprecated.txt index e6e4750db23c5..07754e4fe633d 100644 --- a/mod/quiz/lang/en/deprecated.txt +++ b/mod/quiz/lang/en/deprecated.txt @@ -1,9 +1,3 @@ -completionpass,mod_quiz -completionpassdesc,mod_quiz -completionpass_help,mod_quiz -completiondetail:passgrade,mod_quiz -gradetopassnotset,mod_quiz -basicideasofquiz,mod_quiz completionminattemptsgroup,mod_quiz grade,mod_quiz timetaken,mod_quiz diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index d8b5b082bf657..579fd1c46ce27 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -1078,14 +1078,6 @@ $string['alwayslatest'] = 'Always latest'; $string['gobacktoquiz'] = 'Go back'; -// Deprecated since Moodle 4.0. -$string['completionpass'] = 'Require passing grade'; -$string['completionpassdesc'] = 'Student must achieve a passing grade to complete this activity'; -$string['completionpass_help'] = 'If enabled, this activity is considered complete when the student receives a pass grade (as specified in the Grade section of the quiz settings) or higher.'; -$string['completiondetail:passgrade'] = 'Receive a pass grade'; -$string['gradetopassnotset'] = 'This quiz does not yet have a grade to pass set. It may be set in the Grade section of the quiz settings.'; -$string['basicideasofquiz'] = 'The basic ideas of quiz-making'; - // Deprecated since Moodle 4.3. $string['completionminattemptsgroup'] = 'Require attempts'; diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index aa5167d68afeb..eb2b00b7c7186 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -39,6 +39,7 @@ use mod_quiz\access_manager; use mod_quiz\event\attempt_submitted; use mod_quiz\grade_calculator; +use mod_quiz\hook\attempt_state_changed; use mod_quiz\question\bank\qbank_helper; use mod_quiz\question\display_options; use mod_quiz\quiz_attempt; @@ -145,6 +146,8 @@ function quiz_create_attempt(quiz_settings $quizobj, $attemptnumber, $lastattemp $attempt->timecheckstate = $timeclose; } + \core\hook\manager::get_instance()->dispatch(new attempt_state_changed(null, $attempt)); + return $attempt; } /** @@ -448,10 +451,14 @@ function quiz_delete_attempt($attempt, $quiz) { $event->add_record_snapshot('quiz_attempts', $attempt); $event->trigger(); + // This class callback is deprecated, and will be removed in Moodle 4.8 (MDL-80327). + // Use the attempt_state_changed hook instead. $callbackclasses = \core_component::get_plugin_list_with_class('quiz', 'quiz_attempt_deleted'); foreach ($callbackclasses as $callbackclass) { - component_class_callback($callbackclass, 'callback', [$quiz->id]); + component_class_callback($callbackclass, 'callback', [$quiz->id], null, true); } + + \core\hook\manager::get_instance()->dispatch(new attempt_state_changed($attempt, null)); } // Search quiz_attempts for other instances by this user. diff --git a/mod/quiz/report/grading/lang/en/deprecated.txt b/mod/quiz/report/grading/lang/en/deprecated.txt index fa0c6258e67c6..018ae66be99ce 100644 --- a/mod/quiz/report/grading/lang/en/deprecated.txt +++ b/mod/quiz/report/grading/lang/en/deprecated.txt @@ -1,8 +1 @@ -bydate,quiz_grading -bystudentidnumber,quiz_grading -bystudentfirstname,quiz_grading -bystudentlastname,quiz_grading -gradingattemptwithidnumber,quiz_grading -orderattempts,quiz_grading -randomly,quiz_grading grade,quiz_grading diff --git a/mod/quiz/report/grading/lang/en/quiz_grading.php b/mod/quiz/report/grading/lang/en/quiz_grading.php index 67b9b49f8fad2..4b73324eddcc4 100644 --- a/mod/quiz/report/grading/lang/en/quiz_grading.php +++ b/mod/quiz/report/grading/lang/en/quiz_grading.php @@ -77,12 +77,5 @@ $string['unknownquestion'] = 'Unknown question'; $string['updategrade'] = 'update grades'; -// Deprecated since Moodle 4.0. -$string['bydate'] = 'By date'; -$string['bystudentidnumber'] = 'By student ID number'; -$string['bystudentfirstname'] = 'By student first name'; -$string['bystudentlastname'] = 'By student last name'; -$string['gradingattemptwithidnumber'] = 'Attempt number {$a->attempt} for {$a->fullname} ({$a->idnumber})'; -$string['orderattempts'] = 'Order attempts'; -$string['randomly'] = 'Randomly'; +// Deprecated since Moodle 4.4. $string['grade'] = 'grade'; diff --git a/mod/quiz/report/statistics/classes/event/observer/attempt_submitted.php b/mod/quiz/report/statistics/classes/event/observer/attempt_submitted.php index b83ff8a7ef9ac..8d058feb23c04 100644 --- a/mod/quiz/report/statistics/classes/event/observer/attempt_submitted.php +++ b/mod/quiz/report/statistics/classes/event/observer/attempt_submitted.php @@ -16,6 +16,7 @@ namespace quiz_statistics\event\observer; +use core\check\performance\debugging; use quiz_statistics\task\recalculate; /** @@ -25,6 +26,8 @@ * @copyright 2023 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @deprecated Since Moodle 4.4 MDL-80099. + * @todo Final deprecation in Moodle 4.8 MDL-80956. */ class attempt_submitted { /** @@ -35,8 +38,11 @@ class attempt_submitted { * * @param \mod_quiz\event\attempt_submitted $event * @return void + * @deprecated Since Moodle 4.4 MDL-80099 */ public static function process(\mod_quiz\event\attempt_submitted $event): void { + debugging('quiz_statistics\event\observer\attempt_submitted event observer has been deprecated in favour of ' . + 'the quiz_statistics\hook_callbacks::quiz_attempt_submitted_or_deleted hook callback.', DEBUG_DEVELOPER); $data = $event->get_data(); recalculate::queue_future_run($data['other']['quizid']); } diff --git a/mod/quiz/report/statistics/classes/quiz_structure_modified.php b/mod/quiz/report/statistics/classes/hook_callbacks.php similarity index 55% rename from mod/quiz/report/statistics/classes/quiz_structure_modified.php rename to mod/quiz/report/statistics/classes/hook_callbacks.php index 1cd8b52e8c928..da441d415f7ba 100644 --- a/mod/quiz/report/statistics/classes/quiz_structure_modified.php +++ b/mod/quiz/report/statistics/classes/hook_callbacks.php @@ -13,34 +13,34 @@ // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . - namespace quiz_statistics; use core\dml\sql_join; +use mod_quiz\hook\attempt_state_changed; +use mod_quiz\hook\structure_modified; +use mod_quiz\quiz_attempt; +use quiz_statistics\task\recalculate; /** - * Clear the statistics cache when the quiz structure is modified. + * Hook callbacks * * @package quiz_statistics * @copyright 2023 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class quiz_structure_modified { +class hook_callbacks { /** - * Clear the statistics cache. + * Clear the statistics cache for the quiz where the structure was modified. * - * @param int $quizid The quiz to clear the cache for. + * @param structure_modified $hook The structure_modified hook containing the new structure. * @return void */ - public static function callback(int $quizid): void { - global $DB, $CFG; + public static function quiz_structure_modified(structure_modified $hook) { + global $CFG; require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php'); require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php'); - $quiz = $DB->get_record('quiz', ['id' => $quizid]); - if (!$quiz) { - throw new \coding_exception('Could not find quiz with ID ' . $quizid . '.'); - } + $quiz = $hook->get_structure()->get_quiz(); $qubaids = quiz_statistics_qubaids_condition( $quiz->id, new sql_join(), @@ -50,4 +50,20 @@ public static function callback(int $quizid): void { $report = new \quiz_statistics_report(); $report->clear_cached_data($qubaids); } + + /** + * Queue a statistics recalculation when an attempt is submitted or deleting. + * + * @param attempt_state_changed $hook + * @return bool True if a task was queued. + */ + public static function quiz_attempt_submitted_or_deleted(attempt_state_changed $hook): bool { + $originalattempt = $hook->get_original_attempt(); + $updatedattempt = $hook->get_updated_attempt(); + if (is_null($updatedattempt) || $updatedattempt->state === quiz_attempt::FINISHED) { + // Only recalculate on deletion or submission. + return recalculate::queue_future_run($originalattempt->quiz); + } + return false; + } } diff --git a/mod/quiz/report/statistics/classes/quiz_attempt_deleted.php b/mod/quiz/report/statistics/classes/quiz_attempt_deleted.php index 8c6dccda6c7ef..011842e902544 100644 --- a/mod/quiz/report/statistics/classes/quiz_attempt_deleted.php +++ b/mod/quiz/report/statistics/classes/quiz_attempt_deleted.php @@ -25,6 +25,8 @@ * @copyright 2023 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @deprecated Since Moodle 4.4 MDL-80099. + * @todo Final deprecation in Moodle 4.8 MDL-80956. */ class quiz_attempt_deleted { /** @@ -32,8 +34,11 @@ class quiz_attempt_deleted { * * @param int $quizid The quiz the attempt belongs to. * @return void + * @deprecated Since Moodle 4.4 MDL-80099. */ public static function callback(int $quizid): void { + debugging('quiz_statistics\quiz_attempt_deleted callback class has been deprecated in favour of ' . + 'the quiz_statistics\hook_callbacks::quiz_attempt_submitted_or_deleted hook callback.', DEBUG_DEVELOPER); recalculate::queue_future_run($quizid); } } diff --git a/mod/quiz/report/statistics/db/hooks.php b/mod/quiz/report/statistics/db/hooks.php new file mode 100644 index 0000000000000..dae66ed389bef --- /dev/null +++ b/mod/quiz/report/statistics/db/hooks.php @@ -0,0 +1,37 @@ +. +/** + * Hook callback definitions for quiz_statistics + * + * @package quiz_statistics + * @copyright 2023 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Mark Johnson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +defined('MOODLE_INTERNAL') || die(); + +$callbacks = [ + [ + 'hook' => mod_quiz\hook\structure_modified::class, + 'callback' => quiz_statistics\hook_callbacks::class . '::quiz_structure_modified', + 'priority' => 500, + ], + [ + 'hook' => mod_quiz\hook\attempt_state_changed::class, + 'callback' => quiz_statistics\hook_callbacks::class . '::quiz_attempt_submitted_or_deleted', + 'priority' => 500, + ], +]; diff --git a/mod/quiz/report/statistics/tests/quiz_attempt_deleted_test.php b/mod/quiz/report/statistics/tests/quiz_attempt_deleted_test.php index 6b1ca956e3ab5..47fa441db1cf3 100644 --- a/mod/quiz/report/statistics/tests/quiz_attempt_deleted_test.php +++ b/mod/quiz/report/statistics/tests/quiz_attempt_deleted_test.php @@ -32,7 +32,7 @@ * @copyright 2023 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - * @covers \quiz_statistics\quiz_attempt_deleted + * @covers \quiz_statistics\hook_callbacks::quiz_attempt_submitted_or_deleted */ class quiz_attempt_deleted_test extends \advanced_testcase { use \quiz_question_helper_test_trait; diff --git a/mod/quiz/report/statistics/tests/event/observer/attempt_submitted_test.php b/mod/quiz/report/statistics/tests/quiz_attempt_submitted_test.php similarity index 96% rename from mod/quiz/report/statistics/tests/event/observer/attempt_submitted_test.php rename to mod/quiz/report/statistics/tests/quiz_attempt_submitted_test.php index 796f5f08edf0b..8e8592c8529db 100644 --- a/mod/quiz/report/statistics/tests/event/observer/attempt_submitted_test.php +++ b/mod/quiz/report/statistics/tests/quiz_attempt_submitted_test.php @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace quiz_statistics\event\observer; +namespace quiz_statistics; defined('MOODLE_INTERNAL') || die(); @@ -32,13 +32,12 @@ * @copyright 2023 onwards Catalyst IT EU {@link https://catalyst-eu.net} * @author Mark Johnson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - * @covers \quiz_statistics\event\observer\attempt_submitted + * @covers \quiz_statistics\hook_callbacks::quiz_attempt_submitted_or_deleted */ -class attempt_submitted_test extends \advanced_testcase { +class quiz_attempt_submitted_test extends \advanced_testcase { use \quiz_question_helper_test_trait; use statistics_test_trait; - /** * Attempting a quiz should queue the recalculation task for that quiz in 1 hour's time. * diff --git a/mod/quiz/report/statistics/version.php b/mod/quiz/report/statistics/version.php index 25a1f70a18df8..23228b92f769e 100644 --- a/mod/quiz/report/statistics/version.php +++ b/mod/quiz/report/statistics/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2023100900; +$plugin->version = 2023100901; $plugin->requires = 2023100400; $plugin->component = 'quiz_statistics'; diff --git a/mod/scorm/lang/en/deprecated.txt b/mod/scorm/lang/en/deprecated.txt index 016783b72d02f..161293a8cc27d 100644 --- a/mod/scorm/lang/en/deprecated.txt +++ b/mod/scorm/lang/en/deprecated.txt @@ -1,3 +1 @@ -info,mod_scorm -displayactivityname,mod_scorm -displayactivityname_help,mod_scorm +completionscorerequired_help,mod_scorm diff --git a/mod/scorm/lang/en/scorm.php b/mod/scorm/lang/en/scorm.php index d319063a3ce96..0f79d1fd0d741 100644 --- a/mod/scorm/lang/en/scorm.php +++ b/mod/scorm/lang/en/scorm.php @@ -456,10 +456,5 @@ $string['window'] = 'Window'; $string['youmustselectastatus'] = 'You must select a status to require'; -// Deprecated since Moodle 4.0. -$string['info'] = 'Info'; -$string['displayactivityname'] = 'Display activity name'; -$string['displayactivityname_help'] = 'Whether or not to display the activity name above the SCORM player.'; - // Deprecated since Moodle 4.3. $string['completionscorerequired_help'] = 'Enabling this setting will require a user to have at least the minimum score entered to be marked complete in this SCORM activity, as well as any other Activity Completion requirements.'; diff --git a/mod/scorm/mod_form.php b/mod/scorm/mod_form.php index 036544ff057c8..83336afb2df93 100644 --- a/mod/scorm/mod_form.php +++ b/mod/scorm/mod_form.php @@ -472,7 +472,10 @@ public function validation($data, $files) { // Validate 'Require minimum score' value. $completionscorerequiredel = 'completionscorerequired' . $this->get_suffix(); - if (array_key_exists($completionscorerequiredel, $data) && + $completionscoreenabledel = 'completionscoreenabled' . $this->get_suffix(); + if (array_key_exists($completionscoreenabledel, $data) && + $data[$completionscoreenabledel] && + array_key_exists($completionscorerequiredel, $data) && strlen($data[$completionscorerequiredel]) && $data[$completionscorerequiredel] <= 0 ) { diff --git a/mod/survey/lang/en/deprecated.txt b/mod/survey/lang/en/deprecated.txt deleted file mode 100644 index 8eaa5cc031fef..0000000000000 --- a/mod/survey/lang/en/deprecated.txt +++ /dev/null @@ -1,3 +0,0 @@ -clicktocontinue,mod_survey -viewsurveyresponses,mod_survey -allquestions,mod_survey diff --git a/mod/survey/lang/en/survey.php b/mod/survey/lang/en/survey.php index cef6a930863b3..640b9150e2af8 100644 --- a/mod/survey/lang/en/survey.php +++ b/mod/survey/lang/en/survey.php @@ -281,8 +281,3 @@ $string['time'] = 'Time'; $string['notyetanswered'] = 'Not yet answered'; $string['allquestionrequireanswer'] = 'All questions are required and must be answered.'; - -// Deprecated since Moodle 4.0. -$string['clicktocontinue'] = 'Click here to continue'; -$string['viewsurveyresponses'] = 'View {$a} survey responses'; -$string['allquestions'] = 'All questions in order, all students'; diff --git a/mod/wiki/pagelib.php b/mod/wiki/pagelib.php index d87eb6e17dd1c..11c1887d57a8b 100644 --- a/mod/wiki/pagelib.php +++ b/mod/wiki/pagelib.php @@ -2216,7 +2216,7 @@ private function print_version_view() { $pageversion->content = file_rewrite_pluginfile_urls($pageversion->content, 'pluginfile.php', $this->modcontext->id, 'mod_wiki', 'attachments', $this->subwiki->id); $parseroutput = wiki_parse_content($pageversion->contentformat, $pageversion->content, $options); - $content = $OUTPUT->container(format_text($parseroutput['parsed_text'], FORMAT_HTML, array('overflowdiv'=>true)), false, '', '', true); + $content = $OUTPUT->container(format_text($parseroutput['parsed_text'], FORMAT_HTML, ['overflowdiv' => true])); echo $OUTPUT->box($content, 'generalbox wiki_contentbox'); } else { diff --git a/pix/i/cloudupload.svg b/pix/i/cloudupload.svg new file mode 100644 index 0000000000000..02efa9eeedb4b --- /dev/null +++ b/pix/i/cloudupload.svg @@ -0,0 +1 @@ + diff --git a/report/security/lang/en/deprecated.txt b/report/security/lang/en/deprecated.txt deleted file mode 100644 index cc725afe39481..0000000000000 --- a/report/security/lang/en/deprecated.txt +++ /dev/null @@ -1,4 +0,0 @@ -check_mediafilterswf_details,report_security -check_mediafilterswf_error,report_security -check_mediafilterswf_name,report_security -check_mediafilterswf_ok,report_security diff --git a/report/security/lang/en/report_security.php b/report/security/lang/en/report_security.php index 43c92f87ef6b0..596e4c0c90dbf 100644 --- a/report/security/lang/en/report_security.php +++ b/report/security/lang/en/report_security.php @@ -144,9 +144,3 @@ $string['security:view'] = 'View security report'; $string['timewarning'] = 'Data processing may take a long time, please be patient...'; $string['privacy:metadata'] = 'The Security overview plugin does not store any personal data.'; - -// Deprecated since Moodle 4.0. -$string['check_mediafilterswf_details'] = '

Automatic swf embedding is very dangerous - any registered user may launch an XSS attack against other server users. Please disable it on production servers.

'; -$string['check_mediafilterswf_error'] = 'Flash media filter is enabled - this is very dangerous for the majority of servers.'; -$string['check_mediafilterswf_name'] = 'Enabled .swf media filter'; -$string['check_mediafilterswf_ok'] = 'Flash media filter is not enabled.'; diff --git a/reportbuilder/classes/form/report.php b/reportbuilder/classes/form/report.php index 2bf6c569db5c8..8d70cea038987 100644 --- a/reportbuilder/classes/form/report.php +++ b/reportbuilder/classes/form/report.php @@ -26,6 +26,7 @@ use core_reportbuilder\datasource; use core_reportbuilder\manager; use core_reportbuilder\local\helpers\report as reporthelper; +use core_tag_tag; defined('MOODLE_INTERNAL') || die(); @@ -114,6 +115,10 @@ public function definition() { $mform->addElement('advcheckbox', 'uniquerows', get_string('uniquerows', 'core_reportbuilder')); $mform->addHelpButton('uniquerows', 'uniquerows', 'core_reportbuilder'); + + $mform->addElement('tags', 'tags', get_string('tags'), [ + 'component' => 'core_reportbuilder', 'itemtype' => 'reportbuilder_report', + ]); } /** @@ -137,8 +142,9 @@ public function process_dynamic_submission() { * Load in existing data as form defaults */ public function set_data_for_dynamic_submission(): void { - if ($report = $this->get_custom_report()) { - $this->set_data($report->get_report_persistent()->to_record()); + if ($persistent = $this->get_custom_report()?->get_report_persistent()) { + $tags = core_tag_tag::get_item_tags_array('core_reportbuilder', 'reportbuilder_report', $persistent->get('id')); + $this->set_data(array_merge((array) $persistent->to_record(), ['tags' => $tags])); } } diff --git a/reportbuilder/classes/local/entities/user.php b/reportbuilder/classes/local/entities/user.php index 4893e9b57e25c..3dfa5fca68399 100644 --- a/reportbuilder/classes/local/entities/user.php +++ b/reportbuilder/classes/local/entities/user.php @@ -445,6 +445,7 @@ protected function get_user_fields(): array { 'suspended' => new lang_string('suspended'), 'confirmed' => new lang_string('confirmed', 'admin'), 'username' => new lang_string('username'), + 'auth' => new lang_string('authentication', 'moodle'), 'moodlenetprofile' => new lang_string('moodlenetprofile', 'user'), 'timecreated' => new lang_string('timecreated', 'core_reportbuilder'), 'timemodified' => new lang_string('timemodified', 'core_reportbuilder'), @@ -550,32 +551,6 @@ protected function get_all_filters(): array { )) ->add_joins($this->get_joins()); - // Authentication method filter. - $filters[] = (new filter( - select::class, - 'auth', - new lang_string('authentication', 'moodle'), - $this->get_entity_name(), - "{$tablealias}.auth" - )) - ->add_joins($this->get_joins()) - ->set_options_callback(static function(): array { - $plugins = core_component::get_plugin_list('auth'); - $enabled = get_string('pluginenabled', 'core_plugin'); - $disabled = get_string('plugindisabled', 'core_plugin'); - $authoptions = [$enabled => [], $disabled => []]; - - foreach ($plugins as $pluginname => $unused) { - $plugin = get_auth_plugin($pluginname); - if (is_enabled_auth($pluginname)) { - $authoptions[$enabled][$pluginname] = $plugin->get_title(); - } else { - $authoptions[$disabled][$pluginname] = $plugin->get_title(); - } - } - return $authoptions; - }); - return $filters; } @@ -598,6 +573,20 @@ protected function get_options_for(string $fieldname): ?array { return $cached[$fieldname]; } + /** + * List of options for the field auth + * + * @return string[] + */ + public static function get_options_for_auth(): array { + $authlist = array_keys(core_component::get_plugin_list('auth')); + + return array_map( + fn(string $auth) => get_auth_plugin($auth)->get_title(), + array_combine($authlist, $authlist), + ); + } + /** * List of options for the field country. * @@ -610,7 +599,7 @@ public static function get_options_for_country(): array { /** * List of options for the field theme. * - * @return array + * @return string[] */ public static function get_options_for_theme(): array { return array_map( diff --git a/reportbuilder/classes/local/filters/tags.php b/reportbuilder/classes/local/filters/tags.php index a0730d24c2c63..b04de1d305ec5 100644 --- a/reportbuilder/classes/local/filters/tags.php +++ b/reportbuilder/classes/local/filters/tags.php @@ -18,17 +18,24 @@ namespace core_reportbuilder\local\filters; -use coding_exception; use core_tag_tag; use lang_string; use MoodleQuickForm; use stdClass; use core_reportbuilder\local\helpers\database; +use core_reportbuilder\local\report\filter; /** * Class containing logic for the tags filter * - * The field SQL should be the field containing the ID of the {tag} table + * The filter can operate in two modes: + * + * 1. Filtering of tags directly from the {tag} table, in which case the field SQL expression should return the ID of that table; + * 2. Filtering of component tags, in which case the field SQL expression should return the ID of the component table that would + * join to the {tag_instance} itemid field + * + * If filtering component tags then the following must be passed to the {@see filter::get_options} method when using this filter + * in a report: ['component' => 'mycomponent', 'itemtype' => 'myitem'] * * @package core_reportbuilder * @copyright 2022 Paul Holden @@ -80,14 +87,26 @@ public function setup_form(MoodleQuickForm $mform): void { $mform->addElement('select', "{$this->name}_operator", $operatorlabel, $this->get_operators()) ->setHiddenLabel(true); - $sql = 'SELECT DISTINCT t.id, t.name, t.rawname + // If we're filtering component tags, show only those related to the component itself. + $options = (array) $this->filter->get_options(); + if (array_key_exists('component', $options) && array_key_exists('itemtype', $options)) { + $taginstancejoin = 'JOIN {tag_instance} ti ON ti.tagid = t.id + WHERE ti.component = :component AND ti.itemtype = :itemtype'; + $params = array_intersect_key($options, array_flip(['component', 'itemtype'])); + } else { + $taginstancejoin = ''; + $params = []; + } + + $sql = "SELECT DISTINCT t.id, t.name, t.rawname FROM {tag} t - ORDER BY t.name'; + {$taginstancejoin} + ORDER BY t.name"; // Transform tag records into appropriate display name, for selection in the autocomplete element. $tags = array_map(static function(stdClass $record): string { return core_tag_tag::make_display_name($record); - }, $DB->get_records_sql($sql)); + }, $DB->get_records_sql($sql, $params)); $valuelabel = get_string('filterfieldvalue', 'core_reportbuilder', $this->get_header()); $mform->addElement('autocomplete', "{$this->name}_value", $valuelabel, $tags, ['multiple' => true]) @@ -110,26 +129,66 @@ public function get_sql_filter(array $values): array { $operator = (int) ($values["{$this->name}_operator"] ?? self::ANY_VALUE); $tags = (array) ($values["{$this->name}_value"] ?? []); - if ($operator === self::NOT_EMPTY) { - $select = "{$fieldsql} IS NOT NULL"; - } else if ($operator === self::EMPTY) { - $select = "{$fieldsql} IS NULL"; - } else if ($operator === self::EQUAL_TO && !empty($tags)) { - [$tagselect, $tagselectparams] = $DB->get_in_or_equal($tags, SQL_PARAMS_NAMED, - database::generate_param_name('_')); - - $select = "{$fieldsql} {$tagselect}"; - $params = array_merge($params, $tagselectparams); - } else if ($operator === self::NOT_EQUAL_TO && !empty($tags)) { - [$tagselect, $tagselectparams] = $DB->get_in_or_equal($tags, SQL_PARAMS_NAMED, - database::generate_param_name('_'), false); - - // We should also return those elements that aren't tagged at all. - $select = "COALESCE({$fieldsql}, 0) {$tagselect}"; - $params = array_merge($params, $tagselectparams); + // If we're filtering component tags, we need to perform [not] exists queries to ensure no row duplication occurs. + $options = (array) $this->filter->get_options(); + if (array_key_exists('component', $options) && array_key_exists('itemtype', $options)) { + [$paramcomponent, $paramitemtype] = database::generate_param_names(2); + + $componenttagselect = <<get_in_or_equal($tags, SQL_PARAMS_NAMED, + database::generate_param_name('_')); + + $select = "EXISTS ({$componenttagselect} AND t.id {$tagselect})"; + $params = array_merge($params, $tagselectparams); + } else if ($operator === self::NOT_EQUAL_TO && !empty($tags)) { + [$tagselect, $tagselectparams] = $DB->get_in_or_equal($tags, SQL_PARAMS_NAMED, + database::generate_param_name('_')); + + // We should also return those elements that aren't tagged at all. + $select = "NOT EXISTS ({$componenttagselect} AND t.id {$tagselect})"; + $params = array_merge($params, $tagselectparams); + } else { + // Invalid/inactive (any value) filter.. + return ['', []]; + } } else { - // Invalid/inactive (any value) filter.. - return ['', []]; + + // We're filtering directly from the tag table. + if ($operator === self::NOT_EMPTY) { + $select = "{$fieldsql} IS NOT NULL"; + } else if ($operator === self::EMPTY) { + $select = "{$fieldsql} IS NULL"; + } else if ($operator === self::EQUAL_TO && !empty($tags)) { + [$tagselect, $tagselectparams] = $DB->get_in_or_equal($tags, SQL_PARAMS_NAMED, + database::generate_param_name('_')); + + $select = "{$fieldsql} {$tagselect}"; + $params = array_merge($params, $tagselectparams); + } else if ($operator === self::NOT_EQUAL_TO && !empty($tags)) { + [$tagselect, $tagselectparams] = $DB->get_in_or_equal($tags, SQL_PARAMS_NAMED, + database::generate_param_name('_'), false); + + // We should also return those elements that aren't tagged at all. + $select = "COALESCE({$fieldsql}, 0) {$tagselect}"; + $params = array_merge($params, $tagselectparams); + } else { + // Invalid/inactive (any value) filter.. + return ['', []]; + } } return [$select, $params]; diff --git a/reportbuilder/classes/local/helpers/report.php b/reportbuilder/classes/local/helpers/report.php index 4e69f704861c1..4471e0b11636f 100644 --- a/reportbuilder/classes/local/helpers/report.php +++ b/reportbuilder/classes/local/helpers/report.php @@ -26,6 +26,7 @@ use core_reportbuilder\local\models\column; use core_reportbuilder\local\models\filter; use core_reportbuilder\local\models\report as report_model; +use core_tag_tag; /** * Helper class for manipulating custom reports and their elements (columns, filters, conditions, etc) @@ -48,19 +49,24 @@ public static function create_report(stdClass $data, bool $default = true): repo $data->name = trim($data->name); $data->type = datasource::TYPE_CUSTOM_REPORT; - $reportpersistent = manager::create_report_persistent($data); + // Create report persistent. + $report = manager::create_report_persistent($data); // Add datasource default columns, filters and conditions to the report. if ($default) { - $source = $reportpersistent->get('source'); + $source = $report->get('source'); /** @var datasource $datasource */ - $datasource = new $source($reportpersistent, []); + $datasource = new $source($report); $datasource->add_default_columns(); $datasource->add_default_filters(); $datasource->add_default_conditions(); } - return $reportpersistent; + // Report tags. + core_tag_tag::set_item_tags('core_reportbuilder', 'reportbuilder_report', $report->get('id'), + $report->get_context(), $data->tags); + + return $report; } /** @@ -80,6 +86,10 @@ public static function update_report(stdClass $data): report_model { 'uniquerows' => $data->uniquerows, ])->update(); + // Report tags. + core_tag_tag::set_item_tags('core_reportbuilder', 'reportbuilder_report', $report->get('id'), + $report->get_context(), $data->tags); + return $report; } @@ -96,6 +106,9 @@ public static function delete_report(int $reportid): bool { throw new invalid_parameter_exception('Invalid report'); } + // Report tags. + core_tag_tag::remove_all_item_tags('core_reportbuilder', 'reportbuilder_report', $report->get('id')); + return $report->delete(); } diff --git a/reportbuilder/classes/local/systemreports/reports_list.php b/reportbuilder/classes/local/systemreports/reports_list.php index b588a89943da6..ba726e7180166 100644 --- a/reportbuilder/classes/local/systemreports/reports_list.php +++ b/reportbuilder/classes/local/systemreports/reports_list.php @@ -28,6 +28,7 @@ use core_reportbuilder\system_report; use core_reportbuilder\local\entities\user; use core_reportbuilder\local\filters\date; +use core_reportbuilder\local\filters\tags; use core_reportbuilder\local\filters\text; use core_reportbuilder\local\filters\select; use core_reportbuilder\local\helpers\audience; @@ -38,6 +39,7 @@ use core_reportbuilder\output\report_name_editable; use core_reportbuilder\local\models\report; use core_reportbuilder\permission; +use core_tag_tag; /** * Reports list @@ -113,6 +115,8 @@ public function get_row_class(stdClass $row): string { * Add columns to report */ protected function add_columns(): void { + global $DB; + $tablealias = $this->get_main_table_alias(); // Report name column. @@ -158,6 +162,37 @@ protected function add_columns(): void { }) ); + // Tags column. TODO: Reuse tag entity column when MDL-76392 is integrated. + $tagfieldconcatsql = $DB->sql_group_concat( + field: $DB->sql_concat_join("'|'", ['t.name', 't.rawname']), + sort: 't.name', + ); + $this->add_column((new column( + 'tags', + new lang_string('tags'), + $this->get_report_entity_name(), + )) + ->set_type(column::TYPE_TEXT) + ->add_field("( + SELECT {$tagfieldconcatsql} + FROM {tag_instance} ti + JOIN {tag} t ON t.id = ti.tagid + WHERE ti.component = 'core_reportbuilder' AND ti.itemtype = 'reportbuilder_report' + AND ti.itemid = {$tablealias}.id + )", 'tags') + ->set_is_sortable(true) + ->set_is_available(core_tag_tag::is_enabled('core_reportbuilder', 'reportbuilder_report')) + ->add_callback(static function(?string $tags): string { + return implode(', ', array_map(static function(string $tag): string { + [$name, $rawname] = explode('|', $tag); + return core_tag_tag::make_display_name((object) [ + 'name' => $name, + 'rawname' => $rawname, + ]); + }, preg_split('/, /', (string) $tags, -1, PREG_SPLIT_NO_EMPTY))); + }) + ); + // Time created column. $this->add_column((new column( 'timecreated', @@ -218,6 +253,21 @@ protected function add_filters(): void { }) ); + // Tags filter. + $this->add_filter((new filter( + tags::class, + 'tags', + new lang_string('tags'), + $this->get_report_entity_name(), + "{$tablealias}.id", + )) + ->set_options([ + 'component' => 'core_reportbuilder', + 'itemtype' => 'reportbuilder_report', + ]) + ->set_is_available(core_tag_tag::is_enabled('core_reportbuilder', 'reportbuilder_report')) + ); + // Time created filter. $this->add_filter((new filter( date::class, diff --git a/reportbuilder/lib.php b/reportbuilder/lib.php index 4a0f575c1b42b..08c888b019570 100644 --- a/reportbuilder/lib.php +++ b/reportbuilder/lib.php @@ -27,6 +27,9 @@ use core\output\inplace_editable; use core_reportbuilder\form\audience; use core_reportbuilder\form\filter; +use core_reportbuilder\local\helpers\audience as audience_helper; +use core_reportbuilder\local\models\report; +use core_tag\output\{tagfeed, tagindex}; /** * Return the filters form fragment @@ -74,6 +77,60 @@ function core_reportbuilder_output_fragment_audience_form(array $params): string return $renderer->render_from_template('core_reportbuilder/local/audience/form', $context); } +/** + * Callback to return tagged reports + * + * @param core_tag_tag $tag + * @param bool $exclusivemode + * @param int|null $fromcontextid + * @param int|null $contextid + * @param bool $recurse + * @param int $page + * @return tagindex + */ +function core_reportbuilder_get_tagged_reports( + core_tag_tag $tag, + bool $exclusivemode = false, + ?int $fromcontextid = 0, + ?int $contextid = 0, + bool $recurse = true, + int $page = 0, +): tagindex { + global $OUTPUT; + + // Limit the returned list to those reports the current user can access. + [$where, $params] = audience_helper::user_reports_list_access_sql('it'); + + $tagcount = $tag->count_tagged_items('core_reportbuilder', 'reportbuilder_report', $where, $params); + $perpage = $exclusivemode ? 20 : 5; + $pagecount = ceil($tagcount / $perpage); + + $content = ''; + + if ($tagcount > 0) { + $tagfeed = new tagfeed(); + + $pixicon = new pix_icon('i/report', new lang_string('customreport', 'core_reportbuilder')); + + $reports = $tag->get_tagged_items('core_reportbuilder', 'reportbuilder_report', $page * $perpage, $perpage, + $where, $params); + foreach ($reports as $report) { + $tagfeed->add( + $OUTPUT->render($pixicon), + html_writer::link( + new moodle_url('/reportbuilder/view.php', ['id' => $report->id]), + (new report(0, $report))->get_formatted_name(), + ), + ); + } + + $content = $OUTPUT->render_from_template('core_tag/tagfeed', $tagfeed->export_for_template($OUTPUT)); + } + + return new tagindex($tag, 'core_reportbuilder', 'reportbuilder_report', $content, $exclusivemode, $fromcontextid, + $contextid, $recurse, $page, $pagecount); +} + /** * Plugin inplace editable implementation * diff --git a/reportbuilder/tests/behat/customreports.feature b/reportbuilder/tests/behat/customreports.feature index d69bdfe7edc16..3cccb734b08fd 100644 --- a/reportbuilder/tests/behat/customreports.feature +++ b/reportbuilder/tests/behat/customreports.feature @@ -92,8 +92,12 @@ Feature: Manage custom reports And I set the following fields in the "New report" "dialogue" to these values: | Name | Manager report | | Report source | Users | + | Tags | Cat, Dog | And I click on "Save" "button" in the "New report" "dialogue" And I click on "Close 'Manager report' editor" "button" + And the following should exist in the "Reports list" table: + | Name | Tags | Report source | + | Manager report | Cat, Dog | Users | # Manager can edit their own report, but not those of other users. And I set the field "Edit report name" in the "Manager report" "table_row" to "Manager report (renamed)" Then the "Edit report content" item should exist in the "Actions" action menu of the "Manager report (renamed)" "table_row" @@ -140,16 +144,18 @@ Feature: Manage custom reports When I press "Edit report details" action in the "My report" report row And I set the following fields in the "Edit report details" "dialogue" to these values: | Name | My renamed report | + | Tags | Cat, Dog | And I click on "Save" "button" in the "Edit report details" "dialogue" Then I should see "Report updated" And the following should exist in the "Reports list" table: - | Name | Report source | - | My renamed report | Users | + | Name | Tags | Report source | + | My renamed report | Cat, Dog | Users | Scenario Outline: Filter custom reports Given the following "core_reportbuilder > Reports" exist: - | name | source | - | My users | core_user\reportbuilder\datasource\users | + | name | source | tags | + | My users | core_user\reportbuilder\datasource\users | Cat, Dog | + | My courses | core_course\reportbuilder\datasource\courses | | And I log in as "admin" When I navigate to "Reports > Report builder > Custom reports" in site administration And I click on "Filters" "button" @@ -158,11 +164,30 @@ Feature: Manage custom reports | value | | And I click on "Apply" "button" in the "[data-region='report-filters']" "css_element" Then I should see "Filters applied" - And I should see "My users" in the "Reports list" "table" + And the following should exist in the "Reports list" table: + | Name | Tags | Report source | + | My users | Cat, Dog | Users | + And I should not see "My courses" in the "Reports list" "table" Examples: | filter | value | | Name | My users | | Report source | Users | + | Tags | Cat | + + Scenario: Custom report tags are not displayed if tagging is disabled + Given the following config values are set as admin: + | usetags | 0 | + And the following "core_reportbuilder > Reports" exist: + | name | source | + | My report | core_user\reportbuilder\datasource\users | + And I log in as "admin" + When I navigate to "Reports > Report builder > Custom reports" in site administration + Then the following should exist in the "Reports list" table: + | Name | Report source | + | My report | Users | + And "Tags" "link" should not exist in the "Reports list" "table" + And I click on "Filters" "button" + And "Tags" "core_reportbuilder > Filter" should not exist Scenario: Delete custom report Given the following "core_reportbuilder > Reports" exist: diff --git a/reportbuilder/tests/datasource_test.php b/reportbuilder/tests/datasource_test.php index 7b0bae7079020..d1168f519baf3 100644 --- a/reportbuilder/tests/datasource_test.php +++ b/reportbuilder/tests/datasource_test.php @@ -55,7 +55,7 @@ public static function add_columns_from_entity_provider(): array { 'All column' => [ [], [], - 30, + 31, ], 'Include columns (picture, fullname, fullnamewithlink, fullnamewithpicture, fullnamewithpicturelink)' => [ ['picture', 'fullname*'], @@ -65,7 +65,7 @@ public static function add_columns_from_entity_provider(): array { 'Exclude columns (picture, fullname, fullnamewithlink, fullnamewithpicture, fullnamewithpicturelink)' => [ [], ['picture', 'fullname*'], - 25, + 26, ], ]; } diff --git a/reportbuilder/tests/external/system_report_data_exporter_test.php b/reportbuilder/tests/external/system_report_data_exporter_test.php index 881a97bb213e5..637615c49f122 100644 --- a/reportbuilder/tests/external/system_report_data_exporter_test.php +++ b/reportbuilder/tests/external/system_report_data_exporter_test.php @@ -50,20 +50,28 @@ public function test_export(): void { // Two reports, created one second apart to ensure consistent ordering by time created. $generator->create_report(['name' => 'My first report', 'source' => users::class]); $this->waitForSecond(); - $generator->create_report(['name' => 'My second report', 'source' => users::class]); + $generator->create_report(['name' => 'My second report', 'source' => users::class, 'tags' => ['cat', 'dog']]); $reportinstance = system_report_factory::create(reports_list::class, system::instance()); $exporter = new system_report_data_exporter(null, ['report' => $reportinstance, 'page' => 0, 'perpage' => 1]); $export = $exporter->export($PAGE->get_renderer('core_reportbuilder')); - $this->assertEquals(['Name', 'Report source', 'Time created', 'Time modified', 'Modified by'], $export->headers); + $this->assertEquals([ + 'Name', + 'Report source', + 'Tags', + 'Time created', + 'Time modified', + 'Modified by', + ], $export->headers); $this->assertCount(1, $export->rows); - [$name, $source, $timecreated, $timemodified, $modifiedby] = $export->rows[0]['columns']; + [$name, $source, $tags, $timecreated, $timemodified, $modifiedby] = $export->rows[0]['columns']; $this->assertStringContainsString('My second report', $name); $this->assertEquals(users::get_name(), $source); + $this->assertEquals('cat, dog', $tags); $this->assertNotEmpty($timecreated); $this->assertNotEmpty($timemodified); $this->assertEquals('Admin User', $modifiedby); diff --git a/reportbuilder/tests/external/systemreports/retrieve_test.php b/reportbuilder/tests/external/systemreports/retrieve_test.php index 0b0d151d2a3d7..0a410ad6bf0d3 100644 --- a/reportbuilder/tests/external/systemreports/retrieve_test.php +++ b/reportbuilder/tests/external/systemreports/retrieve_test.php @@ -54,20 +54,28 @@ public function test_execute(): void { // Two reports, created one second apart to ensure consistent ordering by time created. $generator->create_report(['name' => 'My first report', 'source' => users::class]); $this->waitForSecond(); - $generator->create_report(['name' => 'My second report', 'source' => users::class]); + $generator->create_report(['name' => 'My second report', 'source' => users::class, 'tags' => ['cat', 'dog']]); // Retrieve paged results. $result = retrieve::execute(reports_list::class, ['contextid' => system::instance()->id], '', '', 0, [], 0, 1); $result = external_api::clean_returnvalue(retrieve::execute_returns(), $result); $this->assertArrayHasKey('data', $result); - $this->assertEquals(['Name', 'Report source', 'Time created', 'Time modified', 'Modified by'], $result['data']['headers']); + $this->assertEquals([ + 'Name', + 'Report source', + 'Tags', + 'Time created', + 'Time modified', + 'Modified by', + ], $result['data']['headers']); $this->assertCount(1, $result['data']['rows']); - [$name, $source, $timecreated, $timemodified, $modifiedby] = $result['data']['rows'][0]['columns']; + [$name, $source, $tags, $timecreated, $timemodified, $modifiedby] = $result['data']['rows'][0]['columns']; $this->assertStringContainsString('My second report', $name); $this->assertEquals(users::get_name(), $source); + $this->assertEquals('cat, dog', $tags); $this->assertNotEmpty($timecreated); $this->assertNotEmpty($timemodified); $this->assertEquals('Admin User', $modifiedby); diff --git a/reportbuilder/tests/generator/lib.php b/reportbuilder/tests/generator/lib.php index dc7cbdf049c0c..e83d6e2d1c4ab 100644 --- a/reportbuilder/tests/generator/lib.php +++ b/reportbuilder/tests/generator/lib.php @@ -51,6 +51,12 @@ public function create_report($record): report { throw new coding_exception('Record must contain \'source\' property'); } + // Report tags. + $tags = $record['tags'] ?? ''; + if (!is_array($tags)) { + $record['tags'] = preg_split('/\s*,\s*/', $tags, -1, PREG_SPLIT_NO_EMPTY); + } + // Include default setup unless specifically disabled in passed record. $default = (bool) ($record['default'] ?? true); diff --git a/reportbuilder/tests/generator_test.php b/reportbuilder/tests/generator_test.php index f867a6f41b626..1c9a13f603320 100644 --- a/reportbuilder/tests/generator_test.php +++ b/reportbuilder/tests/generator_test.php @@ -21,6 +21,7 @@ use advanced_testcase; use core_reportbuilder_generator; use core_reportbuilder\local\models\{audience, column, filter, report, schedule}; +use core_tag_tag; use core_user\reportbuilder\datasource\users; /** @@ -44,9 +45,11 @@ public function test_create_report(): void { /** @var core_reportbuilder_generator $generator */ $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); - $report = $generator->create_report(['name' => 'My report', 'source' => users::class]); + $report = $generator->create_report(['name' => 'My report', 'source' => users::class, 'tags' => ['cat', 'dog']]); $this->assertTrue(report::record_exists($report->get('id'))); + $this->assertEqualsCanonicalizing(['cat', 'dog'], + core_tag_tag::get_item_tags_array('core_reportbuilder', 'reportbuilder_report', $report->get('id'))); } /** diff --git a/reportbuilder/tests/lib_test.php b/reportbuilder/tests/lib_test.php new file mode 100644 index 0000000000000..1a01a14115733 --- /dev/null +++ b/reportbuilder/tests/lib_test.php @@ -0,0 +1,78 @@ +. + +declare(strict_types=1); + +namespace core_reportbuilder; + +use advanced_testcase; +use core_reportbuilder_generator; +use core_tag_tag; +use core_user\reportbuilder\datasource\users; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once("{$CFG->dirroot}/reportbuilder/lib.php"); + +/** + * Unit tests for the component callbacks + * + * @package core_reportbuilder + * @copyright 2023 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class lib_test extends advanced_testcase { + + /** + * Test getting tagged reports + * + * @covers ::core_reportbuilder_get_tagged_reports + */ + public function test_core_reportbuilder_get_tagged_reports(): void { + $this->resetAfterTest(); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + + // Create three tagged reports. + $reportone = $generator->create_report(['name' => 'Report 1', 'source' => users::class, 'tags' => ['cat']]); + $reporttwo = $generator->create_report(['name' => 'Report 2', 'source' => users::class, 'tags' => ['dog']]); + $reportthree = $generator->create_report(['name' => 'Report 3', 'source' => users::class, 'tags' => ['cat']]); + + // Add all users audience to report one and two. + $generator->create_audience(['reportid' => $reportone->get('id'), 'configdata' => []]); + $generator->create_audience(['reportid' => $reporttwo->get('id'), 'configdata' => []]); + + $tag = core_tag_tag::get_by_name(0, 'cat'); + + // Current user can only access report one with "cat" tag. + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + $tagindex = core_reportbuilder_get_tagged_reports($tag); + $this->assertStringContainsString($reportone->get_formatted_name(), $tagindex->content); + $this->assertStringNotContainsString($reporttwo->get_formatted_name(), $tagindex->content); + $this->assertStringNotContainsString($reportthree->get_formatted_name(), $tagindex->content); + + // Admin can access both reports with "cat" tag. + $this->setAdminUser(); + $tagindex = core_reportbuilder_get_tagged_reports($tag); + $this->assertStringContainsString($reportone->get_formatted_name(), $tagindex->content); + $this->assertStringNotContainsString($reporttwo->get_formatted_name(), $tagindex->content); + $this->assertStringContainsString($reportthree->get_formatted_name(), $tagindex->content); + } +} diff --git a/reportbuilder/tests/local/filters/tags_test.php b/reportbuilder/tests/local/filters/tags_test.php index a3148673c087d..9d42c591eb8da 100644 --- a/reportbuilder/tests/local/filters/tags_test.php +++ b/reportbuilder/tests/local/filters/tags_test.php @@ -20,7 +20,9 @@ use advanced_testcase; use lang_string; +use core_reportbuilder_generator; use core_reportbuilder\local\report\filter; +use core_user\reportbuilder\datasource\users; /** * Unit tests for tags report filter @@ -38,7 +40,7 @@ class tags_test extends advanced_testcase { * * @return array[] */ - public function get_sql_filter_provider(): array { + public static function get_sql_filter_provider(): array { return [ 'Any value' => [tags::ANY_VALUE, null, ['course01', 'course01', 'course02', 'course03']], 'Not empty' => [tags::NOT_EMPTY, null, ['course01', 'course01', 'course02']], @@ -102,4 +104,78 @@ public function test_get_sql_filter(int $operator, ?string $tagname, array $expe $courses = $DB->get_fieldset_sql($sql, $params); $this->assertEqualsCanonicalizing($expectedcoursenames, $courses); } + + /** + * Data provider for {@see test_get_sql_filter_component} + * + * @return array[] + */ + public static function get_sql_filter_component_provider(): array { + return [ + 'Any value' => [tags::ANY_VALUE, null, ['report01', 'report02']], + 'Not empty' => [tags::NOT_EMPTY, null, ['report01']], + 'Empty' => [tags::EMPTY, null, ['report02']], + 'Equal to unselected' => [tags::EQUAL_TO, null, ['report01', 'report02']], + 'Equal to selected tag' => [tags::EQUAL_TO, 'fish', ['report01']], + 'Equal to selected tag (different component)' => [tags::EQUAL_TO, 'cat', []], + 'Not equal to unselected' => [tags::NOT_EQUAL_TO, null, ['report01', 'report02']], + 'Not equal to selected tag' => [tags::NOT_EQUAL_TO, 'fish', ['report02']], + 'Not Equal to selected tag (different component)' => [tags::NOT_EQUAL_TO, 'cat', ['report01', 'report02']], + ]; + } + + /** + * Test getting filter SQL + * + * @param int $operator + * @param string|null $tagname + * @param array $expectedreportnames + * + * @dataProvider get_sql_filter_component_provider + */ + public function test_get_sql_filter_component(int $operator, ?string $tagname, array $expectedreportnames): void { + global $DB; + + $this->resetAfterTest(); + + // Create a course with tags, we shouldn't ever get this data back when specifying another component. + $this->getDataGenerator()->create_course(['tags' => ['cat', 'dog']]); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $generator->create_report(['name' => 'report01', 'source' => users::class, 'tags' => ['fish']]); + $generator->create_report(['name' => 'report02', 'source' => users::class]); + + $filter = (new filter( + tags::class, + 'tags', + new lang_string('tags'), + 'testentity', + 'r.id' + ))->set_options([ + 'component' => 'core_reportbuilder', + 'itemtype' => 'reportbuilder_report', + ]); + + // Create instance of our filter, passing ID of the tag if specified. + if ($tagname !== null) { + $tagid = $DB->get_field('tag', 'id', ['name' => $tagname], MUST_EXIST); + $value = [$tagid]; + } else { + $value = null; + } + + [$select, $params] = tags::create($filter)->get_sql_filter([ + $filter->get_unique_identifier() . '_operator' => $operator, + $filter->get_unique_identifier() . '_value' => $value, + ]); + + $sql = 'SELECT r.name FROM {reportbuilder_report} r'; + if ($select) { + $sql .= " WHERE {$select}"; + } + + $reports = $DB->get_fieldset_sql($sql, $params); + $this->assertEqualsCanonicalizing($expectedreportnames, $reports); + } } diff --git a/reportbuilder/tests/local/helpers/report_test.php b/reportbuilder/tests/local/helpers/report_test.php index 1eceae0f5ce40..dcb198f935cfb 100644 --- a/reportbuilder/tests/local/helpers/report_test.php +++ b/reportbuilder/tests/local/helpers/report_test.php @@ -21,8 +21,10 @@ use advanced_testcase; use core_reportbuilder_generator; use invalid_parameter_exception; +use core_reportbuilder\datasource; use core_reportbuilder\local\models\column; use core_reportbuilder\local\models\filter; +use core_tag_tag; use core_user\reportbuilder\datasource\users; /** @@ -35,6 +37,49 @@ */ class report_test extends advanced_testcase { + /** + * Test creation report + */ + public function test_create_report(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + $report = report::create_report((object) [ + 'name' => 'My report', + 'source' => users::class, + 'tags' => ['cat', 'dog'], + ]); + + $this->assertEquals('My report', $report->get('name')); + $this->assertEquals(datasource::TYPE_CUSTOM_REPORT, $report->get('type')); + $this->assertEqualsCanonicalizing(['cat', 'dog'], + core_tag_tag::get_item_tags_array('core_reportbuilder', 'reportbuilder_report', $report->get('id'))); + } + + /** + * Test updating report + */ + public function test_update_report(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + /** @var core_reportbuilder_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); + $report = $generator->create_report(['name' => 'My report', 'source' => users::class, 'uniquerows' => 0]); + + $reportupdated = report::update_report((object) [ + 'id' => $report->get('id'), + 'name' => 'My renamed report', + 'uniquerows' => 1, + 'tags' => ['cat', 'dog'], + ]); + + $this->assertEquals('My renamed report', $reportupdated->get('name')); + $this->assertTrue($reportupdated->get('uniquerows')); + $this->assertEqualsCanonicalizing(['cat', 'dog'], + core_tag_tag::get_item_tags_array('core_reportbuilder', 'reportbuilder_report', $reportupdated->get('id'))); + } + /** * Test deleting report */ @@ -46,7 +91,8 @@ public function test_delete_report(): void { $generator = $this->getDataGenerator()->get_plugin_generator('core_reportbuilder'); // Create Report1 and add some elements. - $report1 = $generator->create_report(['name' => 'My report 1', 'source' => users::class, 'default' => false]); + $report1 = $generator->create_report(['name' => 'My report 1', 'source' => users::class, 'default' => false, + 'tags' => ['cat', 'dog']]); $column1 = $generator->create_column(['reportid' => $report1->get('id'), 'uniqueidentifier' => 'user:email']); $filter1 = $generator->create_filter(['reportid' => $report1->get('id'), 'uniqueidentifier' => 'user:email']); $condition1 = $generator->create_condition(['reportid' => $report1->get('id'), 'uniqueidentifier' => 'user:email']); @@ -58,13 +104,15 @@ public function test_delete_report(): void { $condition2 = $generator->create_condition(['reportid' => $report2->get('id'), 'uniqueidentifier' => 'user:email']); // Delete Report1. - report::delete_report($report1->get('id')); + $result = report::delete_report($report1->get('id')); + $this->assertTrue($result); // Make sure Report1, and all it's elements are deleted. $this->assertFalse($report1::record_exists($report1->get('id'))); $this->assertFalse($column1::record_exists($column1->get('id'))); $this->assertFalse($filter1::record_exists($filter1->get('id'))); $this->assertFalse($condition1::record_exists($condition1->get('id'))); + $this->assertEmpty(core_tag_tag::get_item_tags_array('core_reportbuilder', 'reportbuilder_report', $report1->get('id'))); // Make sure Report2, and all it's elements still exist. $this->assertTrue($report2::record_exists($report2->get('id'))); diff --git a/reportbuilder/upgrade.txt b/reportbuilder/upgrade.txt index c4ab09ab63cfb..08c38f8fb3990 100644 --- a/reportbuilder/upgrade.txt +++ b/reportbuilder/upgrade.txt @@ -15,6 +15,10 @@ Information provided here is intended especially for developers. * The base datasource `add_all_from_entity` method accepts additional parameters to limit which columns, filters and conditions are added. The `add_[columns|filters|conditions]_from_entity` class methods also now support wildcard matching in both `$include` and `$exclude` parameters +* Custom reports now implement the tag API, with options for specifying in the `report::[create|update]_report` helper methods + as well as in the `create_report` test generator method +* The `tags` filter has been improved to also allow for filtering by component/itemtype core_tag definition - this is more + suited for system reports * New report filter types: - `cohort` for reports containing cohort data - `courserole` for reports showing course enrolments diff --git a/repository/dropbox/lang/en/deprecated.txt b/repository/dropbox/lang/en/deprecated.txt deleted file mode 100644 index 2808d9f8722da..0000000000000 --- a/repository/dropbox/lang/en/deprecated.txt +++ /dev/null @@ -1,3 +0,0 @@ -apikey,repository_dropbox -secret,repository_dropbox -instruction,repository_dropbox diff --git a/repository/dropbox/lang/en/repository_dropbox.php b/repository/dropbox/lang/en/repository_dropbox.php index 79b185fa87c71..33399017436b3 100644 --- a/repository/dropbox/lang/en/repository_dropbox.php +++ b/repository/dropbox/lang/en/repository_dropbox.php @@ -37,8 +37,3 @@ $string['oauth2redirecturi'] = 'OAuth 2 Redirect URI'; $string['privacy:metadata:repository_dropbox'] = 'The Dropbox repository plugin does not store any personal data, but does transmit user data from Moodle to the remote system.'; $string['privacy:metadata:repository_dropbox:query'] = 'The Dropbox repository user search text query.'; - -// Deprecated since Moodle 4.0. -$string['apikey'] = 'Dropbox API key'; -$string['secret'] = 'Dropbox secret'; -$string['instruction'] = 'You can get your API Key and secret from Dropbox developers. When setting up your key please select "Full Dropbox" as the "Access level".'; \ No newline at end of file diff --git a/theme/boost/classes/output/core_renderer.php b/theme/boost/classes/output/core_renderer.php index 203a42afec814..95cf185088cef 100644 --- a/theme/boost/classes/output/core_renderer.php +++ b/theme/boost/classes/output/core_renderer.php @@ -170,8 +170,7 @@ public function context_header($headerinfo = null, $headinglevel = 1): string { $purposeclass = plugin_supports('mod', $this->page->activityname, FEATURE_MOD_PURPOSE); $purposeclass .= ' activityiconcontainer icon-size-6'; $purposeclass .= ' modicon_' . $this->page->activityname; - $isbrandedfunction = $this->page->activityname.'_is_branded'; - $isbranded = function_exists($isbrandedfunction) ? $isbrandedfunction() : false; + $isbranded = component_callback('mod_' . $this->page->activityname, 'is_branded', [], false); $imagedata = html_writer::tag('div', $imagedata, ['class' => $purposeclass . ($isbranded ? ' isbranded' : '')]); if (!empty($USER->editing)) { $prefix = get_string('modulename', $this->page->activityname); diff --git a/theme/boost/lang/en/deprecated.txt b/theme/boost/lang/en/deprecated.txt index 37e07310d8aaa..cff2d7cc7278b 100644 --- a/theme/boost/lang/en/deprecated.txt +++ b/theme/boost/lang/en/deprecated.txt @@ -1,4 +1,3 @@ -totop,theme_boost privacy:drawernavclosed,theme_boost privacy:drawernavopen,theme_boost currentinparentheses,theme_boost \ No newline at end of file diff --git a/theme/boost/lang/en/theme_boost.php b/theme/boost/lang/en/theme_boost.php index 0c9bcd19137b8..358f127b7a5c1 100644 --- a/theme/boost/lang/en/theme_boost.php +++ b/theme/boost/lang/en/theme_boost.php @@ -59,9 +59,6 @@ $string['privacy:drawerblockclosed'] = 'The current preference for the block drawer is closed.'; $string['privacy:drawerblockopen'] = 'The current preference for the block drawer is open.'; -// Deprecated since Moodle 4.0. -$string['totop'] = 'Go to top'; - // Deprecated since Moodle 4.1. $string['currentinparentheses'] = '(current)'; $string['privacy:drawernavclosed'] = 'The current preference for the navigation drawer is closed.'; diff --git a/theme/boost/scss/fontawesome/_variables.scss b/theme/boost/scss/fontawesome/_variables.scss index 4d34515d89b30..cc9c00ac343de 100644 --- a/theme/boost/scss/fontawesome/_variables.scss +++ b/theme/boost/scss/fontawesome/_variables.scss @@ -1873,7 +1873,6 @@ $fa-var-exclamation-triangle: \f071; $fa-var-warning: \f071; $fa-var-database: \f1c0; $fa-var-share: \f064; -$fa-var-arrow-turn-right: \f064; $fa-var-mail-forward: \f064; $fa-var-bottle-droplet: \e4c4; $fa-var-mask-face: \e1d7; @@ -2012,6 +2011,7 @@ $fa-var-redhat: \f7bc; $fa-var-yoast: \f2b1; $fa-var-cloudflare: \e07d; $fa-var-ups: \f7e0; +$fa-var-pixiv: \e640; $fa-var-wpexplorer: \f2de; $fa-var-dyalog: \f399; $fa-var-bity: \f37a; @@ -2047,6 +2047,7 @@ $fa-var-vimeo-v: \f27d; $fa-var-contao: \f26d; $fa-var-square-font-awesome: \e5ad; $fa-var-deskpro: \f38f; +$fa-var-brave: \e63c; $fa-var-sistrix: \f3ee; $fa-var-square-instagram: \e055; $fa-var-instagram-square: \e055; @@ -2055,6 +2056,7 @@ $fa-var-the-red-yeti: \f69d; $fa-var-square-hacker-news: \f3af; $fa-var-hacker-news-square: \f3af; $fa-var-edge: \f282; +$fa-var-threads: \e618; $fa-var-napster: \f3d2; $fa-var-square-snapchat: \f2ad; $fa-var-snapchat-square: \f2ad; @@ -2102,6 +2104,7 @@ $fa-var-meetup: \f2e0; $fa-var-centos: \f789; $fa-var-adn: \f170; $fa-var-cloudsmith: \f384; +$fa-var-opensuse: \e62b; $fa-var-pied-piper-alt: \f1a8; $fa-var-square-dribbble: \f397; $fa-var-dribbble-square: \f397; @@ -2111,6 +2114,7 @@ $fa-var-mix: \f3cb; $fa-var-steam: \f1b6; $fa-var-cc-apple-pay: \f416; $fa-var-scribd: \f28a; +$fa-var-debian: \e60b; $fa-var-openid: \f19b; $fa-var-instalod: \e081; $fa-var-expeditedssl: \f23e; @@ -2149,6 +2153,7 @@ $fa-var-meta: \e49b; $fa-var-laravel: \f3bd; $fa-var-hotjar: \f3b1; $fa-var-bluetooth-b: \f294; +$fa-var-square-letterboxd: \e62e; $fa-var-sticker-mule: \f3f7; $fa-var-creative-commons-zero: \f4f3; $fa-var-hips: \f452; @@ -2160,6 +2165,7 @@ $fa-var-app-store-ios: \f370; $fa-var-cc-discover: \f1f2; $fa-var-wpbeginner: \f297; $fa-var-confluence: \f78d; +$fa-var-shoelace: \e60c; $fa-var-mdb: \f8ca; $fa-var-dochub: \f394; $fa-var-accessible-icon: \f368; @@ -2191,6 +2197,7 @@ $fa-var-fedex: \f797; $fa-var-phoenix-framework: \f3dc; $fa-var-shopify: \e057; $fa-var-neos: \f612; +$fa-var-square-threads: \e619; $fa-var-hackerrank: \f5f7; $fa-var-researchgate: \f4f8; $fa-var-swift: \f8e1; @@ -2200,6 +2207,7 @@ $fa-var-angrycreative: \f36e; $fa-var-y-combinator: \f23b; $fa-var-empire: \f1d1; $fa-var-envira: \f299; +$fa-var-google-scholar: \e63b; $fa-var-square-gitlab: \e5ae; $fa-var-gitlab-square: \e5ae; $fa-var-studiovinari: \f3f8; @@ -2244,6 +2252,7 @@ $fa-var-less: \f41d; $fa-var-blogger-b: \f37d; $fa-var-opencart: \f23d; $fa-var-vine: \f1ca; +$fa-var-signal-messenger: \e663; $fa-var-paypal: \f1ed; $fa-var-gitlab: \f296; $fa-var-typo3: \f42b; @@ -2255,6 +2264,7 @@ $fa-var-pied-piper-pp: \f1a7; $fa-var-bootstrap: \f836; $fa-var-odnoklassniki: \f263; $fa-var-nfc-symbol: \e531; +$fa-var-mintbit: \e62f; $fa-var-ethereum: \f42e; $fa-var-speaker-deck: \f83c; $fa-var-creative-commons-nc-eu: \f4e9; @@ -2263,6 +2273,7 @@ $fa-var-avianex: \f374; $fa-var-ello: \f5f1; $fa-var-gofore: \f3a7; $fa-var-bimobject: \f378; +$fa-var-brave-reverse: \e63d; $fa-var-facebook-f: \f39e; $fa-var-square-google-plus: \f0d4; $fa-var-google-plus-square: \f0d4; @@ -2297,6 +2308,7 @@ $fa-var-viber: \f409; $fa-var-soundcloud: \f1be; $fa-var-digg: \f1a6; $fa-var-tencent-weibo: \f1d5; +$fa-var-letterboxd: \e62d; $fa-var-symfony: \f83d; $fa-var-maxcdn: \f136; $fa-var-etsy: \f2d7; @@ -2305,6 +2317,7 @@ $fa-var-audible: \f373; $fa-var-think-peaks: \f731; $fa-var-bilibili: \e3d9; $fa-var-erlang: \f39d; +$fa-var-x-twitter: \e61b; $fa-var-cotton-bureau: \f89e; $fa-var-dashcube: \f210; $fa-var-42-group: \e080; @@ -2342,6 +2355,7 @@ $fa-var-cc-amazon-pay: \f42d; $fa-var-dropbox: \f16b; $fa-var-instagram: \f16d; $fa-var-cmplid: \e360; +$fa-var-upwork: \e641; $fa-var-facebook: \f09a; $fa-var-gripfire: \f3ac; $fa-var-jedi-order: \f50e; @@ -2400,6 +2414,7 @@ $fa-var-wix: \f5cf; $fa-var-square-behance: \f1b5; $fa-var-behance-square: \f1b5; $fa-var-supple: \f3f9; +$fa-var-webflow: \e65c; $fa-var-rebel: \f1d0; $fa-var-css3: \f13c; $fa-var-staylinked: \f3f5; @@ -2470,6 +2485,7 @@ $fa-var-usb: \f287; $fa-var-tumblr: \f173; $fa-var-vaadin: \f408; $fa-var-quora: \f2c4; +$fa-var-square-x-twitter: \e61a; $fa-var-reacteurope: \f75d; $fa-var-medium: \f23a; $fa-var-medium-m: \f23a; @@ -4328,7 +4344,6 @@ $fa-icons: ( "warning": $fa-var-warning, "database": $fa-var-database, "share": $fa-var-share, - "arrow-turn-right": $fa-var-arrow-turn-right, "mail-forward": $fa-var-mail-forward, "bottle-droplet": $fa-var-bottle-droplet, "mask-face": $fa-var-mask-face, @@ -4469,6 +4484,7 @@ $fa-brand-icons: ( "yoast": $fa-var-yoast, "cloudflare": $fa-var-cloudflare, "ups": $fa-var-ups, + "pixiv": $fa-var-pixiv, "wpexplorer": $fa-var-wpexplorer, "dyalog": $fa-var-dyalog, "bity": $fa-var-bity, @@ -4504,6 +4520,7 @@ $fa-brand-icons: ( "contao": $fa-var-contao, "square-font-awesome": $fa-var-square-font-awesome, "deskpro": $fa-var-deskpro, + "brave": $fa-var-brave, "sistrix": $fa-var-sistrix, "square-instagram": $fa-var-square-instagram, "instagram-square": $fa-var-instagram-square, @@ -4512,6 +4529,7 @@ $fa-brand-icons: ( "square-hacker-news": $fa-var-square-hacker-news, "hacker-news-square": $fa-var-hacker-news-square, "edge": $fa-var-edge, + "threads": $fa-var-threads, "napster": $fa-var-napster, "square-snapchat": $fa-var-square-snapchat, "snapchat-square": $fa-var-snapchat-square, @@ -4559,6 +4577,7 @@ $fa-brand-icons: ( "centos": $fa-var-centos, "adn": $fa-var-adn, "cloudsmith": $fa-var-cloudsmith, + "opensuse": $fa-var-opensuse, "pied-piper-alt": $fa-var-pied-piper-alt, "square-dribbble": $fa-var-square-dribbble, "dribbble-square": $fa-var-dribbble-square, @@ -4568,6 +4587,7 @@ $fa-brand-icons: ( "steam": $fa-var-steam, "cc-apple-pay": $fa-var-cc-apple-pay, "scribd": $fa-var-scribd, + "debian": $fa-var-debian, "openid": $fa-var-openid, "instalod": $fa-var-instalod, "expeditedssl": $fa-var-expeditedssl, @@ -4606,6 +4626,7 @@ $fa-brand-icons: ( "laravel": $fa-var-laravel, "hotjar": $fa-var-hotjar, "bluetooth-b": $fa-var-bluetooth-b, + "square-letterboxd": $fa-var-square-letterboxd, "sticker-mule": $fa-var-sticker-mule, "creative-commons-zero": $fa-var-creative-commons-zero, "hips": $fa-var-hips, @@ -4617,6 +4638,7 @@ $fa-brand-icons: ( "cc-discover": $fa-var-cc-discover, "wpbeginner": $fa-var-wpbeginner, "confluence": $fa-var-confluence, + "shoelace": $fa-var-shoelace, "mdb": $fa-var-mdb, "dochub": $fa-var-dochub, "accessible-icon": $fa-var-accessible-icon, @@ -4648,6 +4670,7 @@ $fa-brand-icons: ( "phoenix-framework": $fa-var-phoenix-framework, "shopify": $fa-var-shopify, "neos": $fa-var-neos, + "square-threads": $fa-var-square-threads, "hackerrank": $fa-var-hackerrank, "researchgate": $fa-var-researchgate, "swift": $fa-var-swift, @@ -4657,6 +4680,7 @@ $fa-brand-icons: ( "y-combinator": $fa-var-y-combinator, "empire": $fa-var-empire, "envira": $fa-var-envira, + "google-scholar": $fa-var-google-scholar, "square-gitlab": $fa-var-square-gitlab, "gitlab-square": $fa-var-gitlab-square, "studiovinari": $fa-var-studiovinari, @@ -4701,6 +4725,7 @@ $fa-brand-icons: ( "blogger-b": $fa-var-blogger-b, "opencart": $fa-var-opencart, "vine": $fa-var-vine, + "signal-messenger": $fa-var-signal-messenger, "paypal": $fa-var-paypal, "gitlab": $fa-var-gitlab, "typo3": $fa-var-typo3, @@ -4712,6 +4737,7 @@ $fa-brand-icons: ( "bootstrap": $fa-var-bootstrap, "odnoklassniki": $fa-var-odnoklassniki, "nfc-symbol": $fa-var-nfc-symbol, + "mintbit": $fa-var-mintbit, "ethereum": $fa-var-ethereum, "speaker-deck": $fa-var-speaker-deck, "creative-commons-nc-eu": $fa-var-creative-commons-nc-eu, @@ -4720,6 +4746,7 @@ $fa-brand-icons: ( "ello": $fa-var-ello, "gofore": $fa-var-gofore, "bimobject": $fa-var-bimobject, + "brave-reverse": $fa-var-brave-reverse, "facebook-f": $fa-var-facebook-f, "square-google-plus": $fa-var-square-google-plus, "google-plus-square": $fa-var-google-plus-square, @@ -4754,6 +4781,7 @@ $fa-brand-icons: ( "soundcloud": $fa-var-soundcloud, "digg": $fa-var-digg, "tencent-weibo": $fa-var-tencent-weibo, + "letterboxd": $fa-var-letterboxd, "symfony": $fa-var-symfony, "maxcdn": $fa-var-maxcdn, "etsy": $fa-var-etsy, @@ -4762,6 +4790,7 @@ $fa-brand-icons: ( "think-peaks": $fa-var-think-peaks, "bilibili": $fa-var-bilibili, "erlang": $fa-var-erlang, + "x-twitter": $fa-var-x-twitter, "cotton-bureau": $fa-var-cotton-bureau, "dashcube": $fa-var-dashcube, "42-group": $fa-var-42-group, @@ -4799,6 +4828,7 @@ $fa-brand-icons: ( "dropbox": $fa-var-dropbox, "instagram": $fa-var-instagram, "cmplid": $fa-var-cmplid, + "upwork": $fa-var-upwork, "facebook": $fa-var-facebook, "gripfire": $fa-var-gripfire, "jedi-order": $fa-var-jedi-order, @@ -4857,6 +4887,7 @@ $fa-brand-icons: ( "square-behance": $fa-var-square-behance, "behance-square": $fa-var-behance-square, "supple": $fa-var-supple, + "webflow": $fa-var-webflow, "rebel": $fa-var-rebel, "css3": $fa-var-css3, "staylinked": $fa-var-staylinked, @@ -4927,6 +4958,7 @@ $fa-brand-icons: ( "tumblr": $fa-var-tumblr, "vaadin": $fa-var-vaadin, "quora": $fa-var-quora, + "square-x-twitter": $fa-var-square-x-twitter, "reacteurope": $fa-var-reacteurope, "medium": $fa-var-medium, "medium-m": $fa-var-medium-m, diff --git a/theme/boost/scss/fontawesome/brands.scss b/theme/boost/scss/fontawesome/brands.scss index 438e4e00067a3..9677d5c23b954 100644 --- a/theme/boost/scss/fontawesome/brands.scss +++ b/theme/boost/scss/fontawesome/brands.scss @@ -1,5 +1,5 @@ /*! - * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2023 Fonticons, Inc. */ @@ -16,8 +16,7 @@ font-style: normal; font-weight: 400; font-display: $fa-font-display; - src: - url('[[font:core|fa-brands-400.woff2]]') format('woff2'), + src: url('[[font:core|fa-brands-400.woff2]]') format('woff2'), url('[[font:core|fa-brands-400.ttf]]') format('truetype'); } diff --git a/theme/boost/scss/fontawesome/fontawesome.scss b/theme/boost/scss/fontawesome/fontawesome.scss index ab4f133a7001f..61541e368d83d 100644 --- a/theme/boost/scss/fontawesome/fontawesome.scss +++ b/theme/boost/scss/fontawesome/fontawesome.scss @@ -1,5 +1,5 @@ /*! - * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2023 Fonticons, Inc. */ diff --git a/theme/boost/scss/fontawesome/regular.scss b/theme/boost/scss/fontawesome/regular.scss index 6b2fa234e9576..89e9338a33691 100644 --- a/theme/boost/scss/fontawesome/regular.scss +++ b/theme/boost/scss/fontawesome/regular.scss @@ -1,5 +1,5 @@ /*! - * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2023 Fonticons, Inc. */ @@ -16,8 +16,7 @@ font-style: normal; font-weight: 400; font-display: $fa-font-display; - src: - url('[[font:core|fa-regular-400.woff2]]') format('woff2'), + src: url('[[font:core|fa-regular-400.woff2]]') format('woff2'), url('[[font:core|fa-regular-400.ttf]]') format('truetype'); } diff --git a/theme/boost/scss/fontawesome/solid.scss b/theme/boost/scss/fontawesome/solid.scss index 2dfd72c2bbb40..6d623325c49fb 100644 --- a/theme/boost/scss/fontawesome/solid.scss +++ b/theme/boost/scss/fontawesome/solid.scss @@ -1,5 +1,5 @@ /*! - * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2023 Fonticons, Inc. */ @@ -16,8 +16,7 @@ font-style: normal; font-weight: 900; font-display: $fa-font-display; - src: - url('[[font:core|fa-solid-900.woff2]]') format('woff2'), + src: url('[[font:core|fa-solid-900.woff2]]') format('woff2'), url('[[font:core|fa-solid-900.ttf]]') format('truetype'); } diff --git a/theme/boost/scss/fontawesome/v4-shims.scss b/theme/boost/scss/fontawesome/v4-shims.scss index 7893e7cc06dcc..263b16ef70cad 100644 --- a/theme/boost/scss/fontawesome/v4-shims.scss +++ b/theme/boost/scss/fontawesome/v4-shims.scss @@ -1,5 +1,5 @@ /*! - * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2023 Fonticons, Inc. */ diff --git a/theme/boost/scss/moodle/calendar.scss b/theme/boost/scss/moodle/calendar.scss index cff348758269c..9991b797caac8 100644 --- a/theme/boost/scss/moodle/calendar.scss +++ b/theme/boost/scss/moodle/calendar.scss @@ -557,7 +557,7 @@ $calendarCurrentDateBackground: $primary; } .calendarwrapper { .arrow_text { - display: none; + @include sr-only; } } } diff --git a/theme/boost/scss/moodle/core.scss b/theme/boost/scss/moodle/core.scss index 80e5a3635baaa..158e53fd0f1e0 100644 --- a/theme/boost/scss/moodle/core.scss +++ b/theme/boost/scss/moodle/core.scss @@ -393,7 +393,7 @@ img.activityicon { #maincontent { display: block; height: 1px; - overflow: hidden; + overflow: clip; } img.uihint { @@ -2835,6 +2835,48 @@ body.dragging { cursor: move; } +.dropzone-container { + cursor: pointer; + + .dropzone { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + border: 2px dashed $filemanager-dnd-border-color; + border-radius: 0.5rem; + + &.dragover { + border: 2px dashed $filemanager-dnd-upload-over-border-color; + } + } + + .dropzone-icon { + color: $gray-500; + + .icon { + font-size: 6em; + width: auto; + height: auto; + max-width: initial; + max-height: initial; + margin-right: 0; + } + } + + .dropzone-sr-only-focusable { + &:active, + &:focus { + outline: 0; + box-shadow: $input-btn-focus-box-shadow; + z-index: $zindex-popover; + position: relative; + background: $sr-only-active-bg; + padding: 7px; + } + } +} + // Generic classes reactive components can use. .overlay-preview { diff --git a/theme/boost/scss/moodle/drawer.scss b/theme/boost/scss/moodle/drawer.scss index a57de7fbe1330..cf3b20e50aa7e 100644 --- a/theme/boost/scss/moodle/drawer.scss +++ b/theme/boost/scss/moodle/drawer.scss @@ -11,7 +11,8 @@ $drawer-bg-color: $gray-100 !default; $drawer-scroll-bg-track: $gray-100 !default; $drawer-shadow-color: rgba(0, 0, 0, .25) !default; -:target { +:target, +:focus { scroll-margin-top: $fixed-header-y + 10px; } diff --git a/theme/boost/scss/moodle/sticky-footer.scss b/theme/boost/scss/moodle/sticky-footer.scss index 8c78f113aa056..8e10985bde4d1 100644 --- a/theme/boost/scss/moodle/sticky-footer.scss +++ b/theme/boost/scss/moodle/sticky-footer.scss @@ -11,7 +11,7 @@ body { position: fixed; right: 0; left: 0; - min-height: $stickyfooter-height; + height: $stickyfooter-height; bottom: calc(#{$stickyfooter-height} * -1); transition: bottom .5s; z-index: $zindex-fixed; diff --git a/theme/boost/style/moodle.css b/theme/boost/style/moodle.css index b7ee36ca08574..0384046f9d7b1 100644 --- a/theme/boost/style/moodle.css +++ b/theme/boost/style/moodle.css @@ -1,6 +1,6 @@ @charset "UTF-8"; /*! - * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2023 Fonticons, Inc. */ @@ -69,6 +69,10 @@ content: "\f7e0"; } +.fa-pixiv:before { + content: "\e640"; +} + .fa-wpexplorer:before { content: "\f2de"; } @@ -209,6 +213,10 @@ content: "\f38f"; } +.fa-brave:before { + content: "\e63c"; +} + .fa-sistrix:before { content: "\f3ee"; } @@ -241,6 +249,10 @@ content: "\f282"; } +.fa-threads:before { + content: "\e618"; +} + .fa-napster:before { content: "\f3d2"; } @@ -429,6 +441,10 @@ content: "\f384"; } +.fa-opensuse:before { + content: "\e62b"; +} + .fa-pied-piper-alt:before { content: "\f1a8"; } @@ -465,6 +481,10 @@ content: "\f28a"; } +.fa-debian:before { + content: "\e60b"; +} + .fa-openid:before { content: "\f19b"; } @@ -617,6 +637,10 @@ content: "\f294"; } +.fa-square-letterboxd:before { + content: "\e62e"; +} + .fa-sticker-mule:before { content: "\f3f7"; } @@ -661,6 +685,10 @@ content: "\f78d"; } +.fa-shoelace:before { + content: "\e60c"; +} + .fa-mdb:before { content: "\f8ca"; } @@ -785,6 +813,10 @@ content: "\f612"; } +.fa-square-threads:before { + content: "\e619"; +} + .fa-hackerrank:before { content: "\f5f7"; } @@ -821,6 +853,10 @@ content: "\f299"; } +.fa-google-scholar:before { + content: "\e63b"; +} + .fa-square-gitlab:before { content: "\e5ae"; } @@ -997,6 +1033,10 @@ content: "\f1ca"; } +.fa-signal-messenger:before { + content: "\e663"; +} + .fa-paypal:before { content: "\f1ed"; } @@ -1041,6 +1081,10 @@ content: "\e531"; } +.fa-mintbit:before { + content: "\e62f"; +} + .fa-ethereum:before { content: "\f42e"; } @@ -1073,6 +1117,10 @@ content: "\f378"; } +.fa-brave-reverse:before { + content: "\e63d"; +} + .fa-facebook-f:before { content: "\f39e"; } @@ -1209,6 +1257,10 @@ content: "\f1d5"; } +.fa-letterboxd:before { + content: "\e62d"; +} + .fa-symfony:before { content: "\f83d"; } @@ -1241,6 +1293,10 @@ content: "\f39d"; } +.fa-x-twitter:before { + content: "\e61b"; +} + .fa-cotton-bureau:before { content: "\f89e"; } @@ -1389,6 +1445,10 @@ content: "\e360"; } +.fa-upwork:before { + content: "\e641"; +} + .fa-facebook:before { content: "\f09a"; } @@ -1621,6 +1681,10 @@ content: "\f3f9"; } +.fa-webflow:before { + content: "\e65c"; +} + .fa-rebel:before { content: "\f1d0"; } @@ -1901,6 +1965,10 @@ content: "\f2c4"; } +.fa-square-x-twitter:before { + content: "\e61a"; +} + .fa-reacteurope:before { content: "\f75d"; } @@ -2026,7 +2094,7 @@ } /*! - * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2023 Fonticons, Inc. */ @@ -2049,7 +2117,7 @@ } /*! - * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2023 Fonticons, Inc. */ @@ -2095,7 +2163,7 @@ } /*! - * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2023 Fonticons, Inc. */ @@ -4914,7 +4982,7 @@ } /*! - * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2023 Fonticons, Inc. */ @@ -12662,10 +12730,6 @@ readers do not read off random characters that represent icons */ content: "\f064"; } -.fa-arrow-turn-right::before { - content: "\f064"; -} - .fa-mail-forward::before { content: "\f064"; } @@ -23282,7 +23346,7 @@ img.activityicon { #maincontent { display: block; height: 1px; - overflow: hidden; + overflow: clip; } img.uihint { @@ -25725,6 +25789,40 @@ body.dragging .dragging { cursor: move; } +.dropzone-container { + cursor: pointer; +} +.dropzone-container .dropzone { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + border: 2px dashed #bbb; + border-radius: 0.5rem; +} +.dropzone-container .dropzone.dragover { + border: 2px dashed #6c8cd3; +} +.dropzone-container .dropzone-icon { + color: #8f959e; +} +.dropzone-container .dropzone-icon .icon { + font-size: 6em; + width: auto; + height: auto; + max-width: initial; + max-height: initial; + margin-right: 0; +} +.dropzone-container .dropzone-sr-only-focusable:active, .dropzone-container .dropzone-sr-only-focusable:focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(15, 108, 191, 0.75); + z-index: 1060; + position: relative; + background: #fff; + padding: 7px; +} + .overlay-preview { background-color: rgba(255, 255, 255, 0.8); border: 2px dashed #0f6cbf; @@ -27622,7 +27720,15 @@ aside[id^=block-region-side-] .block_recentlyaccesseditems .dashboard-card-deck height: 0; } .path-course-view .block.block_calendar_month .maincalendar .calendarwrapper .arrow_text { - display: none; + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } .path-course-view .block.block_calendar_month .footer .bottom .footer-link { display: block; @@ -29748,7 +29854,8 @@ span.editinstructions .alert-link { } /* Anchor link offset fix. This makes hash links scroll 60px down to account for the fixed header. */ -:target { +:target, +:focus { scroll-margin-top: 70px; } @@ -36112,7 +36219,7 @@ body { position: fixed; right: 0; left: 0; - min-height: max(80px, 0.9375rem * 3); + height: max(80px, 0.9375rem * 3); bottom: calc(max(80px, 0.9375rem * 3) * -1); transition: bottom 0.5s; z-index: 1030; diff --git a/theme/boost/thirdpartylibs.xml b/theme/boost/thirdpartylibs.xml index a6c09cecf7864..6576f9cd947e5 100644 --- a/theme/boost/thirdpartylibs.xml +++ b/theme/boost/thirdpartylibs.xml @@ -199,7 +199,7 @@ scss/fontawesome Font Awesome - http://fontawesome.com Font Awesome CSS, LESS, and Sass files. Font Awesome is the Internet's icon library and toolkit, used by millions of designers, developers, and content creators. - 6.4.0 + 6.5.1 (MIT) https://github.com/FortAwesome/Font-Awesome diff --git a/theme/classic/style/moodle.css b/theme/classic/style/moodle.css index 20cd8dfe65df6..1781a921e3752 100644 --- a/theme/classic/style/moodle.css +++ b/theme/classic/style/moodle.css @@ -1,6 +1,6 @@ @charset "UTF-8"; /*! - * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2023 Fonticons, Inc. */ @@ -69,6 +69,10 @@ content: "\f7e0"; } +.fa-pixiv:before { + content: "\e640"; +} + .fa-wpexplorer:before { content: "\f2de"; } @@ -209,6 +213,10 @@ content: "\f38f"; } +.fa-brave:before { + content: "\e63c"; +} + .fa-sistrix:before { content: "\f3ee"; } @@ -241,6 +249,10 @@ content: "\f282"; } +.fa-threads:before { + content: "\e618"; +} + .fa-napster:before { content: "\f3d2"; } @@ -429,6 +441,10 @@ content: "\f384"; } +.fa-opensuse:before { + content: "\e62b"; +} + .fa-pied-piper-alt:before { content: "\f1a8"; } @@ -465,6 +481,10 @@ content: "\f28a"; } +.fa-debian:before { + content: "\e60b"; +} + .fa-openid:before { content: "\f19b"; } @@ -617,6 +637,10 @@ content: "\f294"; } +.fa-square-letterboxd:before { + content: "\e62e"; +} + .fa-sticker-mule:before { content: "\f3f7"; } @@ -661,6 +685,10 @@ content: "\f78d"; } +.fa-shoelace:before { + content: "\e60c"; +} + .fa-mdb:before { content: "\f8ca"; } @@ -785,6 +813,10 @@ content: "\f612"; } +.fa-square-threads:before { + content: "\e619"; +} + .fa-hackerrank:before { content: "\f5f7"; } @@ -821,6 +853,10 @@ content: "\f299"; } +.fa-google-scholar:before { + content: "\e63b"; +} + .fa-square-gitlab:before { content: "\e5ae"; } @@ -997,6 +1033,10 @@ content: "\f1ca"; } +.fa-signal-messenger:before { + content: "\e663"; +} + .fa-paypal:before { content: "\f1ed"; } @@ -1041,6 +1081,10 @@ content: "\e531"; } +.fa-mintbit:before { + content: "\e62f"; +} + .fa-ethereum:before { content: "\f42e"; } @@ -1073,6 +1117,10 @@ content: "\f378"; } +.fa-brave-reverse:before { + content: "\e63d"; +} + .fa-facebook-f:before { content: "\f39e"; } @@ -1209,6 +1257,10 @@ content: "\f1d5"; } +.fa-letterboxd:before { + content: "\e62d"; +} + .fa-symfony:before { content: "\f83d"; } @@ -1241,6 +1293,10 @@ content: "\f39d"; } +.fa-x-twitter:before { + content: "\e61b"; +} + .fa-cotton-bureau:before { content: "\f89e"; } @@ -1389,6 +1445,10 @@ content: "\e360"; } +.fa-upwork:before { + content: "\e641"; +} + .fa-facebook:before { content: "\f09a"; } @@ -1621,6 +1681,10 @@ content: "\f3f9"; } +.fa-webflow:before { + content: "\e65c"; +} + .fa-rebel:before { content: "\f1d0"; } @@ -1901,6 +1965,10 @@ content: "\f2c4"; } +.fa-square-x-twitter:before { + content: "\e61a"; +} + .fa-reacteurope:before { content: "\f75d"; } @@ -2026,7 +2094,7 @@ } /*! - * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2023 Fonticons, Inc. */ @@ -2049,7 +2117,7 @@ } /*! - * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2023 Fonticons, Inc. */ @@ -2095,7 +2163,7 @@ } /*! - * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2023 Fonticons, Inc. */ @@ -4914,7 +4982,7 @@ } /*! - * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) * Copyright 2023 Fonticons, Inc. */ @@ -12662,10 +12730,6 @@ readers do not read off random characters that represent icons */ content: "\f064"; } -.fa-arrow-turn-right::before { - content: "\f064"; -} - .fa-mail-forward::before { content: "\f064"; } @@ -23282,7 +23346,7 @@ img.activityicon { #maincontent { display: block; height: 1px; - overflow: hidden; + overflow: clip; } img.uihint { @@ -25725,6 +25789,40 @@ body.dragging .dragging { cursor: move; } +.dropzone-container { + cursor: pointer; +} +.dropzone-container .dropzone { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + border: 2px dashed #bbb; + border-radius: 0.5rem; +} +.dropzone-container .dropzone.dragover { + border: 2px dashed #6c8cd3; +} +.dropzone-container .dropzone-icon { + color: #8f959e; +} +.dropzone-container .dropzone-icon .icon { + font-size: 6em; + width: auto; + height: auto; + max-width: initial; + max-height: initial; + margin-right: 0; +} +.dropzone-container .dropzone-sr-only-focusable:active, .dropzone-container .dropzone-sr-only-focusable:focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(15, 108, 191, 0.75); + z-index: 1060; + position: relative; + background: #fff; + padding: 7px; +} + .overlay-preview { background-color: rgba(255, 255, 255, 0.8); border: 2px dashed #0f6cbf; @@ -27622,7 +27720,15 @@ aside[id^=block-region-side-] .block_recentlyaccesseditems .dashboard-card-deck height: 0; } .path-course-view .block.block_calendar_month .maincalendar .calendarwrapper .arrow_text { - display: none; + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } .path-course-view .block.block_calendar_month .footer .bottom .footer-link { display: block; @@ -29748,7 +29854,8 @@ span.editinstructions .alert-link { } /* Anchor link offset fix. This makes hash links scroll 60px down to account for the fixed header. */ -:target { +:target, +:focus { scroll-margin-top: 60px; } @@ -36046,7 +36153,7 @@ body { position: fixed; right: 0; left: 0; - min-height: max(80px, 0.9375rem * 3); + height: max(80px, 0.9375rem * 3); bottom: calc(max(80px, 0.9375rem * 3) * -1); transition: bottom 0.5s; z-index: 1030; diff --git a/user/externallib.php b/user/externallib.php index 0213d9425d562..360fba70c0fac 100644 --- a/user/externallib.php +++ b/user/externallib.php @@ -1139,6 +1139,8 @@ public static function user_description($additionalfields = array()) { 'theme' => new external_value(core_user::get_property_type('theme'), 'Theme name such as "standard", must exist on server', VALUE_OPTIONAL), 'timezone' => new external_value(core_user::get_property_type('timezone'), 'Timezone code such as Australia/Perth, or 99 for default', VALUE_OPTIONAL), 'mailformat' => new external_value(core_user::get_property_type('mailformat'), 'Mail format code is 0 for plain text, 1 for HTML etc', VALUE_OPTIONAL), + 'trackforums' => new external_value(core_user::get_property_type('trackforums'), + 'Whether the user is tracking forums.', VALUE_OPTIONAL), 'description' => new external_value(core_user::get_property_type('description'), 'User profile description', VALUE_OPTIONAL), 'descriptionformat' => new external_format_value(core_user::get_property_type('descriptionformat'), VALUE_OPTIONAL), 'city' => new external_value(core_user::get_property_type('city'), 'Home city of the user', VALUE_OPTIONAL), diff --git a/user/lib.php b/user/lib.php index e7e46e2de08cd..4d437d3ac8aed 100644 --- a/user/lib.php +++ b/user/lib.php @@ -300,7 +300,7 @@ function user_get_default_fields() { 'institution', 'interests', 'firstaccess', 'lastaccess', 'auth', 'confirmed', 'idnumber', 'lang', 'theme', 'timezone', 'mailformat', 'description', 'descriptionformat', 'city', 'country', 'profileimageurlsmall', 'profileimageurl', 'customfields', - 'groups', 'roles', 'preferences', 'enrolledcourses', 'suspended', 'lastcourseaccess' + 'groups', 'roles', 'preferences', 'enrolledcourses', 'suspended', 'lastcourseaccess', 'trackforums', ); } @@ -612,7 +612,7 @@ function user_get_user_details($user, $course = null, array $userfields = array( } if ($currentuser or has_capability('moodle/user:viewalldetails', $context)) { - $extrafields = ['auth', 'confirmed', 'lang', 'theme', 'mailformat']; + $extrafields = ['auth', 'confirmed', 'lang', 'theme', 'mailformat', 'trackforums']; foreach ($extrafields as $extrafield) { if (in_array($extrafield, $userfields) && isset($user->$extrafield)) { $userdetails[$extrafield] = $user->$extrafield; diff --git a/user/tests/externallib_test.php b/user/tests/externallib_test.php index 3e1ddc64e1e4c..5701a2e4bd527 100644 --- a/user/tests/externallib_test.php +++ b/user/tests/externallib_test.php @@ -304,6 +304,7 @@ public function test_get_users_by_field() { } // Default language and no theme were used for the user. $this->assertEquals($CFG->lang, $returneduser['lang']); + $this->assertEquals($generateduser->trackforums, $returneduser['trackforums']); $this->assertEmpty($returneduser['theme']); if ($returneduser['id'] == $user1->id) { diff --git a/user/tests/reportbuilder/datasource/users_test.php b/user/tests/reportbuilder/datasource/users_test.php index 5364a03ef6ed3..29e6d45a37442 100644 --- a/user/tests/reportbuilder/datasource/users_test.php +++ b/user/tests/reportbuilder/datasource/users_test.php @@ -112,14 +112,15 @@ public function test_datasource_non_default_columns(): void { $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:lastaccess']); $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:suspended']); $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:confirmed']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:auth']); $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:moodlenetprofile']); $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:timecreated']); + $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:timemodified']); $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:lastip']); $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'user:theme']); // Tags. $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'tag:name']); - $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'tag:namewithlink']); // Cohort. $generator->create_column(['reportid' => $report->get('id'), 'uniqueidentifier' => 'cohort:name']); @@ -127,44 +128,87 @@ public function test_datasource_non_default_columns(): void { $content = $this->get_custom_report_content($report->get('id')); $this->assertCount(2, $content); - [$adminrow, $userrow] = array_map('array_values', $content); - - $this->assertStringContainsString('Admin User', $adminrow[0]); - $this->assertStringContainsString('Admin User', $adminrow[1]); - $this->assertStringContainsString('Admin User', $adminrow[2]); - $this->assertNotEmpty($adminrow[3]); - $this->assertEquals('Admin', $adminrow[4]); - $this->assertEquals('User', $adminrow[5]); - - $this->assertStringContainsString(fullname($user), $userrow[0]); - $this->assertStringContainsString(fullname($user), $userrow[1]); - $this->assertStringContainsString(fullname($user), $userrow[2]); - $this->assertNotEmpty($userrow[3]); - $this->assertEquals($user->firstname, $userrow[4]); - $this->assertEquals($user->lastname, $userrow[5]); - $this->assertEquals($user->city, $userrow[6]); - $this->assertEquals('United Kingdom', $userrow[7]); - $this->assertEquals($user->description, $userrow[8]); - $this->assertEquals($user->firstnamephonetic, $userrow[9]); - $this->assertEquals($user->lastnamephonetic, $userrow[10]); - $this->assertEquals($user->middlename, $userrow[11]); - $this->assertEquals($user->alternatename, $userrow[12]); - $this->assertEquals($user->idnumber, $userrow[13]); - $this->assertEquals($user->institution, $userrow[14]); - $this->assertEquals($user->department, $userrow[15]); - $this->assertEquals($user->phone1, $userrow[16]); - $this->assertEquals($user->phone2, $userrow[17]); - $this->assertEquals($user->address, $userrow[18]); - $this->assertEmpty($userrow[19]); - $this->assertEquals('No', $userrow[20]); - $this->assertEquals('Yes', $userrow[21]); - $this->assertEquals($user->moodlenetprofile, $userrow[22]); - $this->assertNotEmpty($userrow[23]); - $this->assertEquals('0.0.0.0', $userrow[24]); - $this->assertEquals('Boost', $userrow[25]); - $this->assertEquals('Horses', $userrow[26]); - $this->assertStringContainsString('Horses', $userrow[27]); - $this->assertEquals($cohort->name, $userrow[28]); + // Admin row. + [ + $fullnamewithlink, + $fullnamewithpicture, + $fullnamewithpicturelink, + $picture, + $lastname, + $firstname, + ] = array_values($content[0]); + + $this->assertStringContainsString('Admin User', $fullnamewithlink); + $this->assertStringContainsString('Admin User', $fullnamewithpicture); + $this->assertStringContainsString('Admin User', $fullnamewithpicturelink); + $this->assertNotEmpty($picture); + $this->assertEquals('Admin', $lastname); + $this->assertEquals('User', $firstname); + + // User row. + [ + $fullnamewithlink, + $fullnamewithpicture, + $fullnamewithpicturelink, + $picture, + $firstname, + $lastname, + $city, + $country, + $description, + $firstnamephonetic, + $lastnamephonetic, + $middlename, + $alternatename, + $idnumber, + $institution, + $department, + $phone1, + $phone2, + $address, + $lastaccess, + $suspended, + $confirmed, + $auth, + $moodlenetprofile, + $timecreated, + $timemodified, + $lastip, + $theme, + $tag, + $cohortname, + ] = array_values($content[1]); + + $this->assertStringContainsString(fullname($user), $fullnamewithlink); + $this->assertStringContainsString(fullname($user), $fullnamewithpicture); + $this->assertStringContainsString(fullname($user), $fullnamewithpicturelink); + $this->assertNotEmpty($picture); + $this->assertEquals($user->firstname, $firstname); + $this->assertEquals($user->lastname, $lastname); + $this->assertEquals($user->city, $city); + $this->assertEquals('United Kingdom', $country); + $this->assertEquals($user->description, $description); + $this->assertEquals($user->firstnamephonetic, $firstnamephonetic); + $this->assertEquals($user->lastnamephonetic, $lastnamephonetic); + $this->assertEquals($user->middlename, $middlename); + $this->assertEquals($user->alternatename, $alternatename); + $this->assertEquals($user->idnumber, $idnumber); + $this->assertEquals($user->institution, $institution); + $this->assertEquals($user->department, $department); + $this->assertEquals($user->phone1, $phone1); + $this->assertEquals($user->phone2, $phone2); + $this->assertEquals($user->address, $address); + $this->assertEmpty($lastaccess); + $this->assertEquals('No', $suspended); + $this->assertEquals('Yes', $confirmed); + $this->assertEquals('Manual accounts', $auth); + $this->assertEquals($user->moodlenetprofile, $moodlenetprofile); + $this->assertNotEmpty($timecreated); + $this->assertNotEmpty($timemodified); + $this->assertEquals('0.0.0.0', $lastip); + $this->assertEquals('Boost', $theme); + $this->assertEquals('Horses', $tag); + $this->assertEquals($cohort->name, $cohortname); } /** @@ -368,6 +412,14 @@ public function datasource_filters_provider(): array { 'user:timecreated_from' => 1619823600, 'user:timecreated_to' => 1622502000, ], false], + 'Filter timemodified' => ['user:timemodified', [ + 'user:timemodified_operator' => date::DATE_RANGE, + 'user:timemodified_from' => 1622502000, + ], true], + 'Filter timemodified (no match)' => ['user:timemodified', [ + 'user:timemodified_operator' => date::DATE_RANGE, + 'user:timemodified_to' => 1622502000, + ], false], 'Filter lastaccess' => ['user:lastaccess', [ 'user:lastaccess_operator' => date::DATE_EMPTY, ], true], diff --git a/user/tests/userlib_test.php b/user/tests/userlib_test.php index 56947ee62b291..7271b2fed40bb 100644 --- a/user/tests/userlib_test.php +++ b/user/tests/userlib_test.php @@ -892,6 +892,7 @@ public function test_user_get_user_details_missing_fields() { 'theme' => $CFG->theme, 'timezone' => '5', 'mailformat' => '0', + 'trackforums' => '1', ]); // Fields that should get by default. @@ -903,6 +904,7 @@ public function test_user_get_user_details_missing_fields() { self::assertSame($CFG->theme, $got['theme']); self::assertSame('5', $got['timezone']); self::assertSame('0', $got['mailformat']); + self::assertSame('1', $got['trackforums']); } /** diff --git a/version.php b/version.php index ea35f11db131e..61e210cb0029f 100644 --- a/version.php +++ b/version.php @@ -29,10 +29,9 @@ defined('MOODLE_INTERNAL') || die(); - -$version = 2024032000.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2024032200.00; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. -$release = '4.4dev+ (Build: 20240320)'; // Human-friendly version name +$release = '4.4dev+ (Build: 20240322)'; // Human-friendly version name $branch = '404'; // This version's branch. $maturity = MATURITY_ALPHA; // This version's maturity level.