diff --git a/.gitignore b/.gitignore index d74f914f79f50..2ab8b0a66666e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ /.patches/ /.idea/ .phpstorm.* +!.phpstorm.meta.php /nbproject/ CVS .DS_Store diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php new file mode 100644 index 0000000000000..1f9f6c3000b18 --- /dev/null +++ b/.phpstorm.meta.php @@ -0,0 +1,40 @@ +. + +/** + * Helper file for PhpStorm, and other IDEs, to provide better code completion. + * + * @package core + * @copyright 2024 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace PHPSTORM_META; + +// Assume that anything returned by \core\di::get('...') is an instance of the first argument. +override(\core\di::get(0), map([ + '' => '@', +])); + +// Assume that anything returned by \Psr\Container\ContainerInterface::get('...') is an instance of the first argument. +override(\Psr\Container\ContainerInterface::get(0), map([ + '' => '@', +])); + +// Assume that anything returned by \DI\Container::get('...') is an instance of the first argument. +override(\DI\Container::get(0), map([ + '' => '@', +])); diff --git a/admin/tool/policy/tests/api_test.php b/admin/tool/policy/tests/api_test.php index de473299f635b..0b428fd3c2b7b 100644 --- a/admin/tool/policy/tests/api_test.php +++ b/admin/tool/policy/tests/api_test.php @@ -37,7 +37,7 @@ public function test_policy_document_life_cycle() { // Prepare the form data for adding a new policy document. $formdata = api::form_policydoc_data(new policy_version(0)); - $this->assertObjectHasAttribute('name', $formdata); + $this->assertObjectHasProperty('name', $formdata); $this->assertArrayHasKey('text', $formdata->summary_editor); $this->assertArrayHasKey('format', $formdata->content_editor); diff --git a/admin/tool/uploadcourse/tests/behat/enrolments.feature b/admin/tool/uploadcourse/tests/behat/enrolments.feature index 90f3060bfc52f..03dc99318320c 100644 --- a/admin/tool/uploadcourse/tests/behat/enrolments.feature +++ b/admin/tool/uploadcourse/tests/behat/enrolments.feature @@ -102,3 +102,55 @@ Feature: An admin can update courses enrolments using a CSV file Then I should see "Course updated" And I am on the "Course 1" "enrolment methods" page And I should not see "Guest access" in the "generaltable" "table" + + @javascript + Scenario: Re-upload a file using CSV data only after deleting the enrolments method + Given I navigate to "Plugins > Enrolments > Manage enrol plugins" in site administration + And I click on "Enable" "link" in the "Course meta link" "table_row" + And the following "cohort" exists: + | name | Cohort1 | + | idnumber | Cohort1 | + And the following "category" exists: + | name | Cat 1 | + | category | 0 | + | idnumber | CAT1 | + And I navigate to "Courses > Upload courses" in site administration + And I upload "admin/tool/uploadcourse/tests/fixtures/enrolment_multiple.csv" file to "File" filemanager + And I click on "Preview" "button" + And I click on "Upload courses" "button" + And I am on the "Course 2" "enrolment methods" page + And I should see "Self enrolment (Student)" in the "generaltable" "table" + And I should see "Cohort sync (Cohort1 - Student)" in the "generaltable" "table" + And I should see "Guest access" in the "generaltable" "table" + And I should see "Manual enrolments" in the "generaltable" "table" + And I should see "Course meta link (Course 1)" in the "generaltable" "table" + And I navigate to "Courses > Upload courses" in site administration + # Delete all enrolment methods. + And I upload "admin/tool/uploadcourse/tests/fixtures/enrolment_multiple_delete.csv" file to "File" filemanager + And I set the field "Upload mode" to "Only update existing courses" + And I set the field "Update mode" to "Update with CSV data only" + And I set the field "Allow deletes" to "Yes" + And I click on "Preview" "button" + And I click on "Upload courses" "button" + And I should see "Course updated" + And I am on the "Course 2" "enrolment methods" page + And I should not see "Self enrolment (Student)" in the "generaltable" "table" + And I should not see "Cohort sync (Cohort1 - Student)" in the "generaltable" "table" + And I should not see "Guest access" in the "generaltable" "table" + And I should not see "Manual enrolments" in the "generaltable" "table" + And I should not see "Course meta link (Course 1)" in the "generaltable" "table" + # Re-upload again the CSV file, to add again the enrolment methods. + And I navigate to "Courses > Upload courses" in site administration + When I upload "admin/tool/uploadcourse/tests/fixtures/enrolment_multiple.csv" file to "File" filemanager + And I set the field "Upload mode" to "Only update existing courses" + And I set the field "Update mode" to "Update with CSV data only" + And I set the field "Allow deletes" to "Yes" + And I click on "Preview" "button" + And I click on "Upload courses" "button" + And I am on the "Course 2" "enrolment methods" page + Then I should see "Self enrolment (Student)" in the "generaltable" "table" + And I should see "Cohort sync (Cohort1 - Student)" in the "generaltable" "table" + And I should see "Guest access" in the "generaltable" "table" + And I should see "Manual enrolments" in the "generaltable" "table" + And I should see "Course meta link (Course 1)" in the "generaltable" "table" + And I navigate to "Courses > Upload courses" in site administration diff --git a/admin/tool/uploadcourse/tests/fixtures/enrolment_multiple.csv b/admin/tool/uploadcourse/tests/fixtures/enrolment_multiple.csv new file mode 100644 index 0000000000000..60f0768a2bf74 --- /dev/null +++ b/admin/tool/uploadcourse/tests/fixtures/enrolment_multiple.csv @@ -0,0 +1,2 @@ +shortname,fullname,category_idnumber,enrolment_1,enrolment_1_role,enrolment_2,enrolment_2_role,enrolment_2_cohortidnumber,enrolment_3,enrolment_4,enrolment_4_role,enrolment_5,enrolment_5_metacoursename +C2,Course 2,CAT1,self,student,cohort,student,Cohort1,guest,manual,student,meta,C1 diff --git a/admin/tool/uploadcourse/tests/fixtures/enrolment_multiple_delete.csv b/admin/tool/uploadcourse/tests/fixtures/enrolment_multiple_delete.csv new file mode 100644 index 0000000000000..8ec824556d9f7 --- /dev/null +++ b/admin/tool/uploadcourse/tests/fixtures/enrolment_multiple_delete.csv @@ -0,0 +1,2 @@ +shortname,fullname,category_idnumber,enrolment_1,enrolment_1_delete,enrolment_2,enrolment_2_role,enrolment_2_cohortidnumber,enrolment_2_delete,enrolment_3,enrolment_3_delete,enrolment_4,enrolment_4_delete,enrolment_5,enrolment_5_metacoursename,enrolment_5_delete +C2,Course 2,CAT1,self,1,cohort,student,Cohort1,1,guest,1,manual,1,meta,C1,1 diff --git a/admin/tool/uploaduser/tests/cli_test.php b/admin/tool/uploaduser/tests/cli_test.php index f053eec7dea25..bea9eab178b5f 100644 --- a/admin/tool/uploaduser/tests/cli_test.php +++ b/admin/tool/uploaduser/tests/cli_test.php @@ -143,7 +143,7 @@ public function test_upload_with_profile_fields() { // Created users have data in the profile fields. $user1 = \core_user::get_user_by_username('reznort'); $profilefields1 = profile_user_record($user1->id); - $this->assertObjectHasAttribute('superfield', $profilefields1); + $this->assertObjectHasProperty('superfield', $profilefields1); $this->assertEquals('Loves cats', $profilefields1->superfield); } diff --git a/admin/tool/xmldb/actions/edit_field_save/edit_field_save.class.php b/admin/tool/xmldb/actions/edit_field_save/edit_field_save.class.php index 40bbeee44c910..67ddf045edf19 100644 --- a/admin/tool/xmldb/actions/edit_field_save/edit_field_save.class.php +++ b/admin/tool/xmldb/actions/edit_field_save/edit_field_save.class.php @@ -91,12 +91,12 @@ function invoke() { $comment = trim($comment); $type = required_param('type', PARAM_INT); - $length = strtolower(optional_param('length', NULL, PARAM_ALPHANUM)); + $length = optional_param('length', null, PARAM_INT); $decimals = optional_param('decimals', NULL, PARAM_INT); $notnull = optional_param('notnull', false, PARAM_BOOL); $sequence = optional_param('sequence', false, PARAM_BOOL); $default = optional_param('default', NULL, PARAM_PATH); - $default = trim($default); + $default = is_null($default) ? $default : trim($default); $editeddir = $XMLDB->editeddirs[$dirpath]; $structure = $editeddir->xml_file->getStructure(); diff --git a/auth/tests/privacy/provider_test.php b/auth/tests/privacy/provider_test.php index 6e041552087c0..469acfe6ba9b5 100644 --- a/auth/tests/privacy/provider_test.php +++ b/auth/tests/privacy/provider_test.php @@ -94,11 +94,11 @@ public function test_export_user_preferences() { $prefs = writer::with_context($sysctx)->get_user_preferences('core_auth'); $this->assertEquals(transform::yesno(false), $prefs->auth_forcepasswordchange->value); $this->assertEquals(transform::yesno(false), $prefs->create_password->value); - $this->assertObjectNotHasAttribute('login_failed_count', $prefs); - $this->assertObjectNotHasAttribute('login_failed_count_since_success', $prefs); - $this->assertObjectNotHasAttribute('login_failed_last', $prefs); - $this->assertObjectNotHasAttribute('login_lockout', $prefs); + $this->assertObjectNotHasProperty('login_failed_count', $prefs); + $this->assertObjectNotHasProperty('login_failed_count_since_success', $prefs); + $this->assertObjectNotHasProperty('login_failed_last', $prefs); + $this->assertObjectNotHasProperty('login_lockout', $prefs); $this->assertEquals(transform::yesno(true), $prefs->login_lockout_ignored->value); - $this->assertObjectNotHasAttribute('login_lockout_secret', $prefs); + $this->assertObjectNotHasProperty('login_lockout_secret', $prefs); } } diff --git a/badges/tests/badgeslib_test.php b/badges/tests/badgeslib_test.php index 555e2d8a70421..a5268d839da97 100644 --- a/badges/tests/badgeslib_test.php +++ b/badges/tests/badgeslib_test.php @@ -330,8 +330,8 @@ public function test_badge_awards() { $message = array_pop($messages); // Check we have the expected data. $customdata = json_decode($message->customdata); - $this->assertObjectHasAttribute('notificationiconurl', $customdata); - $this->assertObjectHasAttribute('hash', $customdata); + $this->assertObjectHasProperty('notificationiconurl', $customdata); + $this->assertObjectHasProperty('hash', $customdata); $user2 = $this->getDataGenerator()->create_user(); $badge->issue($user2->id, true); diff --git a/blocks/tests/privacy/provider_test.php b/blocks/tests/privacy/provider_test.php index eb6bcf1618e9f..6438ccc081c09 100644 --- a/blocks/tests/privacy/provider_test.php +++ b/blocks/tests/privacy/provider_test.php @@ -381,12 +381,12 @@ public function test_export_data_for_user() { $this->assertEquals($yes, $prefs->block_is_hidden->value); $prefs = writer::with_context($bprivatefiles->context)->get_user_context_preferences('core_block'); - $this->assertObjectNotHasAttribute('block_is_docked', $prefs); + $this->assertObjectNotHasProperty('block_is_docked', $prefs); $this->assertEquals($no, $prefs->block_is_hidden->value); $prefs = writer::with_context($bmyprofile->context)->get_user_context_preferences('core_block'); $this->assertEquals($yes, $prefs->block_is_docked->value); - $this->assertObjectNotHasAttribute('block_is_hidden', $prefs); + $this->assertObjectNotHasProperty('block_is_hidden', $prefs); } /** diff --git a/cache/classes/helper.php b/cache/classes/helper.php index b3a5d083d21d0..cd46243af8fe5 100644 --- a/cache/classes/helper.php +++ b/cache/classes/helper.php @@ -873,4 +873,13 @@ public static function warnings(array $stores = null) { public static function result_found($value): bool { return $value !== false; } + + /** + * Checks whether the cluster mode is available in PHP. + * + * @return bool Return true if the PHP supports redis cluster, otherwise false. + */ + public static function is_cluster_available(): bool { + return class_exists('RedisCluster'); + } } diff --git a/cache/stores/redis/addinstanceform.php b/cache/stores/redis/addinstanceform.php index cdd9ed653a34a..770100c37658e 100644 --- a/cache/stores/redis/addinstanceform.php +++ b/cache/stores/redis/addinstanceform.php @@ -24,7 +24,7 @@ defined('MOODLE_INTERNAL') || die(); -require_once($CFG->dirroot.'/cache/forms.php'); +require_once($CFG->dirroot . '/cache/forms.php'); /** * Form for adding instance of Redis Cache Store. @@ -39,7 +39,12 @@ class cachestore_redis_addinstance_form extends cachestore_addinstance_form { protected function configuration_definition() { $form = $this->_form; - $form->addElement('text', 'server', get_string('server', 'cachestore_redis'), array('size' => 24)); + $form->addElement('advcheckbox', 'clustermode', get_string('clustermode', 'cachestore_redis'), '', + cache_helper::is_cluster_available() ? '' : 'disabled'); + $form->addHelpButton('clustermode', 'clustermode', 'cachestore_redis'); + $form->setType('clustermode', PARAM_BOOL); + + $form->addElement('textarea', 'server', get_string('server', 'cachestore_redis'), ['cols' => 6, 'rows' => 10]); $form->setType('server', PARAM_TEXT); $form->addHelpButton('server', 'server', 'cachestore_redis'); $form->addRule('server', get_string('required'), 'required'); diff --git a/cache/stores/redis/lang/en/cachestore_redis.php b/cache/stores/redis/lang/en/cachestore_redis.php index a12779e799ed7..7b170e3e7328d 100644 --- a/cache/stores/redis/lang/en/cachestore_redis.php +++ b/cache/stores/redis/lang/en/cachestore_redis.php @@ -24,13 +24,18 @@ defined('MOODLE_INTERNAL') || die(); +$string['ca_file'] = 'CA file path'; +$string['ca_file_help'] = 'Location of Certificate Authority file on local filesystem'; +$string['clustermode'] = 'Cluster Mode'; +$string['clustermode_help'] = 'Enabling it will run the Redis cluster function, allowing your server to serve multiple servers to handle concurrent requests simultaneously.'; +$string['clustermodeunavailable'] = 'Redis Cluster is currently unavailable. Please ensure that the PHP Redis extension supports Redis Cluster functionality.'; $string['compressor_none'] = 'No compression.'; $string['compressor_php_gzip'] = 'Use gzip compression.'; $string['compressor_php_zstd'] = 'Use Zstandard compression.'; $string['encrypt_connection'] = 'Use TLS encryption.'; $string['encrypt_connection_help'] = 'Use TLS to connect to Redis. Do not use \'tls://\' in the hostname for Redis, use this option instead.'; -$string['ca_file'] = 'CA file path'; -$string['ca_file_help'] = 'Location of Certificate Authority file on local filesystem'; +$string['password'] = 'Password'; +$string['password_help'] = 'This sets the password of the Redis server.'; $string['pluginname'] = 'Redis'; $string['prefix'] = 'Key prefix'; $string['prefix_help'] = 'This prefix is used for all key names on the Redis server. @@ -41,8 +46,8 @@ $string['privacy:metadata:redis:data'] = 'The various data stored in the cache'; $string['serializer_igbinary'] = 'The igbinary serializer.'; $string['serializer_php'] = 'The default PHP serializer.'; -$string['server'] = 'Server'; -$string['server_help'] = 'This sets the hostname, IP address or Unix socket path of the Redis server to use. +$string['server'] = 'Server(s)'; +$string['server_help'] = 'Redis server to use for testing. Some example values: @@ -52,12 +57,20 @@ * 1.2.3.4:1234 - To connect to a Redis server by IP address with a specific port. * unix:///var/redis.sock - To connect to a Redis server using a Unix socket. * /var/redis.sock - To connect to a Redis server using a Unix socket (alternative format). +* If cluster mode is enabled, please specify servers separated by a new line:
+ 172.23.0.11
+ 172.23.0.12
+ 172.23.0.13
+ Refer to the above examples to write a server. -See Accepting Client Connections and Redis PHP clients for more information. -'; -$string['password'] = 'Password'; -$string['password_help'] = 'This sets the password of the Redis server.'; +See Accepting Client Connections and Redis PHP clients for more information.'; $string['task_ttl'] = 'Free up memory used by expired entries in Redis caches'; +$string['test_clustermode'] = 'Cluster Mode'; +$string['test_clustermode_desc'] = 'Enable Test in Redis cluster mode.'; +$string['test_password'] = 'Test server password'; +$string['test_password_desc'] = 'Redis test server password.'; +$string['test_serializer'] = 'Serializer'; +$string['test_serializer_desc'] = 'Serializer to use for testing.'; $string['test_server'] = 'Test server'; $string['test_server_desc'] = 'Redis server to use for testing. @@ -69,17 +82,18 @@ * 1.2.3.4:1234 - To connect to a Redis server by IP address with a specific port. * unix:///var/redis.sock - To connect to a Redis server using a Unix socket. * /var/redis.sock - To connect to a Redis server using a Unix socket (alternative format). +* If cluster mode is enabled, please specify servers separated by a new line:
+ 172.23.0.11
+ 172.23.0.12
+ 172.23.0.13
+ Refer to the above examples to write a server. See Accepting Client Connections and Redis PHP clients for more information.'; -$string['test_password'] = 'Test server password'; -$string['test_password_desc'] = 'Redis test server password.'; -$string['test_serializer'] = 'Serializer'; -$string['test_serializer_desc'] = 'Serializer to use for testing.'; $string['test_ttl'] = 'Testing TTL'; $string['test_ttl_desc'] = 'Run the performance test using a cache that requires TTL (slower sets).'; +$string['usecompressor'] = 'Use compressor'; +$string['usecompressor_help'] = 'Specifies the compressor to use after serializing. It is done at Moodle Cache API level, not at php-redis level.'; $string['useserializer'] = 'Use serializer'; $string['useserializer_help'] = 'Specifies the serializer to use for serializing. The valid serializers are Redis::SERIALIZER_PHP or Redis::SERIALIZER_IGBINARY. The latter is supported only when phpredis is configured with --enable-redis-igbinary option and the igbinary extension is loaded.'; -$string['usecompressor'] = 'Use compressor'; -$string['usecompressor_help'] = 'Specifies the compressor to use after serializing. It is done at Moodle Cache API level, not at php-redis level.'; diff --git a/cache/stores/redis/lib.php b/cache/stores/redis/lib.php index 16a8d25d8f496..07fb02b7b0abe 100644 --- a/cache/stores/redis/lib.php +++ b/cache/stores/redis/lib.php @@ -94,7 +94,7 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_ /** * Connection to Redis for this store. * - * @var Redis + * @var Redis|RedisCluster */ protected $redis; @@ -177,7 +177,10 @@ public static function get_supported_modes(array $configuration = array()) { * @param string $name * @param array $configuration */ - public function __construct($name, array $configuration = array()) { + public function __construct( + $name, + array $configuration = [], + ) { $this->name = $name; if (!array_key_exists('server', $configuration) || empty($configuration['server'])) { @@ -199,75 +202,112 @@ public function __construct($name, array $configuration = array()) { } /** - * Create a new Redis instance and - * connect to the server. + * Create a new Redis or RedisCluster instance and connect to the server. * - * @param array $configuration The server configuration - * @return Redis + * @param array $configuration The redis instance configuration. + * @return Redis|RedisCluster|null */ - protected function new_redis(array $configuration): \Redis { - global $CFG; - - $redis = new Redis(); - - $server = $configuration['server']; + protected function new_redis(array $configuration): Redis|RedisCluster|null { $encrypt = (bool) ($configuration['encryption'] ?? false); + $clustermode = (bool) ($configuration['clustermode'] ?? false); $password = !empty($configuration['password']) ? $configuration['password'] : ''; - $prefix = !empty($configuration['prefix']) ? $configuration['prefix'] : ''; - // Check if it isn't a Unix socket to set default port. - $port = null; - $opts = []; - // Unix sockets can start with / or with unix://. - if ($server[0] === '/' || strpos($server, 'unix://') === 0) { - $port = 0; - } else { - $port = 6379; // No Unix socket so set default port. - if (strpos($server, ':')) { // Check for custom port. - list($server, $port) = explode(':', $server); - } - // We can encrypt if we aren't unix socket. - if ($encrypt) { - $server = 'tls://' . $server; - if (empty($configuration['cafile'])) { - $sslopts = [ - 'verify_peer' => false, - 'verify_peer_name' => false, - ]; + // Set Redis server(s). + $servers = explode("\n", $configuration['server']); + $trimmedservers = []; + // print_r($configuration); + // print_r($servers); + foreach ($servers as $server) { + $server = strtolower(trim($server)); + if (!empty($server)) { + if ($server[0] === '/' || str_starts_with($server, 'unix://')) { + $port = 0; + $trimmedservers[] = $server; } else { - $sslopts = ['cafile' => $configuration['cafile']]; + $port = 6379; // No Unix socket so set default port. + if (strpos($server, ':')) { // Check for custom port. + list($server, $port) = explode(':', $server); + } + if (!$clustermode && $encrypt) { + $server = 'tls://' . $server; + } + $trimmedservers[] = $server.':'.$port; + } + + // We only need the first record for the single redis. + if (!$clustermode) { + break; } - $opts['stream'] = $sslopts; } } + // TLS/SSL Configuration. + $exceptionclass = $clustermode ? 'RedisClusterException' : 'RedisException'; + $opts = []; + if ($encrypt) { + $opts = empty($configuration['cafile']) ? + ['verify_peer' => false, 'verify_peer_name' => false] : + ['cafile' => $configuration['cafile']]; + + // For a single (non-cluster) Redis, the TLS/SSL config must be added to the 'stream' key. + if (!$clustermode) { + $opts['stream'] = $opts; + } + } + // Connect to redis. + $redis = null; + // print_r($trimmedservers); + // exit; try { - if ($redis->connect($server, $port, 1, null, 100, 1, $opts)) { - + // Create a $redis object of a RedisCluster or Redis class. + if ($clustermode) { + $redis = new RedisCluster( + name: null, + seeds: $trimmedservers, + timeout: 1, + read_timeout: 1, + persistent: true, + auth: $password, + context: !empty($opts) ? $opts : null, + ); + } else { + // We only need the first record for the single redis. + list($server, $port) = explode(':', $trimmedservers[0]); + $redis = new Redis(); + $redis->connect( + host: $server, + port: $port, + timeout: 1, + retry_interval: 100, + read_timeout: 1, + context: $opts, + ); if (!empty($password)) { $redis->auth($password); } - // If using compressor, serialisation will be done at cachestore level, not php-redis. - if ($this->compressor == self::COMPRESSOR_NONE) { - $redis->setOption(Redis::OPT_SERIALIZER, $this->serializer); - } - if (!empty($prefix)) { - $redis->setOption(Redis::OPT_PREFIX, $prefix); - } - if ($encrypt && !$redis->ping()) { - /* - * In case of a TLS connection, if phpredis client does not - * communicate immediately with the server the connection hangs. - * See https://github.com/phpredis/phpredis/issues/2332 . - */ - throw new \RedisException("Ping failed"); - } - $this->isready = true; - } else { - $this->isready = false; } - } catch (\RedisException $e) { - debugging("redis $server: $e", DEBUG_NORMAL); + + // In case of a TLS connection, + // if phpredis client does not communicate immediately with the server the connection hangs. + // See https://github.com/phpredis/phpredis/issues/2332. + if ($encrypt && !$redis->ping('Ping')) { + throw new $exceptionclass("Ping failed"); + } + + // If using compressor, serialisation will be done at cachestore level, not php-redis. + if ($this->compressor === self::COMPRESSOR_NONE) { + $redis->setOption(Redis::OPT_SERIALIZER, $this->serializer); + } + + // Set the prefix. + $prefix = !empty($configuration['prefix']) ? $configuration['prefix'] : ''; + if (!empty($prefix)) { + $redis->setOption(Redis::OPT_PREFIX, $prefix); + } + $this->isready = true; + } catch (RedisException | RedisClusterException $e) { + $server = $clustermode ? implode(',', $trimmedservers) : $trimmedservers[0].':'.$port; + debugging("Failed to connect to Redis at {$server}, the error returned was: {$e->getMessage()}"); $this->isready = false; } @@ -277,10 +317,10 @@ protected function new_redis(array $configuration): \Redis { /** * See if we can ping Redis server * - * @param Redis $redis + * @param RedisCluster|Redis $redis * @return bool */ - protected function ping(Redis $redis) { + protected function ping(RedisCluster|Redis $redis): bool { try { if ($redis->ping() === false) { return false; @@ -763,7 +803,7 @@ public function estimate_stored_size($key, $value): int { public function store_total_size(): ?int { try { $details = $this->redis->info('MEMORY'); - } catch (\RedisException $e) { + } catch (RedisException $e) { return null; } if (empty($details['used_memory'])) { @@ -789,6 +829,7 @@ public static function config_get_configuration_array($data) { 'compressor' => $data->compressor, 'encryption' => $data->encryption, 'cafile' => $data->cafile, + 'clustermode' => $data->clustermode, ); } @@ -816,6 +857,9 @@ public static function config_set_edit_form_data(moodleform $editform, array $co if (!empty($config['cafile'])) { $data['cafile'] = $config['cafile']; } + if (!empty($config['clustermode'])) { + $data['clustermode'] = $config['clustermode']; + } $editform->set_data($data); } @@ -847,6 +891,9 @@ public static function initialise_test_instance(cache_definition $definition) { if (!empty($config->test_cafile)) { $configuration['cafile'] = $config->test_cafile; } + if (!empty($config->test_clustermode)) { + $configuration['clustermode'] = $config->test_clustermode; + } // Make it possible to test TTL performance by hacking a copy of the cache definition. if (!empty($config->test_ttl)) { $definition = clone $definition; diff --git a/cache/stores/redis/settings.php b/cache/stores/redis/settings.php index d7e7dfc7c3ff1..7e2136e9acfd1 100644 --- a/cache/stores/redis/settings.php +++ b/cache/stores/redis/settings.php @@ -25,15 +25,26 @@ defined('MOODLE_INTERNAL') || die(); $settings->add( - new admin_setting_configtext( - 'cachestore_redis/test_server', - get_string('test_server', 'cachestore_redis'), - get_string('test_server_desc', 'cachestore_redis'), - '', - PARAM_TEXT, - 16 + new admin_setting_configcheckbox( + name: 'cachestore_redis/test_clustermode', + visiblename: get_string('clustermode', 'cachestore_redis'), + description: cache_helper::is_cluster_available() ? + get_string('clustermode_help', 'cachestore_redis') : + get_string('clustermodeunavailable', 'cachestore_redis'), + defaultsetting: 0, ) ); + +$settings->add( + new admin_setting_configtextarea( + name: 'cachestore_redis/test_server', + visiblename: get_string('test_server', 'cachestore_redis'), + description: get_string('test_server_desc', 'cachestore_redis'), + defaultsetting: '', + paramtype: PARAM_TEXT, + ) +); + $settings->add(new admin_setting_configcheckbox( 'cachestore_redis/test_encryption', get_string('encrypt_connection', 'cachestore_redis'), diff --git a/cache/stores/redis/tests/cachestore_cluster_redis_test.php b/cache/stores/redis/tests/cachestore_cluster_redis_test.php new file mode 100644 index 0000000000000..604b122bea514 --- /dev/null +++ b/cache/stores/redis/tests/cachestore_cluster_redis_test.php @@ -0,0 +1,193 @@ +. + +namespace cachestore_redis; + +use cache_definition; +use cache_store; +use cachestore_redis; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/../../../tests/fixtures/stores.php'); +require_once(__DIR__ . '/../lib.php'); + +/** + * Redis cluster test. + * + * If you wish to use these unit tests all you need to do is add the following definition to + * your config.php file: + * + * define('TEST_CACHESTORE_REDIS_SERVERSCLUSTER', 'localhost:7000,localhost:7001'); + * define('TEST_CACHESTORE_REDIS_ENCRYPTCLUSTER', true); + * define('TEST_CACHESTORE_REDIS_AUTHCLUSTER', 'foobared'); + * define('TEST_CACHESTORE_REDIS_CASCLUSTER', '/cafile/dir/ca.crt'); + * + * @package cachestore_redis + * @author Daniel Thee Roperto + * @copyright 2017 Catalyst IT Australia {@link http://www.catalyst-au.net} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + * @coversDefaultClass \cachestore_redis + */ +class cachestore_cluster_redis_test extends \advanced_testcase { + /** + * Create a cache store for testing the Redis cluster. + * + * @param string|null $seed The redis cluster servers. + * @return cachestore_redis The created cache store instance. + */ + public function create_store(?string $seed = null): cachestore_redis { + global $DB; + + $definition = cache_definition::load_adhoc( + mode: cache_store::MODE_APPLICATION, + component: 'cachestore_redis', + area: 'phpunit_test', + ); + + $servers = $seed ?? str_replace(",", "\n", TEST_CACHESTORE_REDIS_SERVERSCLUSTER); + + $config = [ + 'server' => $servers, + 'prefix' => $DB->get_prefix(), + 'clustermode' => true, + ]; + + if (defined('TEST_CACHESTORE_REDIS_ENCRYPTCLUSTER') && TEST_CACHESTORE_REDIS_ENCRYPTCLUSTER === true) { + $config['encryption'] = true; + } + if (defined('TEST_CACHESTORE_REDIS_AUTHCLUSTER') && TEST_CACHESTORE_REDIS_AUTHCLUSTER) { + $config['password'] = TEST_CACHESTORE_REDIS_AUTHCLUSTER; + } + if (defined('TEST_CACHESTORE_REDIS_CASCLUSTER') && TEST_CACHESTORE_REDIS_CASCLUSTER) { + $config['cafile'] = TEST_CACHESTORE_REDIS_CASCLUSTER; + } + + $store = new cachestore_redis('TestCluster', $config); + $store->initialise($definition); + $store->purge(); + + return $store; + } + + /** + * Set up the test environment. + */ + public function setUp(): void { + if (!cachestore_redis::are_requirements_met()) { + $this->markTestSkipped('Could not test cachestore_redis with cluster, missing requirements.'); + } else if (!\cache_helper::is_cluster_available()) { + $this->markTestSkipped('Could not test cachestore_redis with cluster, class RedisCluster is not available.'); + } else if (!defined('TEST_CACHESTORE_REDIS_SERVERSCLUSTER')) { + $this->markTestSkipped('Could not test cachestore_redis with cluster, missing configuration. ' . + "Example: define('TEST_CACHESTORE_REDIS_SERVERSCLUSTER', " . + "'localhost:7000,localhost:7001,localhost:7002');"); + } + } + + /** + * Test if the cache store can be created successfully. + * + * @covers ::is_ready + */ + public function test_it_can_create(): void { + $store = $this->create_store(); + $this->assertNotNull($store); + $this->assertTrue($store->is_ready()); + } + + /** + * Test if the cache store trims server names correctly. + * + * @covers ::new_redis + */ + public function test_it_trims_server_names(): void { + // Add a time before and spaces after the first server. Also adds a blank line before second server. + $servers = explode(',', TEST_CACHESTORE_REDIS_SERVERSCLUSTER); + $servers[0] = "\t" . $servers[0] . " \n"; + $servers = implode("\n", $servers); + + $store = $this->create_store($servers); + + $this->assertTrue($store->is_ready()); + } + + /** + * Test if the cache store can successfully set and get a value. + * + * @covers ::set + * @covers ::get + */ + public function test_it_can_setget(): void { + $store = $this->create_store(); + $store->set('the key', 'the value'); + $actual = $store->get('the key'); + + $this->assertSame('the value', $actual); + } + + /** + * Test if the cache store can successfully set and get multiple values. + * + * @covers ::set_many + * @covers ::get_many + */ + public function test_it_can_setget_many(): void { + $store = $this->create_store(); + + // Create values. + $values = []; + $keys = []; + $expected = []; + for ($i = 0; $i < 10; $i++) { + $key = "getkey_{$i}"; + $value = "getvalue #{$i}"; + $keys[] = $key; + $values[] = [ + 'key' => $key, + 'value' => $value, + ]; + $expected[$key] = $value; + } + + $store->set_many($values); + $actual = $store->get_many($keys); + $this->assertSame($expected, $actual); + } + + /** + * Test if the cache store is marked as not ready if it fails to connect. + * + * @covers ::is_ready + */ + public function test_it_is_marked_not_ready_if_failed_to_connect(): void { + global $DB; + + $config = [ + 'server' => "abc:123", + 'prefix' => $DB->get_prefix(), + 'clustermode' => true, + ]; + $store = new cachestore_redis('TestCluster', $config); + $debugging = $this->getDebuggingMessages(); + // Failed to connect should show a debugging message. + $this->assertCount(1, \phpunit_util::get_debugging_messages() ); + $this->assertStringContainsString('Couldn\'t map cluster keyspace using any provided seed', $debugging[0]->message); + $this->resetDebugging(); + $this->assertFalse($store->is_ready()); + } +} diff --git a/cache/stores/redis/tests/cachestore_redis_test.php b/cache/stores/redis/tests/cachestore_redis_test.php new file mode 100644 index 0000000000000..af3fac0a93891 --- /dev/null +++ b/cache/stores/redis/tests/cachestore_redis_test.php @@ -0,0 +1,154 @@ +. + +namespace cachestore_redis; + +use cache_definition; +use cache_store; +use cachestore_redis; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__.'/../../../tests/fixtures/stores.php'); +require_once(__DIR__.'/../lib.php'); + +/** + * Redis cache store test. + * + * If you wish to use these unit tests all you need to do is add the following definition to + * your config.php file. + * + * define('TEST_CACHESTORE_REDIS_TESTSERVERS', '127.0.0.1'); + * + * @package cachestore_redis + * @copyright (c) 2015 Moodlerooms Inc. (http://www.moodlerooms.com) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + * @coversDefaultClass \cachestore_redis + */ +class cachestore_redis_test extends \cachestore_tests { + /** @var cachestore_redis $store Redis Cache Store. */ + protected $store; + + /** + * Returns the class name. + * + * @return string + */ + protected function get_class_name(): string { + return 'cachestore_redis'; + } + + public function setUp(): void { + if (!cachestore_redis::are_requirements_met() || !defined('TEST_CACHESTORE_REDIS_TESTSERVERS')) { + $this->markTestSkipped('Could not test cachestore_redis. Requirements are not met.'); + } + parent::setUp(); + } + + protected function tearDown(): void { + parent::tearDown(); + + if ($this->store instanceof cachestore_redis) { + $this->store->purge(); + } + } + + /** + * Creates the required cachestore for the tests to run against Redis. + * + * @return cachestore_redis + */ + protected function create_cachestore_redis(): cachestore_redis { + $definition = cache_definition::load_adhoc(cache_store::MODE_APPLICATION, 'cachestore_redis', 'phpunit_test'); + $store = new cachestore_redis('Test', cachestore_redis::unit_test_configuration()); + $store->initialise($definition); + $this->store = $store; + $store->purge(); + return $store; + } + + /** + * Test methods for various operations (set and has) in the cachestore_redis class. + * + * @covers ::set + * @covers ::has + */ + public function test_has(): void { + $store = $this->create_cachestore_redis(); + + $this->assertTrue($store->set('foo', 'bar')); + $this->assertTrue($store->has('foo')); + $this->assertFalse($store->has('bat')); + } + + /** + * Test methods for the 'has_any' operation in the cachestore_redis class. + * + * @covers ::set + * @covers ::has_any + */ + public function test_has_any(): void { + $store = $this->create_cachestore_redis(); + + $this->assertTrue($store->set('foo', 'bar')); + $this->assertTrue($store->has_any(['bat', 'foo'])); + $this->assertFalse($store->has_any(['bat', 'baz'])); + } + + /** + * PHPUnit test methods for the 'has_all' operation in the cachestore_redis class. + * + * @covers ::set + * @covers ::has_all + */ + public function test_has_all(): void { + $store = $this->create_cachestore_redis(); + + $this->assertTrue($store->set('foo', 'bar')); + $this->assertTrue($store->set('bat', 'baz')); + $this->assertTrue($store->has_all(['foo', 'bat'])); + $this->assertFalse($store->has_all(['foo', 'bat', 'this'])); + } + + /** + * Test methods for the 'lock' operations in the cachestore_redis class. + * + * @covers ::acquire_lock + * @covers ::check_lock_state + * @covers ::release_lock + */ + public function test_lock(): void { + $store = $this->create_cachestore_redis(); + + $this->assertTrue($store->acquire_lock('lock', '123')); + $this->assertTrue($store->check_lock_state('lock', '123')); + $this->assertFalse($store->check_lock_state('lock', '321')); + $this->assertNull($store->check_lock_state('notalock', '123')); + $this->assertFalse($store->release_lock('lock', '321')); + $this->assertTrue($store->release_lock('lock', '123')); + } + + /** + * Test method to check if the cachestore_redis instance is ready after connecting. + * + * @covers ::is_ready + */ + public function test_it_is_ready_after_connecting(): void { + $store = $this->create_cachestore_redis(); + $this::assertTrue($store->is_ready()); + } +} diff --git a/cohort/tests/lib_test.php b/cohort/tests/lib_test.php index 8bf48250d1cb3..639a48be4bd2c 100644 --- a/cohort/tests/lib_test.php +++ b/cohort/tests/lib_test.php @@ -711,7 +711,7 @@ public function test_get_functions_return_custom_fields() { // Test cohort_get_cohort. $result = cohort_get_cohort($cohort1->id, $coursectx, true); - $this->assertObjectHasAttribute('customfields', $result); + $this->assertObjectHasProperty('customfields', $result); $this->assertCount(1, $result->customfields); $field = reset($result->customfields); $this->assertInstanceOf(data_controller::class, $field); @@ -720,14 +720,14 @@ public function test_get_functions_return_custom_fields() { // Test custom fields are not returned if not needed. $result = cohort_get_cohort($cohort1->id, $coursectx); - $this->assertObjectNotHasAttribute('customfields', $result); + $this->assertObjectNotHasProperty('customfields', $result); // Test cohort_get_cohorts. $result = cohort_get_cohorts(\context_system::instance()->id, 0, 25, '', true); $this->assertEquals(2, $result['totalcohorts']); $this->assertEquals(2, $result['allcohorts']); foreach ($result['cohorts'] as $cohort) { - $this->assertObjectHasAttribute('customfields', $cohort); + $this->assertObjectHasProperty('customfields', $cohort); $this->assertCount(1, $cohort->customfields); $field = reset($cohort->customfields); $this->assertInstanceOf(data_controller::class, $field); @@ -745,7 +745,7 @@ public function test_get_functions_return_custom_fields() { $this->assertEquals(2, $result['totalcohorts']); $this->assertEquals(2, $result['allcohorts']); foreach ($result['cohorts'] as $cohort) { - $this->assertObjectNotHasAttribute('customfields', $cohort); + $this->assertObjectNotHasProperty('customfields', $cohort); } // Test test_cohort_get_all_cohorts. @@ -753,7 +753,7 @@ public function test_get_functions_return_custom_fields() { $this->assertEquals(2, $result['totalcohorts']); $this->assertEquals(2, $result['allcohorts']); foreach ($result['cohorts'] as $cohort) { - $this->assertObjectHasAttribute('customfields', $cohort); + $this->assertObjectHasProperty('customfields', $cohort); $this->assertCount(1, $cohort->customfields); $field = reset($cohort->customfields); $this->assertInstanceOf(data_controller::class, $field); @@ -771,14 +771,14 @@ public function test_get_functions_return_custom_fields() { $this->assertEquals(2, $result['totalcohorts']); $this->assertEquals(2, $result['allcohorts']); foreach ($result['cohorts'] as $cohort) { - $this->assertObjectNotHasAttribute('customfields', $cohort); + $this->assertObjectNotHasProperty('customfields', $cohort); } // Test cohort_get_available_cohorts. $result = cohort_get_available_cohorts($coursectx, COHORT_ALL, 0, 25, '', true); $this->assertCount(2, $result); foreach ($result as $cohort) { - $this->assertObjectHasAttribute('customfields', $cohort); + $this->assertObjectHasProperty('customfields', $cohort); $this->assertCount(1, $cohort->customfields); $field = reset($cohort->customfields); $this->assertInstanceOf(data_controller::class, $field); @@ -795,7 +795,7 @@ public function test_get_functions_return_custom_fields() { $result = cohort_get_available_cohorts($coursectx, COHORT_ALL, 0, 25, ''); $this->assertCount(2, $result); foreach ($result as $cohort) { - $this->assertObjectNotHasAttribute('customfields', $cohort); + $this->assertObjectNotHasProperty('customfields', $cohort); } // Test cohort_get_user_cohorts. @@ -805,7 +805,7 @@ public function test_get_functions_return_custom_fields() { $result = cohort_get_user_cohorts($user->id, true); $this->assertCount(2, $result); foreach ($result as $cohort) { - $this->assertObjectHasAttribute('customfields', $cohort); + $this->assertObjectHasProperty('customfields', $cohort); $this->assertCount(1, $cohort->customfields); $field = reset($cohort->customfields); $this->assertInstanceOf(data_controller::class, $field); @@ -822,7 +822,7 @@ public function test_get_functions_return_custom_fields() { $result = cohort_get_user_cohorts($user->id); $this->assertCount(2, $result); foreach ($result as $cohort) { - $this->assertObjectNotHasAttribute('customfields', $cohort); + $this->assertObjectNotHasProperty('customfields', $cohort); } } diff --git a/communication/classes/api.php b/communication/classes/api.php index 8dee56b09547f..8e02e9a15cf95 100644 --- a/communication/classes/api.php +++ b/communication/classes/api.php @@ -118,9 +118,9 @@ public function reload(): void { /** * Return the underlying communication processor object. * - * @return processor + * @return ?processor */ - public function get_processor(): processor { + public function get_processor(): ?processor { return $this->communication; } @@ -405,6 +405,114 @@ public function get_provider(): string { return $this->communication->get_provider(); } + /** + * Configure the room and membership by provider selected for the communication instance. + * + * This method will add a task to the queue to configure the room and membership by comparing the change of provider. + * There are some major cases to consider for this method to allow minimum duplication when this api is used. + * Some of the major cases are: + * 1. If the communication instance is not created at all, then create it and add members. + * 2. If the current provider is none and the new provider is also none, then nothing to do. + * 3. If the current and existing provider is the same, don't need to do anything. + * 4. If provider set to none, remove all the members. + * 5. If previous provider was not none and current provider is not none, but a different provider, remove members and add + * for the new one. + * 6. If previous provider was none and current provider is not none, don't need to remove, just + * update the selected provider and add users to that provider. Do not queue the task to add members to room as the room + * might not have created yet. The add room task adds the task to add members to room anyway. + * 7. If it's a new provider, never used/created, now create the room after considering all these cases for a new provider. + * + * @param string $provider The provider name + * @param \stdClass $instance The instance object + * @param string $communicationroomname The communication room name + * @param array $users The user ids to add to the room + * @param null|\stored_file $instanceimage The stored file for the avatar + */ + public function configure_room_and_membership_by_provider( + string $provider, + stdClass $instance, + string $communicationroomname, + array $users, + ?\stored_file $instanceimage = null, + ): void { + // If the current provider is inactive and the new provider is also none, then nothing to do. + if ( + $this->communication !== null && + $this->communication->get_provider_status() === processor::PROVIDER_INACTIVE && + $provider === processor::PROVIDER_NONE + ) { + return; + } + + // If provider set to none, remove all the members. + if ( + $this->communication !== null && + $this->communication->get_provider_status() === processor::PROVIDER_ACTIVE && + $provider === processor::PROVIDER_NONE + ) { + $this->remove_all_members_from_room(); + $this->update_room( + active: processor::PROVIDER_INACTIVE, + communicationroomname: $communicationroomname, + avatar: $instanceimage, + instance: $instance, + ); + return; + } + + if ( + // If previous provider was active and not none and current provider is not none, but a different provider, + // remove members and de-activate the previous provider. + $this->communication !== null && + $this->communication->get_provider_status() === processor::PROVIDER_ACTIVE && + $provider !== $this->get_provider() + ) { + $this->remove_all_members_from_room(); + // Now deactivate the previous provider. + $this->update_room( + active: processor::PROVIDER_INACTIVE, + communicationroomname: $communicationroomname, + avatar: $instanceimage, + instance: $instance, + ); + } + + // Now re-init the constructor for the new provider. + $this->__construct( + context: $this->context, + component: $this->component, + instancetype: $this->instancetype, + instanceid: $this->instanceid, + provider: $provider, + ); + + // If it's a new provider, never used/created, now create the room. + if ($this->communication === null) { + $this->create_and_configure_room( + communicationroomname: $communicationroomname, + avatar: $instanceimage, + instance: $instance, + ); + $queue = false; + } else { + // Otherwise update the room. + $this->update_room( + active: processor::PROVIDER_ACTIVE, + communicationroomname: $communicationroomname, + avatar: $instanceimage, + instance: $instance, + ); + $queue = true; + } + + // Now add the members. + $this->add_members_to_room( + userids: $users, + queue: $queue, + ); + + } + /** * Create a communication ad-hoc task for create operation. * This method will add a task to the queue to create the room. @@ -412,16 +520,17 @@ public function get_provider(): string { * @param string $communicationroomname The communication room name * @param null|\stored_file $avatar The stored file for the avatar * @param \stdClass|null $instance The actual instance object + * @param bool $queue Whether to queue the task or not */ public function create_and_configure_room( string $communicationroomname, ?\stored_file $avatar = null, ?\stdClass $instance = null, + bool $queue = true, ): void { if ($this->provider === processor::PROVIDER_NONE || $this->provider === '') { return; } - // Create communication record. $this->communication = processor::create_instance( context: $this->context, @@ -442,6 +551,11 @@ public function create_and_configure_room( $this->set_avatar($avatar); } + // Nothing else to do if the queue is false. + if (!$queue) { + return; + } + // Add ad-hoc task to create the provider room. create_and_configure_room_task::queue( $this->communication, @@ -456,13 +570,19 @@ public function create_and_configure_room( * @param null|string $communicationroomname The communication room name * @param null|\stored_file $avatar The stored file for the avatar * @param \stdClass|null $instance The actual instance object + * @param bool $queue Whether to queue the task or not */ public function update_room( ?int $active = null, ?string $communicationroomname = null, ?\stored_file $avatar = null, ?\stdClass $instance = null, + bool $queue = true, ): void { + if (!$this->communication) { + return; + } + // If the provider is none, we don't need to do anything from room point of view. if ($this->communication->get_provider() === processor::PROVIDER_NONE) { return; @@ -503,6 +623,11 @@ public function update_room( // If the value is `null`, then unset the avatar. $this->set_avatar($avatar); + // Nothing else to do if the queue is false. + if (!$queue) { + return; + } + // Always queue a room update, even if none of the above standard fields have changed. // It is possible for providers to have custom fields that have been updated. update_room_task::queue( @@ -613,6 +738,35 @@ public function remove_members_from_room(array $userids, bool $queue = true): vo } } + /** + * Remove all users from the room. + * + * @param bool $queue Whether to queue the task or not + */ + public function remove_all_members_from_room(bool $queue = true): void { + // No communication object? something not done right. + if (!$this->communication) { + return; + } + + if ($this->communication->get_provider() === processor::PROVIDER_NONE) { + return; + } + + // This provider does not manage users? No action required. + if (!$this->communication->supports_user_features()) { + return; + } + + $this->communication->add_delete_user_flag($this->communication->get_all_userids_for_instance()); + + if ($queue) { + remove_members_from_room::queue( + $this->communication + ); + } + } + /** * Display the communication room status notification. */ @@ -626,20 +780,23 @@ public function show_communication_room_status_notification(): void { return; } - $roomstatus = $this->get_communication_room_url() ? 'ready' : 'pending'; + $roomstatus = $this->get_communication_room_url() + ? constants::COMMUNICATION_STATUS_READY + : constants::COMMUNICATION_STATUS_PENDING; $pluginname = get_string('pluginname', $this->get_provider()); $message = get_string('communicationroom' . $roomstatus, 'communication', $pluginname); + // We only show the ready notification once per user. + // We check this with a custom user preference. + $roomreadypreference = "{$this->component}_{$this->instancetype}_{$this->instanceid}_room_ready"; + switch ($roomstatus) { - case 'pending': + case constants::COMMUNICATION_STATUS_PENDING: \core\notification::add($message, \core\notification::INFO); + unset_user_preference($roomreadypreference); break; - case 'ready': - // We only show the ready notification once per user. - // We check this with a custom user preference. - $roomreadypreference = "{$this->component}_{$this->instancetype}_{$this->instanceid}_room_ready"; - + case constants::COMMUNICATION_STATUS_READY: if (empty(get_user_preferences($roomreadypreference))) { \core\notification::add($message, \core\notification::SUCCESS); set_user_preference($roomreadypreference, true); diff --git a/communication/classes/constants.php b/communication/classes/constants.php new file mode 100644 index 0000000000000..323ad6c8c6e7e --- /dev/null +++ b/communication/classes/constants.php @@ -0,0 +1,45 @@ +. + +namespace core_communication; + +/** + * Constants for communication api. + * + * @package core_communication + * @copyright 2024 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class constants { + + /** @var string GROUP_COMMUNICATION_INSTANCETYPE The group communication instance type. */ + public const GROUP_COMMUNICATION_INSTANCETYPE = 'groupcommunication'; + + /** @var string GROUP_COMMUNICATION_COMPONENT The group communication component. */ + public const GROUP_COMMUNICATION_COMPONENT = 'core_group'; + + /** @var string COURSE_COMMUNICATION_INSTANCETYPE The course communication instance type. */ + public const COURSE_COMMUNICATION_INSTANCETYPE = 'coursecommunication'; + + /** @var string COURSE_COMMUNICATION_COMPONENT The course communication component. */ + public const COURSE_COMMUNICATION_COMPONENT = 'core_course'; + + /** @var string COMMUNICATION_STATUS_PENDING The communication status pending. */ + public const COMMUNICATION_STATUS_PENDING = 'pending'; + + /** @var string COMMUNICATION_STATUS_READY The communication status sent. */ + public const COMMUNICATION_STATUS_READY = 'ready'; +} diff --git a/communication/classes/helper.php b/communication/classes/helper.php new file mode 100644 index 0000000000000..f1ea4a990502f --- /dev/null +++ b/communication/classes/helper.php @@ -0,0 +1,547 @@ +. + +namespace core_communication; + +use context; +use stdClass; + +/** + * Helper method for communication. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class helper { + + /** + * Load the communication instance for group id. + * + * @param int $groupid The group id + * @param context $context The context, to make sure any instance using group can load the communication instance + * @return api The communication instance. + */ + public static function load_by_group(int $groupid, context $context): api { + return \core_communication\api::load_by_instance( + context: $context, + component: constants::GROUP_COMMUNICATION_COMPONENT, + instancetype: constants::GROUP_COMMUNICATION_INSTANCETYPE, + instanceid: $groupid, + ); + } + + /** + * Load the communication instance for course id. + * + * @param int $courseid The course id + * @param \context $context The context + * @param string|null $provider The provider name + * @return api The communication instance + */ + public static function load_by_course( + int $courseid, + \context $context, + ?string $provider = null, + ): api { + return \core_communication\api::load_by_instance( + context: $context, + component: constants::COURSE_COMMUNICATION_COMPONENT, + instancetype: constants::COURSE_COMMUNICATION_INSTANCETYPE, + instanceid: $courseid, + provider: $provider, + ); + } + + /** + * Communication api call to create room for a group if course has group mode enabled. + * + * @param int $courseid The course id. + * @return stdClass + */ + public static function get_course(int $courseid): stdClass { + global $DB; + return $DB->get_record( + table: 'course', + conditions: ['id' => $courseid], + strictness: MUST_EXIST, + ); + } + + /** + * Is group mode enabled for the course. + * + * @param stdClass $course The course object + */ + public static function is_group_mode_enabled_for_course(stdClass $course): bool { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return false; + } + + $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; + return (int)$groupmode !== NOGROUPS; + } + + /** + * Helper to update room membership according to action passed. + * This method will help reduce a large amount of duplications of code in different places in core. + * + * @param \stdClass $course The course object. + * @param array $userids The user ids to add to the communication room. + * @param string $memberaction The action to perform on the communication room. + */ + public static function update_course_communication_room_membership( + \stdClass $course, + array $userids, + string $memberaction, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + // Validate communication api action. + $roomuserprovider = new \ReflectionClass(room_user_provider::class); + if (!$roomuserprovider->hasMethod($memberaction)) { + throw new \coding_exception('Invalid action provided.'); + } + + // Get the group mode for this course. + $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; + $coursecontext = \context_course::instance(courseid: $course->id); + + // If group mode is not set, then just handle the update normally for these users. + if ((int)$groupmode === NOGROUPS) { + $communication = self::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $communication->$memberaction($userids); + } else { + // If group mode is set, then handle the update for these users with repect to the group they are in. + $coursegroups = groups_get_all_groups(courseid: $course->id); + + $usershandled = []; + + // Filter all the users that have the capability to access all groups. + $allaccessgroupusers = self::get_users_has_access_to_all_groups( + userids: $userids, + courseid: $course->id, + ); + + foreach ($coursegroups as $coursegroup) { + + // Get all group members. + $groupmembers = groups_get_members(groupid: $coursegroup->id, fields: 'u.id'); + $groupmembers = array_column($groupmembers, 'id'); + + // Find the common user ids between the group members and incoming userids. + $groupuserstohandle = array_intersect( + $groupmembers, + $userids, + ); + + // Add users who have the capability to access this group (and haven't been added already). + foreach ($allaccessgroupusers as $allaccessgroupuser) { + if (!in_array($allaccessgroupuser, $groupuserstohandle, true)) { + $groupuserstohandle[] = $allaccessgroupuser; + } + } + + // Keep track of the users we have handled already. + $usershandled = array_merge($usershandled, $groupuserstohandle); + + // Let's check if we need to add/remove members from room because of a role change. + // First, get all the instance users for this group. + $communication = self::load_by_group( + groupid: $coursegroup->id, + context: $coursecontext, + ); + $instanceusers = $communication->get_processor()->get_all_userids_for_instance(); + + // The difference between the instance users and the group members are the ones we want to check. + $roomuserstocheck = array_diff( + $instanceusers, + $groupmembers + ); + + if (!empty($roomuserstocheck)) { + // Check if they still have the capability to keep their access in the room. + $userslostcaps = array_diff( + $roomuserstocheck, + self::get_users_has_access_to_all_groups( + userids: $roomuserstocheck, + courseid: $course->id, + ), + ); + // Remove users who no longer have the capability. + if (!empty($userslostcaps)) { + $communication->remove_members_from_room(userids: $userslostcaps); + } + } + + // Check if we have to add any room members who have gained the capability. + $usersgainedcaps = array_diff( + $allaccessgroupusers, + $instanceusers, + ); + + // If we have users, add them to the room. + if (!empty($usersgainedcaps)) { + $communication->add_members_to_room(userids: $usersgainedcaps); + } + + // Finally, trigger the update task for the users who need to be handled. + $communication->$memberaction($groupuserstohandle); + } + + // If the user was not in any group, but an update/remove action was requested for the user, + // then the user must have had a role with the capablity, but made a regular user. + $usersnothandled = array_diff($userids, $usershandled); + + // These users are not handled and not in any group, so logically these users lost their permission to stay in the room. + foreach ($coursegroups as $coursegroup) { + $communication = self::load_by_group( + groupid: $coursegroup->id, + context: $coursecontext, + ); + $communication->remove_members_from_room(userids: $usersnothandled); + } + } + } + + /** + * Get users with the capability to access all groups. + * + * @param array $userids user ids to check the permission + * @param int $courseid course id + * @return array of userids + */ + public static function get_users_has_access_to_all_groups( + array $userids, + int $courseid + ): array { + $allgroupsusers = []; + $context = \context_course::instance(courseid: $courseid); + + foreach ($userids as $userid) { + if ( + has_capability( + capability: 'moodle/site:accessallgroups', + context: $context, + user: $userid, + ) + ) { + $allgroupsusers[] = $userid; + } + } + + return $allgroupsusers; + } + + /** + * Get the course communication url according to course setup. + * + * @param stdClass $course The course object. + * @return string The communication room url. + */ + public static function get_course_communication_url(stdClass $course): string { + // If it's called from site context, then just return. + if ($course->id === SITEID) { + return ''; + } + + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return ''; + } + + $url = ''; + // Get the group mode for this course. + $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; + $coursecontext = \context_course::instance(courseid: $course->id); + + // If group mode is not set then just handle the course communication for these users. + if ((int)$groupmode === NOGROUPS) { + $communication = self::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $url = $communication->get_communication_room_url(); + } else { + // If group mode is set then handle the group communication rooms for these users. + $coursegroups = groups_get_all_groups(courseid: $course->id); + $numberofgroups = count($coursegroups); + + // If no groups available, nothing to show. + if ($numberofgroups === 0) { + return ''; + } + + $readygroups = []; + + foreach ($coursegroups as $coursegroup) { + $communication = self::load_by_group( + groupid: $coursegroup->id, + context: $coursecontext, + ); + $roomstatus = $communication->get_communication_room_url() ? 'ready' : 'pending'; + if ($roomstatus === 'ready') { + $readygroups[$communication->get_processor()->get_id()] = $communication->get_communication_room_url(); + } + } + if (!empty($readygroups)) { + $highestkey = max(array_keys($readygroups)); + $url = $readygroups[$highestkey]; + } + } + + return empty($url) ? '' : $url; + } + + /** + * Get the enrolled users for course. + * + * @param stdClass $course The course object. + * @return array + */ + public static function get_enrolled_users_for_course(stdClass $course): array { + global $CFG; + require_once($CFG->libdir . '/enrollib.php'); + return array_column( + enrol_get_course_users(courseid: $course->id), + 'id', + ); + } + + /** + * Get the course communication status notification for course. + * + * @param \stdClass $course The course object. + */ + public static function get_course_communication_status_notification(\stdClass $course): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + // Get the group mode for this course. + $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; + $coursecontext = \context_course::instance(courseid: $course->id); + + // If group mode is not set then just handle the course communication for these users. + if ((int)$groupmode === NOGROUPS) { + $communication = self::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $communication->show_communication_room_status_notification(); + } else { + // If group mode is set then handle the group communication rooms for these users. + $coursegroups = groups_get_all_groups(courseid: $course->id); + $numberofgroups = count($coursegroups); + + // If no groups available, nothing to show. + if ($numberofgroups === 0) { + return; + } + + $numberofreadygroups = 0; + + foreach ($coursegroups as $coursegroup) { + $communication = self::load_by_group( + groupid: $coursegroup->id, + context: $coursecontext, + ); + $roomstatus = $communication->get_communication_room_url() ? 'ready' : 'pending'; + switch ($roomstatus) { + case 'ready': + $numberofreadygroups ++; + break; + case 'pending': + $pendincommunicationobject = $communication; + break; + } + } + + if ($numberofgroups === $numberofreadygroups) { + $communication->show_communication_room_status_notification(); + } else { + $pendincommunicationobject->show_communication_room_status_notification(); + } + } + } + + /** + * Update course communication according to course data. + * Course can have course or group rooms. Group mode enabling will create rooms for groups. + * + * @param stdClass $course The course data + * @param bool $changesincoursecat Whether the course moved to a different category + */ + public static function update_course_communication_instance( + stdClass $course, + bool $changesincoursecat + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + // Check if provider is selected. + $provider = $course->selectedcommunication ?? null; + // If the course moved to hidden category, set provider to none. + if ($changesincoursecat && empty($course->visible)) { + $provider = processor::PROVIDER_NONE; + } + + // Get the course context. + $coursecontext = \context_course::instance(courseid: $course->id); + // Get the course image. + $courseimage = course_get_courseimage(course: $course); + // Get the course communication instance. + $coursecommunication = self::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + + // Attempt to get the communication provider if it wasn't provided in the data. + if (empty($provider)) { + $provider = $coursecommunication->get_provider(); + } + + // This nasty logic is here because of hide course doesn't pass anything in the data object. + if (!empty($course->communicationroomname)) { + $coursecommunicationroomname = $course->communicationroomname; + } else { + $coursecommunicationroomname = $course->fullname ?? get_course($course->id)->fullname; + } + + // List of enrolled users for course communication. + $enrolledusers = self::get_enrolled_users_for_course(course: $course); + + // Check for group mode, we will have to get the course data again as the group info is not always in the object. + $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; + + // If group mode is disabled, get the communication information for creating room for a course. + if ((int)$groupmode === NOGROUPS) { + // Remove all the members from active group rooms if there is any. + $coursegroups = groups_get_all_groups(courseid: $course->id); + foreach ($coursegroups as $coursegroup) { + $communication = self::load_by_group( + groupid: $coursegroup->id, + context: $coursecontext, + ); + // Remove the members from the group room. + $communication->remove_all_members_from_room(); + // Now delete the group room. + $communication->update_room(active: processor::PROVIDER_INACTIVE); + } + + // Now create/update the course room. + $communication = self::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $communication->configure_room_and_membership_by_provider( + provider: $provider, + instance: $course, + communicationroomname: $coursecommunicationroomname, + users: $enrolledusers, + instanceimage: $courseimage, + ); + } else { + // Update the group communication instances. + self::update_group_communication_instances_for_course( + course: $course, + provider: $provider, + ); + + // Remove all the members for the course room if instance available. + $communication = self::load_by_course( + courseid: $course->id, + context: $coursecontext, + provider: $provider === processor::PROVIDER_NONE ? null : $provider, + ); + + if ($communication->get_processor() === null) { + // If a course communication instance is not created, create one. + $communication->create_and_configure_room( + communicationroomname: $coursecommunicationroomname, + avatar: $courseimage, + instance: $course, + queue: false, + ); + } else { + $communication->remove_all_members_from_room(); + // Now update the course communication instance with the latest changes. + // We are not making room for this instance as it is a group mode enabled course. + // If provider is none, then we will make the room inactive, otherwise always active in group mode. + $communication->update_room( + active: $provider === processor::PROVIDER_NONE ? processor::PROVIDER_INACTIVE : processor::PROVIDER_ACTIVE, + communicationroomname: $coursecommunicationroomname, + avatar: $courseimage, + instance: $course, + queue: false, + ); + } + } + } + + /** + * Update the group communication instances. + * + * @param stdClass $course The course object. + * @param string $provider The provider name. + */ + public static function update_group_communication_instances_for_course( + stdClass $course, + string $provider, + ): void { + $coursegroups = groups_get_all_groups(courseid: $course->id); + $coursecontext = \context_course::instance(courseid: $course->id); + $allaccessgroupusers = self::get_users_has_access_to_all_groups( + userids: self::get_enrolled_users_for_course(course: $course), + courseid: $course->id, + ); + + foreach ($coursegroups as $coursegroup) { + $groupuserstoadd = array_column( + groups_get_members(groupid: $coursegroup->id), + 'id', + ); + + foreach ($allaccessgroupusers as $allaccessgroupuser) { + if (!in_array($allaccessgroupuser, $groupuserstoadd, true)) { + $groupuserstoadd[] = $allaccessgroupuser; + } + } + + // Now create/update the group room. + $communication = self::load_by_group( + groupid: $coursegroup->id, + context: $coursecontext, + ); + $communication->configure_room_and_membership_by_provider( + provider: $provider, + instance: $course, + communicationroomname: $coursegroup->name, + users: $groupuserstoadd, + ); + } + } +} diff --git a/communication/classes/hook_listener.php b/communication/classes/hook_listener.php new file mode 100644 index 0000000000000..29b03293e0b9c --- /dev/null +++ b/communication/classes/hook_listener.php @@ -0,0 +1,634 @@ +. + +namespace core_communication; + +use context_course; +use core\hook\access\after_role_assigned; +use core\hook\access\after_role_unassigned; +use core_enrol\hook\before_enrol_instance_delete; +use core_enrol\hook\after_enrol_instance_status_updated; +use core_enrol\hook\after_user_enrolled; +use core_enrol\hook\before_user_enrolment_update; +use core_enrol\hook\before_user_enrolment_remove; +use core_course\hook\after_course_created; +use core_course\hook\before_course_delete; +use core_course\hook\after_course_updated; +use core_group\hook\after_group_created; +use core_group\hook\after_group_deleted; +use core_group\hook\after_group_membership_added; +use core_group\hook\after_group_membership_removed; +use core_group\hook\after_group_updated; +use core_user\hook\before_user_deleted; +use core_user\hook\before_user_update; + +/** + * Hook listener for communication api. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class hook_listener { + + /** + * Get the course and group object for the group hook. + * + * @param mixed $hook The hook object. + * @return array + */ + protected static function get_group_and_course_data_for_group_hook(mixed $hook): array { + $group = $hook->groupinstance; + $course = helper::get_course( + courseid: $group->courseid, + ); + + return [ + $group, + $course, + ]; + } + + /** + * Communication api call to create room for a group if course has group mode enabled. + * + * @param after_group_created $hook The group created hook. + */ + public static function create_group_communication( + after_group_created $hook, + ): void { + [$group, $course] = self::get_group_and_course_data_for_group_hook( + hook: $hook, + ); + + // Check if group mode enabled before handling the communication. + if (!helper::is_group_mode_enabled_for_course(course: $course)) { + return; + } + + $coursecontext = \context_course::instance(courseid: $course->id); + // Get the course communication instance to set the provider. + $coursecommunication = helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + + $communication = api::load_by_instance( + context: $coursecontext, + component: constants::GROUP_COMMUNICATION_COMPONENT, + instancetype: constants::GROUP_COMMUNICATION_INSTANCETYPE, + instanceid: $group->id, + provider: $coursecommunication->get_provider(), + ); + + $communication->create_and_configure_room( + communicationroomname: $group->name, + instance: $course, + ); + + // As it's a new group, we need to add the users with all access group role to the room. + $enrolledusers = helper::get_enrolled_users_for_course(course: $course); + $userstoadd = helper::get_users_has_access_to_all_groups( + userids: $enrolledusers, + courseid: $course->id, + ); + $communication->add_members_to_room( + userids: $userstoadd, + queue: false, + ); + } + + /** + * Communication api call to update room for a group if course has group mode enabled. + * + * @param after_group_updated $hook The group updated hook. + */ + public static function update_group_communication( + after_group_updated $hook, + ): void { + [$group, $course] = self::get_group_and_course_data_for_group_hook( + hook: $hook, + ); + + // Check if group mode enabled before handling the communication. + if (!helper::is_group_mode_enabled_for_course(course: $course)) { + return; + } + + $coursecontext = \context_course::instance(courseid: $course->id); + $communication = helper::load_by_group( + groupid: $group->id, + context: $coursecontext, + ); + + // If the name didn't change, then we don't need to update the room. + if ($group->name === $communication->get_room_name()) { + return; + } + + $communication->update_room( + active: processor::PROVIDER_ACTIVE, + communicationroomname: $group->name, + instance: $course, + ); + } + + /** + * Delete the communication room for a group if course has group mode enabled. + * + * @param after_group_deleted $hook The group deleted hook. + */ + public static function delete_group_communication( + after_group_deleted $hook + ): void { + [$group, $course] = self::get_group_and_course_data_for_group_hook( + hook: $hook, + ); + + // Check if group mode enabled before handling the communication. + if (!helper::is_group_mode_enabled_for_course(course: $course)) { + return; + } + + $context = context_course::instance($course->id); + $communication = helper::load_by_group( + groupid: $group->id, + context: $context, + ); + $communication->delete_room(); + } + + /** + * Add members to group room when a new member is added to the group. + * + * @param after_group_membership_added $hook The group membership added hook. + */ + public static function add_members_to_group_room( + after_group_membership_added $hook, + ): void { + [$group, $course] = self::get_group_and_course_data_for_group_hook( + hook: $hook, + ); + + // Check if group mode enabled before handling the communication. + if (!helper::is_group_mode_enabled_for_course(course: $course)) { + return; + } + + $context = context_course::instance($course->id); + $communication = helper::load_by_group( + groupid: $group->id, + context: $context, + ); + $communication->add_members_to_room( + userids: $hook->userids, + ); + } + + /** + * Remove members from the room when a member is removed from group room. + * + * @param after_group_membership_removed $hook The group membership removed hook. + */ + public static function remove_members_from_group_room( + after_group_membership_removed $hook, + ): void { + [$group, $course] = self::get_group_and_course_data_for_group_hook( + hook: $hook, + ); + + // Check if group mode enabled before handling the communication. + if (!helper::is_group_mode_enabled_for_course(course: $course)) { + return; + } + + $context = context_course::instance($course->id); + $communication = helper::load_by_group( + groupid: $group->id, + context: $context, + ); + $communication->remove_members_from_room( + userids: $hook->userids, + ); + } + + /** + * Create course communication instance. + * + * @param after_course_created $hook The course created hook. + */ + public static function create_course_communication( + after_course_created $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $course = $hook->course; + + // Check for default provider config setting. + $defaultprovider = get_config( + plugin: 'moodlecourse', + name: 'coursecommunicationprovider', + ); + $provider = $course->selectedcommunication ?? $defaultprovider; + + if (empty($provider) && $provider === processor::PROVIDER_NONE) { + return; + } + + // Check for group mode, we will have to get the course data again as the group info is not always in the object. + $createcourseroom = true; + $creategrouprooms = false; + $coursedata = get_course(courseid: $course->id); + $groupmode = $course->groupmode ?? $coursedata->groupmode; + if ((int)$groupmode !== NOGROUPS) { + $createcourseroom = false; + $creategrouprooms = true; + } + + // Prepare the communication api data. + $courseimage = course_get_courseimage(course: $course); + $communicationroomname = !empty($course->communicationroomname) ? $course->communicationroomname : $coursedata->fullname; + $coursecontext = \context_course::instance(courseid: $course->id); + // Communication api call for course communication. + $communication = \core_communication\api::load_by_instance( + context: $coursecontext, + component: constants::COURSE_COMMUNICATION_COMPONENT, + instancetype: constants::COURSE_COMMUNICATION_INSTANCETYPE, + instanceid: $course->id, + provider: $provider, + ); + $communication->create_and_configure_room( + communicationroomname: $communicationroomname, + avatar: $courseimage, + instance: $course, + queue: $createcourseroom, + ); + + // Communication api call for group communication. + if ($creategrouprooms) { + helper::update_group_communication_instances_for_course( + course: $course, + provider: $provider, + ); + } else { + $enrolledusers = helper::get_enrolled_users_for_course(course: $course); + $communication->add_members_to_room( + userids: $enrolledusers, + queue: false, + ); + } + } + + /** + * Update the course communication instance. + * + * @param after_course_updated $hook The course updated hook. + */ + public static function update_course_communication( + after_course_updated $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + $course = $hook->course; + $oldcourse = $hook->oldcourse; + $changeincoursecat = $hook->changeincoursecat; + $groupmode = $course->groupmode ?? get_course($course->id)->groupmode; + if ($changeincoursecat || $groupmode !== $oldcourse->groupmode) { + helper::update_course_communication_instance( + course: $course, + changesincoursecat: $changeincoursecat, + ); + } + } + + /** + * Delete course communication data and remove members. + * Course can have communication data if it is a group or a course. + * This action is important to perform even if the experimental feature is disabled. + * + * @param before_course_delete $hook The course deleted hook. + */ + public static function delete_course_communication( + before_course_delete $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $course = $hook->course; + $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; + $coursecontext = \context_course::instance(courseid: $course->id); + + // If group mode is not set then just handle the course communication room. + if ((int)$groupmode === NOGROUPS) { + $communication = helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $communication->delete_room(); + } else { + // If group mode is set then handle the group communication rooms. + $coursegroups = groups_get_all_groups(courseid: $course->id); + foreach ($coursegroups as $coursegroup) { + $communication = helper::load_by_group( + groupid: $coursegroup->id, + context: $coursecontext, + ); + $communication->delete_room(); + } + } + } + + /** + * Update the room membership for the user updates. + * + * @param before_user_update $hook The user updated hook. + */ + public static function update_user_room_memberships( + before_user_update $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $user = $hook->user; + $currentuserrecord = $hook->currentuserdata; + + // Get the user courses. + $usercourses = enrol_get_users_courses(userid: $user->id); + + // If the user is suspended then remove the user from all the rooms. + // Otherwise add the user to all the rooms for the courses the user enrolled in. + if (!empty($currentuserrecord) && isset($user->suspended) && $currentuserrecord->suspended !== $user->suspended) { + // Decide the action for the communication api for the user. + $memberaction = ($user->suspended === 0) ? 'add_members_to_room' : 'remove_members_from_room'; + foreach ($usercourses as $usercourse) { + helper::update_course_communication_room_membership( + course: $usercourse, + userids: [$user->id], + memberaction: $memberaction, + ); + } + } + } + + /** + * Delete all room memberships for a user. + * + * @param before_user_deleted $hook The user deleted hook. + */ + public static function delete_user_room_memberships( + before_user_deleted $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $user = $hook->user; + + foreach (enrol_get_users_courses(userid: $user->id) as $course) { + $groupmode = $course->groupmode ?? get_course(courseid: $course->id)->groupmode; + $coursecontext = \context_course::instance(courseid: $course->id); + + if ((int)$groupmode === NOGROUPS) { + $communication = helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $communication->get_room_user_provider()->remove_members_from_room(userids: [$user->id]); + $communication->get_processor()->delete_instance_user_mapping(userids: [$user->id]); + } else { + // If group mode is set then handle the group communication rooms. + $coursegroups = groups_get_all_groups(courseid: $course->id); + foreach ($coursegroups as $coursegroup) { + $communication = helper::load_by_group( + groupid: $coursegroup->id, + context: $coursecontext, + ); + $communication->get_room_user_provider()->remove_members_from_room(userids: [$user->id]); + $communication->get_processor()->delete_instance_user_mapping(userids: [$user->id]); + } + } + } + } + + /** + * Update the room membership of the user for role assigned in a course. + * + * @param after_role_assigned|after_role_unassigned $hook + */ + public static function update_user_membership_for_role_changes( + after_role_assigned|after_role_unassigned $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $context = $hook->context; + if ($coursecontext = $context->get_course_context(strict: false)) { + helper::update_course_communication_room_membership( + course: get_course(courseid: $coursecontext->instanceid), + userids: [$hook->userid], + memberaction: 'update_room_membership', + ); + } + } + + /** + * Update the communication memberships for enrol status change. + * + * @param after_enrol_instance_status_updated $hook The enrol status updated hook. + */ + public static function update_communication_memberships_for_enrol_status_change( + after_enrol_instance_status_updated $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $enrolinstance = $hook->enrolinstance; + // No need to do anything for guest instances. + if ($enrolinstance->enrol === 'guest') { + return; + } + + $newstatus = $hook->newstatus; + // Check if a valid status is given. + if ( + $newstatus !== ENROL_INSTANCE_ENABLED || + $newstatus !== ENROL_INSTANCE_DISABLED + ) { + return; + } + + // Check if the status provided is valid. + switch ($newstatus) { + case ENROL_INSTANCE_ENABLED: + $action = 'add_members_to_room'; + break; + case ENROL_INSTANCE_DISABLED: + $action = 'remove_members_from_room'; + break; + default: + return; + } + + global $DB; + $instanceusers = $DB->get_records( + table: 'user_enrolments', + conditions: ['enrolid' => $enrolinstance->id, 'status' => ENROL_USER_ACTIVE], + ); + $enrolledusers = array_column($instanceusers, 'userid'); + helper::update_course_communication_room_membership( + course: get_course(courseid: $enrolinstance->courseid), + userids: $enrolledusers, + memberaction: $action, + ); + } + + /** + * Remove the communication instance memberships when an enrolment instance is deleted. + * + * @param before_enrol_instance_delete $hook The enrol instance deleted hook. + */ + public static function remove_communication_memberships_for_enrol_instance_deletion( + before_enrol_instance_delete $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $enrolinstance = $hook->enrolinstance; + // No need to do anything for guest instances. + if ($enrolinstance->enrol === 'guest') { + return; + } + + global $DB; + $instanceusers = $DB->get_records( + table: 'user_enrolments', + conditions: ['enrolid' => $enrolinstance->id, 'status' => ENROL_USER_ACTIVE], + ); + $enrolledusers = array_column($instanceusers, 'userid'); + helper::update_course_communication_room_membership( + course: get_course(courseid: $enrolinstance->courseid), + userids: $enrolledusers, + memberaction: 'remove_members_from_room', + ); + } + + /** + * Add communication instance membership for an enrolled user. + * + * @param after_user_enrolled $hook The user enrolled hook. + */ + public static function add_communication_membership_for_enrolled_user( + after_user_enrolled $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $enrolinstance = $hook->enrolinstance; + // No need to do anything for guest instances. + if ($enrolinstance->enrol === 'guest') { + return; + } + + helper::update_course_communication_room_membership( + course: get_course($enrolinstance->courseid), + userids: [$hook->get_userid()], + memberaction: 'add_members_to_room', + ); + } + + /** + * Update the communication instance membership for the user enrolment updates. + * + * @param before_user_enrolment_update $hook The user enrolment updated hook. + */ + public static function update_communication_membership_for_updated_user_enrolment( + before_user_enrolment_update $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $enrolinstance = $hook->enrolinstance; + // No need to do anything for guest instances. + if ($enrolinstance->enrol === 'guest') { + return; + } + + $userenrolmentinstance = $hook->userenrolmentinstance; + $statusmodified = $hook->statusmodified; + $timeendmodified = $hook->timeendmodified; + + if ( + ($statusmodified && ((int) $userenrolmentinstance->status === 1)) || + ($timeendmodified && $userenrolmentinstance->timeend !== 0 && (time() > $userenrolmentinstance->timeend)) + ) { + $action = 'remove_members_from_room'; + } else { + $action = 'add_members_to_room'; + } + + helper::update_course_communication_room_membership( + course: get_course($enrolinstance->courseid), + userids: [$hook->get_userid()], + memberaction: $action, + ); + } + + /** + * Remove communication instance membership for an enrolled user. + * + * @param before_user_enrolment_remove $hook The user unenrolled hook. + */ + public static function remove_communication_membership_for_unenrolled_user( + before_user_enrolment_remove $hook, + ): void { + // If the communication subsystem is not enabled then just ignore. + if (!api::is_available()) { + return; + } + + $enrolinstance = $hook->enrolinstance; + // No need to do anything for guest instances. + if ($enrolinstance->enrol === 'guest') { + return; + } + + helper::update_course_communication_room_membership( + course: get_course($enrolinstance->courseid), + userids: [$hook->get_userid()], + memberaction: 'remove_members_from_room', + ); + } +} diff --git a/communication/classes/processor.php b/communication/classes/processor.php index e4beaf4ffb43f..990894696498d 100644 --- a/communication/classes/processor.php +++ b/communication/classes/processor.php @@ -111,7 +111,7 @@ public static function create_instance( /** * Update the communication instance with any changes. * - * @param null|int $active Active state of the instance (processor::PROVIDER_ACTIVE or processor::PROVIDER_INACTIVE) + * @param null|string $active Active state of the instance (processor::PROVIDER_ACTIVE or processor::PROVIDER_INACTIVE) * @param null|string $roomname The room name */ public function update_instance( @@ -489,6 +489,15 @@ public function get_room_name(): ?string { return $this->instancedata->roomname; } + /** + * Get provider active status. + * + * @return int + */ + public function get_provider_status(): int { + return $this->instancedata->active; + } + /** * Get communication instance id. * diff --git a/communication/provider/matrix/tests/matrix_user_manager_test.php b/communication/provider/matrix/tests/matrix_user_manager_test.php index 860fb10cf5edc..00877c4b9badb 100644 --- a/communication/provider/matrix/tests/matrix_user_manager_test.php +++ b/communication/provider/matrix/tests/matrix_user_manager_test.php @@ -229,6 +229,6 @@ public function test_create_matrix_user_profile_fields(): void { $this->assertNotFalse($matrixprofilefield); $user = $this->getDataGenerator()->create_user(); - $this->assertObjectHasAttribute($matrixprofilefield, profile_user_record($user->id)); + $this->assertObjectHasProperty($matrixprofilefield, profile_user_record($user->id)); } } diff --git a/communication/tests/api_test.php b/communication/tests/api_test.php index c4be5b94b5fd6..54d35e9986fd8 100644 --- a/communication/tests/api_test.php +++ b/communication/tests/api_test.php @@ -16,9 +16,13 @@ namespace core_communication; +use core_communication\task\add_members_to_room_task; +use core_communication\task\create_and_configure_room_task; use communication_matrix\matrix_test_helper_trait; use core_communication\task\synchronise_provider_task; use core_communication\task\synchronise_providers_task; +use core_communication\task\remove_members_from_room; +use core_communication\task\update_room_task; defined('MOODLE_INTERNAL') || die(); @@ -351,4 +355,194 @@ public function test_sync_provider(): void { $adhoctask = \core\task\manager::get_adhoc_tasks(synchronise_provider_task::class); $this->assertCount(2, $adhoctask); } + + /** + * Test the removal of all members from the room. + * + * @covers ::remove_all_members_from_room + */ + public function test_remove_all_members_from_room(): void { + $course = $this->get_course(); + $userid = $this->get_user()->id; + $communication = \core_communication\api::load_by_instance( + context: \core\context\course::instance($course->id), + component: 'core_course', + instancetype: 'coursecommunication', + instanceid: $course->id, + ); + $communication->add_members_to_room([$userid]); + + // Now test the removing members from a room. + $communication->remove_all_members_from_room(); + + // Test the remove members tasks added. + $adhoctask = \core\task\manager::get_adhoc_tasks(remove_members_from_room::class); + $this->assertCount(1, $adhoctask); + } + + /** + * Test the configuration of room changes as well as the membership with the change of provider. + * + * @covers ::configure_room_and_membership_by_provider + */ + public function test_configure_room_and_membership_by_provider(): void { + global $DB; + + $course = $this->get_course('Sampleroom', 'none'); + $userid = $this->get_user()->id; + $provider = 'communication_matrix'; + + $communication = \core_communication\api::load_by_instance( + context: \core\context\course::instance($course->id), + component: 'core_course', + instancetype: 'coursecommunication', + instanceid: $course->id, + ); + + $communication->configure_room_and_membership_by_provider( + provider: $provider, + instance: $course, + communicationroomname: $course->fullname, + users: [$userid], + ); + $communication->reload(); + + // Test that the task to create a room is added. + $adhoctask = \core\task\manager::get_adhoc_tasks(create_and_configure_room_task::class); + $this->assertCount(1, $adhoctask); + + // Test that no update tasks are added. + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(0, $adhoctask); + + // Test that the task to add members to room is not added, as we are adding the user mapping not the task. + $adhoctask = \core\task\manager::get_adhoc_tasks(add_members_to_room_task::class); + $this->assertCount(0, $adhoctask); + + // Now delete all the ad-hoc tasks. + $DB->delete_records('task_adhoc'); + + // Now disable the provider by setting none. + $communication->configure_room_and_membership_by_provider( + provider: processor::PROVIDER_NONE, + instance: $course, + communicationroomname: $course->fullname, + users: [$userid], + ); + $communication->reload(); + + // Test that the task to delete a room is added. + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(1, $adhoctask); + + // Test that the task to remove members from room is added. + $adhoctask = \core\task\manager::get_adhoc_tasks(remove_members_from_room::class); + $this->assertCount(1, $adhoctask); + + // Now delete all the ad-hoc tasks. + $DB->delete_records('task_adhoc'); + + // Now try to set the same none provider again. + $communication->configure_room_and_membership_by_provider( + provider: processor::PROVIDER_NONE, + instance: $course, + communicationroomname: $course->fullname, + users: [$userid], + ); + + // Test that no communicaiton task is added. + $adhoctask = \core\task\manager::get_adhoc_tasks(create_and_configure_room_task::class); + $this->assertCount(0, $adhoctask); + + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(0, $adhoctask); + + $adhoctask = \core\task\manager::get_adhoc_tasks(add_members_to_room_task::class); + $this->assertCount(0, $adhoctask); + + $adhoctask = \core\task\manager::get_adhoc_tasks(remove_members_from_room::class); + $this->assertCount(0, $adhoctask); + + // Now let's change it back to matrix and test the update task is added. + $communication->configure_room_and_membership_by_provider( + provider: $provider, + instance: $course, + communicationroomname: $course->fullname, + users: [$userid], + ); + $communication->reload(); + + // Test create task is not added because communication has been created in the past. + $adhoctask = \core\task\manager::get_adhoc_tasks(create_and_configure_room_task::class); + $this->assertCount(0, $adhoctask); + + // Test an update task added. + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(1, $adhoctask); + + // Test add membership task is added. + $adhoctask = \core\task\manager::get_adhoc_tasks(add_members_to_room_task::class); + $this->assertCount(1, $adhoctask); + + // Now delete all the ad-hoc tasks. + $DB->delete_records('task_adhoc'); + + // Now change the provider to another one. + $communication->configure_room_and_membership_by_provider( + provider: 'communication_customlink', + instance: $course, + communicationroomname: $course->fullname, + users: [$userid], + ); + $communication->reload(); + + // Remove membership and update room task for the previous provider. + // Create room task for new one. + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(1, $adhoctask); + + $adhoctask = \core\task\manager::get_adhoc_tasks(remove_members_from_room::class); + $this->assertCount(1, $adhoctask); + + $adhoctask = \core\task\manager::get_adhoc_tasks(create_and_configure_room_task::class); + $this->assertCount(1, $adhoctask); + + // Now delete all the ad-hoc tasks. + $DB->delete_records('task_adhoc'); + + // Now disable the provider. + $communication->configure_room_and_membership_by_provider( + provider: processor::PROVIDER_NONE, + instance: $course, + communicationroomname: $course->fullname, + users: [$userid], + ); + $communication->reload(); + + // Should have one update and one remove task. + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(1, $adhoctask); + + // This provider doesn't have any membership, so no remove task. + $adhoctask = \core\task\manager::get_adhoc_tasks(remove_members_from_room::class); + $this->assertCount(0, $adhoctask); + + // Now delete all the ad-hoc tasks. + $DB->delete_records('task_adhoc'); + + // Now enable the same provider again. + $communication->configure_room_and_membership_by_provider( + provider: $provider, + instance: $course, + communicationroomname: $course->fullname, + users: [$userid], + ); + + // Now it should have one update and one add task. + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(1, $adhoctask); + + $adhoctask = \core\task\manager::get_adhoc_tasks(add_members_to_room_task::class); + $this->assertCount(1, $adhoctask); + } } diff --git a/communication/tests/communication_test_helper_trait.php b/communication/tests/communication_test_helper_trait.php index fb53f5b52fa00..049b38a0726a0 100644 --- a/communication/tests/communication_test_helper_trait.php +++ b/communication/tests/communication_test_helper_trait.php @@ -52,7 +52,8 @@ protected function disable_communication_configs(): void { */ protected function get_course( string $roomname = 'Sampleroom', - string $provider = 'communication_matrix' + string $provider = 'communication_matrix', + array $extrafields = [], ): \stdClass { $this->setup_communication_configs(); @@ -61,7 +62,7 @@ protected function get_course( 'communicationroomname' => $roomname, ]; - return $this->getDataGenerator()->create_course($records); + return $this->getDataGenerator()->create_course(array_merge($records, $extrafields)); } /** diff --git a/communication/tests/helper_test.php b/communication/tests/helper_test.php new file mode 100644 index 0000000000000..b0a24e4025610 --- /dev/null +++ b/communication/tests/helper_test.php @@ -0,0 +1,223 @@ +. + +namespace core_communication; + +use communication_matrix\matrix_test_helper_trait; +use core_communication\processor as communication_processor; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/../provider/matrix/tests/matrix_test_helper_trait.php'); +require_once(__DIR__ . '/communication_test_helper_trait.php'); + +/** + * Test communication helper methods. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core_communication\helper + */ +class helper_test extends \advanced_testcase { + use communication_test_helper_trait; + use matrix_test_helper_trait; + + public function setUp(): void { + parent::setUp(); + $this->resetAfterTest(); + $this->setup_communication_configs(); + $this->initialise_mock_server(); + } + + /** + * Test load_by_group. + */ + public function test_load_by_group(): void { + + // As communication is created by default. + $course = $this->get_course( + extrafields: ['groupmode' => SEPARATEGROUPS], + ); + $group = $this->getDataGenerator()->create_group(['courseid' => $course->id]); + $context = \context_course::instance(courseid: $course->id); + + $groupcommunication = helper::load_by_group( + groupid: $group->id, + context: $context, + ); + $this->assertInstanceOf( + expected: communication_processor::class, + actual: $groupcommunication->get_processor(), + ); + } + + /** + * Test load_by_course. + */ + public function test_load_by_course(): void { + // As communication is created by default. + $course = $this->get_course(); + $coursecontext = \context_course::instance(courseid: $course->id); + $coursecommunication = helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $this->assertInstanceOf( + expected: communication_processor::class, + actual: $coursecommunication->get_processor(), + ); + } + + /** + * Test get_access_to_all_group_cap_users. + */ + public function test_get_users_has_access_to_all_groups(): void { + global $DB; + // Set up the data with course, group, user etc. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $course = $this->get_course(); + $coursecontext = \context_course::instance(courseid: $course->id); + + // Enrol user1 as teacher. + $teacherrole = $DB->get_record( + table: 'role', + conditions: ['shortname' => 'manager'], + ); + $this->getDataGenerator()->enrol_user( + userid: $user1->id, + courseid: $course->id, + ); + role_assign( + roleid: $teacherrole->id, + userid: $user1->id, + contextid: $coursecontext->id, + ); + + // Enrol user2 as student. + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->getDataGenerator()->enrol_user( + userid: $user2->id, + courseid: $course->id, + ); + role_assign( + roleid: $studentrole->id, + userid: $user2->id, + contextid: $coursecontext->id, + ); + + $allgroupaccessusers = helper::get_users_has_access_to_all_groups( + userids: [$user1->id, $user2->id], + courseid: $course->id, + ); + $this->assertContains( + needle: $user1->id, + haystack: $allgroupaccessusers, + ); + $this->assertNotContains( + needle: $user2->id, + haystack: $allgroupaccessusers, + ); + } + + /** + * Test update_communication_room_membership. + */ + public function test_update_communication_room_membership(): void { + global $DB; + + // Set up the data with course, group, user etc. + $user = $this->getDataGenerator()->create_user(); + $course = $this->get_course(); + $coursecontext = \context_course::instance(courseid: $course->id); + $teacherrole = $DB->get_record( + table: 'role', + conditions: ['shortname' => 'manager'], + ); + $this->getDataGenerator()->enrol_user( + userid: $user->id, + courseid: $course->id, + ); + role_assign( + roleid: $teacherrole->id, + userid:$user->id, + contextid: $coursecontext->id, + ); + + // Now remove members from room. + helper::update_course_communication_room_membership( + course: $course, + userids: [$user->id], + memberaction: 'remove_members_from_room', + ); + + // Now test that there is communication instances for the course and the user removed from that instance. + $coursecommunication = helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + + // Check the user is added for course communication instance. + $courseusers = $coursecommunication->get_processor()->get_all_delete_flagged_userids(); + $courseusers = reset($courseusers); + $this->assertEquals( + expected: $user->id, + actual: $courseusers, + ); + + // Now add members to room. + helper::update_course_communication_room_membership( + course: $course, + userids: [$user->id], + memberaction: 'add_members_to_room', + ); + + $coursecommunication->reload(); + // Check the user is added for course communication instance. + $courseusers = $coursecommunication->get_processor()->get_instance_userids(); + $courseusers = reset($courseusers); + $this->assertEquals( + expected: $user->id, + actual: $courseusers, + ); + + // Now update membership. + helper::update_course_communication_room_membership( + course: $course, + userids: [$user->id], + memberaction: 'update_room_membership', + ); + + $coursecommunication->reload(); + // Check the user is added for course communication instance. + $courseusers = $coursecommunication->get_processor()->get_instance_userids(); + $courseusers = reset($courseusers); + $this->assertEquals( + expected: $user->id, + actual: $courseusers, + ); + + // Now try using invalid action. + $this->expectException('coding_exception'); + $this->expectExceptionMessage('Invalid action provided.'); + helper::update_course_communication_room_membership( + course: $course, + userids: [$user->id], + memberaction: 'a_funny_action', + ); + } +} diff --git a/communication/tests/hook_listener_test.php b/communication/tests/hook_listener_test.php new file mode 100644 index 0000000000000..81f9225f64aff --- /dev/null +++ b/communication/tests/hook_listener_test.php @@ -0,0 +1,461 @@ +. + +namespace core_communication; + +use communication_matrix\matrix_test_helper_trait; +use core_communication\task\add_members_to_room_task; +use core_communication\task\create_and_configure_room_task; +use core_communication\task\delete_room_task; +use core_communication\task\update_room_membership_task; +use core_communication\task\update_room_task; +use core_communication\processor as communication_processor; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/../provider/matrix/tests/matrix_test_helper_trait.php'); +require_once(__DIR__ . '/communication_test_helper_trait.php'); + +/** + * Test communication hook listeners. + * + * @package core_communication + * @copyright 2023 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core_communication\hook_listener + */ +class hook_listener_test extends \advanced_testcase { + + use communication_test_helper_trait; + use matrix_test_helper_trait; + + public function setUp(): void { + parent::setUp(); + $this->resetAfterTest(); + $this->setup_communication_configs(); + $this->initialise_mock_server(); + } + + /** + * Test create_group_communication. + */ + public function test_create_update_delete_group_communication(): void { + global $DB; + + $course = $this->get_course( + extrafields: ['groupmode' => SEPARATEGROUPS], + ); + $coursecontext = \context_course::instance(courseid: $course->id); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + + // Enrol user1 as teacher. + $teacherrole = $DB->get_record( + table: 'role', + conditions: ['shortname' => 'manager'], + ); + $this->getDataGenerator()->enrol_user( + userid: $user1->id, + courseid: $course->id, + ); + role_assign( + roleid: $teacherrole->id, + userid: $user1->id, + contextid: $coursecontext->id, + ); + + // Enrol user2 as student. + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->getDataGenerator()->enrol_user( + userid: $user2->id, + courseid: $course->id, + ); + role_assign( + roleid: $studentrole->id, + userid: $user2->id, + contextid: $coursecontext->id, + ); + + $group = $this->getDataGenerator()->create_group(['courseid' => $course->id]); + $context = \context_course::instance($course->id); + + $groupcommunication = helper::load_by_group( + groupid: $group->id, + context: $context, + ); + $this->assertInstanceOf( + expected: communication_processor::class, + actual: $groupcommunication->get_processor(), + ); + + $this->assertEquals( + expected: $group->id, + actual: $groupcommunication->get_processor()->get_instance_id(), + ); + + // Task to create room should be added. + $adhoctask = \core\task\manager::get_adhoc_tasks(create_and_configure_room_task::class); + $this->assertCount(1, $adhoctask); + + // Task to add members to room should not be there as the room is yet to be created. + $adhoctask = \core\task\manager::get_adhoc_tasks(add_members_to_room_task::class); + $this->assertCount(0, $adhoctask); + + // Only users with access to all groups should be added to the room at this point. + $groupcommunicationusers = $groupcommunication->get_processor()->get_all_userids_for_instance(); + $this->assertEquals( + expected: [$user1->id], + actual: $groupcommunicationusers, + ); + + // Now delete all the ad-hoc tasks. + $DB->delete_records('task_adhoc'); + + // Now cann the update group but don't change the group name. + groups_update_group($group); + + // No task should be added as nothing changed. + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(0, $adhoctask); + + // Now change the group name. + $changedgroupname = 'Changedgroupname'; + $group->name = $changedgroupname; + groups_update_group($group); + + // Now one task should be there to update the group room name. + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_task::class); + $this->assertCount(1, $adhoctask); + + $groupcommunication->reload(); + $this->assertEquals( + expected: $changedgroupname, + actual: $groupcommunication->get_processor()->get_room_name(), + ); + + // Now delete the group. + groups_delete_group($group->id); + + $adhoctask = \core\task\manager::get_adhoc_tasks(delete_room_task::class); + $this->assertCount(1, $adhoctask); + } + + /** + * Test add_members_to_group_room. + */ + public function test_add_members_to_group_room(): void { + global $DB; + + $course = $this->get_course( + extrafields: ['groupmode' => SEPARATEGROUPS], + ); + $coursecontext = \context_course::instance(courseid: $course->id); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + + // Enrol user1 as teacher. + $teacherrole = $DB->get_record( + table: 'role', + conditions: ['shortname' => 'manager'], + ); + $this->getDataGenerator()->enrol_user( + userid: $user1->id, + courseid: $course->id, + ); + role_assign( + roleid: $teacherrole->id, + userid: $user1->id, + contextid: $coursecontext->id, + ); + + // Enrol user2 as student. + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->getDataGenerator()->enrol_user( + userid: $user2->id, + courseid: $course->id, + ); + role_assign( + roleid: $studentrole->id, + userid: $user2->id, + contextid: $coursecontext->id, + ); + + $group = $this->getDataGenerator()->create_group(['courseid' => $course->id]); + + // Now check if the teacher is added to the group room as the teacher has access to all groups. + $context = \context_course::instance($course->id); + $groupcommunication = helper::load_by_group( + groupid: $group->id, + context: $context, + ); + + // Now the communication instance should not have the student added yet. + $this->assertNotContains( + needle: $user2->id, + haystack: $groupcommunication->get_processor()->get_all_userids_for_instance(), + ); + + groups_add_member( + grouporid: $group, + userorid: $user2, + ); + + // Now it should have the student. + $this->assertContains( + needle: $user2->id, + haystack: $groupcommunication->get_processor()->get_all_userids_for_instance(), + ); + } + + /** + * Test if the course instances are created properly for course default provider. + */ + public function test_course_default_provider(): void { + $defaultprovider = 'communication_matrix'; + // Set the default communication for course. + set_config( + name: 'coursecommunicationprovider', + value: $defaultprovider, + plugin: 'moodlecourse', + ); + + // Test that the default communication is created for course mode. + $course = $this->get_course(); + $coursecontext = \context_course::instance(courseid: $course->id); + $coursecommunication = helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $this->assertEquals( + expected: $defaultprovider, + actual: $coursecommunication->get_provider(), + ); + $this->assertEquals( + expected: 'core_course', + actual: $coursecommunication->get_processor()->get_component(), + ); + $this->assertEquals( + expected: $course->id, + actual: $coursecommunication->get_processor()->get_instance_id(), + ); + } + + /** + * Test update_course_communication. + */ + public function test_update_course_communication(): void { + global $DB; + + // Set up the data with course, group, user etc. + $user = $this->getDataGenerator()->create_user(); + $course = $this->get_course(); + $group = $this->getDataGenerator()->create_group(record: ['courseid' => $course->id]); + $coursecontext = \context_course::instance(courseid: $course->id); + $teacherrole = $DB->get_record( + table: 'role', + conditions: ['shortname' => 'teacher'], + ); + $this->getDataGenerator()->enrol_user( + userid: $user->id, + courseid: $course->id, + ); + role_assign( + roleid: $teacherrole->id, + userid: $user->id, + contextid: $coursecontext->id, + ); + groups_add_member( + grouporid: $group->id, + userorid: $user->id, + ); + + // Now test that there is communication instances for the course and the user added for that instance. + $coursecommunication = helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $this->assertInstanceOf( + expected: communication_processor::class, + actual: $coursecommunication->get_processor(), + ); + + // Check the user is added for course communication instance. + $courseusers = $coursecommunication->get_processor()->get_all_userids_for_instance(); + $courseusers = reset($courseusers); + $this->assertEquals( + expected: $user->id, + actual: $courseusers, + ); + + // Group should not have any instance yet. + $groupcommunication = helper::load_by_group( + groupid: $group->id, + context: $coursecontext, + ); + $this->assertNull(actual: $groupcommunication->get_processor()); + + // Now update the course. + $course->groupmode = SEPARATEGROUPS; + $course->selectedcommunication = 'communication_matrix'; + update_course(data: $course); + + // Now there should be a group communication instance. + $groupcommunication->reload(); + $this->assertInstanceOf( + expected: communication_processor::class, + actual: $groupcommunication->get_processor(), + ); + + // The course communication instance must be active. + $coursecommunication->reload(); + $this->assertInstanceOf( + expected: communication_processor::class, + actual: $coursecommunication->get_processor(), + ); + + // All the course instance users must be marked as deleted. + $coursecommunication->reload(); + $courseusers = $coursecommunication->get_processor()->get_all_delete_flagged_userids(); + $courseusers = reset($courseusers); + $this->assertEquals( + expected: $user->id, + actual: $courseusers, + ); + + // Group instance should have the user. + $groupusers = $groupcommunication->get_processor()->get_all_userids_for_instance(); + $groupusers = reset($groupusers); + $this->assertEquals( + expected: $user->id, + actual: $groupusers, + ); + + // Now disable the communication instance for the course. + $course->selectedcommunication = communication_processor::PROVIDER_NONE; + update_course(data: $course); + + // Now both course and group instance should be disabled. + $coursecommunication->reload(); + $this->assertNull(actual: $coursecommunication->get_processor()); + + $groupcommunication->reload(); + $this->assertNull(actual: $groupcommunication->get_processor()); + } + + /** + * Test create_course_communication_instance. + */ + public function test_create_course_communication_instance(): void { + $course = $this->get_course(); + $coursecontext = \context_course::instance(courseid: $course->id); + $coursecommunication = helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + + $processor = $coursecommunication->get_processor(); + $this->assertEquals( + expected: 'communication_matrix', + actual: $processor->get_provider(), + ); + $this->assertEquals( + expected: 'Sampleroom', + actual: $processor->get_room_name(), + ); + } + + /** + * Test delete_course_communication. + */ + public function test_delete_course_communication(): void { + $course = $this->get_course(); + delete_course( + courseorid: $course, + showfeedback: false, + ); + + $adhoctask = \core\task\manager::get_adhoc_tasks(delete_room_task::class); + $this->assertCount(1, $adhoctask); + } + + /** + * Test update of room membership when user changes occur. + */ + public function test_update_user_room_memberships(): void { + global $DB; + + $user = $this->getDataGenerator()->create_user(); + $course = $this->get_course(); + $coursecontext = \context_course::instance($course->id); + $teacherrole = $DB->get_record('role', ['shortname' => 'teacher']); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + role_assign($teacherrole->id, $user->id, $coursecontext->id); + + $coursecommunication = helper::load_by_course($course->id, $coursecontext); + $courseusers = $coursecommunication->get_processor()->get_all_userids_for_instance(); + $courseusers = reset($courseusers); + $this->assertEquals($user->id, $courseusers); + + $user->suspended = 1; + user_update_user($user, false); + + $coursecommunication->reload(); + $courseusers = $coursecommunication->get_processor()->get_all_delete_flagged_userids(); + $courseusers = reset($courseusers); + $this->assertEquals($user->id, $courseusers); + } + + /** + * Test deletion of user room memberships when a user is deleted. + */ + public function test_delete_user_room_memberships(): void { + global $DB; + + $user = $this->getDataGenerator()->create_user(); + $course = $this->get_course(); + $coursecontext = \context_course::instance($course->id); + $teacherrole = $DB->get_record('role', ['shortname' => 'teacher']); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + role_assign($teacherrole->id, $user->id, $coursecontext->id); + + delete_user($user); + $coursecommunication = helper::load_by_course($course->id, $coursecontext); + $courseusers = $coursecommunication->get_processor()->get_all_userids_for_instance(); + $this->assertEmpty($courseusers); + } + + /** + * Test user room membership updates with role changes in a course. + */ + public function test_update_user_membership_for_role_changes(): void { + global $DB; + + $user = $this->getDataGenerator()->create_user(); + $course = $this->get_course(); + $coursecontext = \context_course::instance($course->id); + $teacherrole = $DB->get_record('role', ['shortname' => 'teacher']); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_membership_task::class); + $this->assertCount(1, $adhoctask); + + role_assign($teacherrole->id, $user->id, $coursecontext->id); + + $adhoctask = \core\task\manager::get_adhoc_tasks(update_room_membership_task::class); + $this->assertCount(2, $adhoctask); + } +} + diff --git a/competency/tests/lib_test.php b/competency/tests/lib_test.php index 660d4c4dfae7c..2d29a0c413491 100644 --- a/competency/tests/lib_test.php +++ b/competency/tests/lib_test.php @@ -99,7 +99,7 @@ public function test_comment_add_user_competency() { $this->assertEquals($expectedurlname, $message->contexturlname); // Test customdata. $customdata = json_decode($message->customdata); - $this->assertObjectHasAttribute('notificationiconurl', $customdata); + $this->assertObjectHasProperty('notificationiconurl', $customdata); $this->assertStringContainsString('tokenpluginfile.php', $customdata->notificationiconurl); $userpicture = new \user_picture($u1); $userpicture->size = 1; // Use f1 size. @@ -229,7 +229,7 @@ public function test_comment_add_plan() { $this->assertEquals($u1->id, $message->useridto); // Test customdata. $customdata = json_decode($message->customdata); - $this->assertObjectHasAttribute('notificationiconurl', $customdata); + $this->assertObjectHasProperty('notificationiconurl', $customdata); // Post a comment in a plan with reviewer. The reviewer is messaged. $p1->set('reviewerid', $u2->id); diff --git a/composer.json b/composer.json index f6115aed59302..bfeeb39b83556 100644 --- a/composer.json +++ b/composer.json @@ -5,15 +5,15 @@ "type": "project", "homepage": "https://moodle.org", "require-dev": { - "phpunit/phpunit": "9.5.*", + "phpunit/phpunit": "^9.6.18", "mikey179/vfsstream": "1.6.*", - "behat/mink": "^1.10.0", - "friends-of-behat/mink-extension": "^2.7.4", - "behat/mink-browserkit-driver": "^2.1.0", - "symfony/process": "^4.4 || ^5.0 || ^6.0", - "symfony/http-client": "^4.4 || ^5.0 || ^6.0", - "symfony/mime": "^4.4 || ^5.0 || ^6.0", - "behat/behat": "3.13.*", + "behat/mink": "^1.11.0", + "friends-of-behat/mink-extension": "^2.7.5", + "behat/mink-browserkit-driver": "^2.2.0", + "symfony/process": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/http-client": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/mime": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "behat/behat": "3.14.*", "oleg-andreyev/mink-phpwebdriver": "1.3.*", "filp/whoops": "^2.15" }, diff --git a/composer.lock b/composer.lock index f4e0028cafe45..37c00a9641c76 100644 --- a/composer.lock +++ b/composer.lock @@ -4,21 +4,21 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ede584ad81dabcafdf0606d10a994ec6", + "content-hash": "93266c895599040b27580a06ca936318", "packages": [], "packages-dev": [ { "name": "behat/behat", - "version": "v3.13.0", + "version": "v3.14.0", "source": { "type": "git", "url": "https://github.com/Behat/Behat.git", - "reference": "9dd7cdb309e464ddeab095cd1a5151c2dccba4ab" + "reference": "2a3832d9cb853a794af3a576f9e524ae460f3340" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Behat/Behat/zipball/9dd7cdb309e464ddeab095cd1a5151c2dccba4ab", - "reference": "9dd7cdb309e464ddeab095cd1a5151c2dccba4ab", + "url": "https://api.github.com/repos/Behat/Behat/zipball/2a3832d9cb853a794af3a576f9e524ae460f3340", + "reference": "2a3832d9cb853a794af3a576f9e524ae460f3340", "shasum": "" }, "require": { @@ -27,18 +27,18 @@ "ext-mbstring": "*", "php": "^7.2 || ^8.0", "psr/container": "^1.0 || ^2.0", - "symfony/config": "^4.4 || ^5.0 || ^6.0", - "symfony/console": "^4.4 || ^5.0 || ^6.0", - "symfony/dependency-injection": "^4.4 || ^5.0 || ^6.0", - "symfony/event-dispatcher": "^4.4 || ^5.0 || ^6.0", - "symfony/translation": "^4.4 || ^5.0 || ^6.0", - "symfony/yaml": "^4.4 || ^5.0 || ^6.0" + "symfony/config": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/console": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/dependency-injection": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/event-dispatcher": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/translation": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/yaml": "^4.4 || ^5.0 || ^6.0 || ^7.0" }, "require-dev": { "herrera-io/box": "~1.6.1", "phpspec/prophecy": "^1.15", "phpunit/phpunit": "^8.5 || ^9.0", - "symfony/process": "^4.4 || ^5.0 || ^6.0", + "symfony/process": "^4.4 || ^5.0 || ^6.0 || ^7.0", "vimeo/psalm": "^4.8" }, "suggest": { @@ -90,9 +90,9 @@ ], "support": { "issues": "https://github.com/Behat/Behat/issues", - "source": "https://github.com/Behat/Behat/tree/v3.13.0" + "source": "https://github.com/Behat/Behat/tree/v3.14.0" }, - "time": "2023-04-18T15:40:53+00:00" + "time": "2023-12-09T13:55:02+00:00" }, { "name": "behat/gherkin", @@ -159,26 +159,28 @@ }, { "name": "behat/mink", - "version": "v1.10.0", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/minkphp/Mink.git", - "reference": "19e58905632e7cfdc5b2bafb9b950a3521af32c5" + "reference": "d8527fdf8785aad38455fb426af457ab9937aece" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/minkphp/Mink/zipball/19e58905632e7cfdc5b2bafb9b950a3521af32c5", - "reference": "19e58905632e7cfdc5b2bafb9b950a3521af32c5", + "url": "https://api.github.com/repos/minkphp/Mink/zipball/d8527fdf8785aad38455fb426af457ab9937aece", + "reference": "d8527fdf8785aad38455fb426af457ab9937aece", "shasum": "" }, "require": { "php": ">=7.2", - "symfony/css-selector": "^4.4 || ^5.0 || ^6.0" + "symfony/css-selector": "^4.4 || ^5.0 || ^6.0 || ^7.0" }, "require-dev": { + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-phpunit": "^1.3", "phpunit/phpunit": "^8.5.22 || ^9.5.11", - "symfony/error-handler": "^4.4 || ^5.0 || ^6.0", - "symfony/phpunit-bridge": "^5.4 || ^6.0" + "symfony/error-handler": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/phpunit-bridge": "^5.4 || ^6.0 || ^7.0" }, "suggest": { "behat/mink-browserkit-driver": "fast headless driver for any app without JS emulation", @@ -217,37 +219,40 @@ ], "support": { "issues": "https://github.com/minkphp/Mink/issues", - "source": "https://github.com/minkphp/Mink/tree/v1.10.0" + "source": "https://github.com/minkphp/Mink/tree/v1.11.0" }, - "time": "2022-03-28T14:22:43+00:00" + "time": "2023-12-09T11:23:23+00:00" }, { "name": "behat/mink-browserkit-driver", - "version": "v2.1.0", + "version": "v2.2.0", "source": { "type": "git", "url": "https://github.com/minkphp/MinkBrowserKitDriver.git", - "reference": "d2768e6c17b293d86d8fcff54cbb9e6ad938fee1" + "reference": "16d53476e42827ed3aafbfa4fde17a1743eafd50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/minkphp/MinkBrowserKitDriver/zipball/d2768e6c17b293d86d8fcff54cbb9e6ad938fee1", - "reference": "d2768e6c17b293d86d8fcff54cbb9e6ad938fee1", + "url": "https://api.github.com/repos/minkphp/MinkBrowserKitDriver/zipball/16d53476e42827ed3aafbfa4fde17a1743eafd50", + "reference": "16d53476e42827ed3aafbfa4fde17a1743eafd50", "shasum": "" }, "require": { - "behat/mink": "^1.9.0@dev", + "behat/mink": "^1.11.0@dev", + "ext-dom": "*", "php": ">=7.2", - "symfony/browser-kit": "^4.4 || ^5.0 || ^6.0", - "symfony/dom-crawler": "^4.4 || ^5.0 || ^6.0" + "symfony/browser-kit": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/dom-crawler": "^4.4 || ^5.0 || ^6.0 || ^7.0" }, "require-dev": { "mink/driver-testsuite": "dev-master", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-phpunit": "^1.3", "phpunit/phpunit": "^8.5 || ^9.5", - "symfony/error-handler": "^4.4 || ^5.0 || ^6.0", - "symfony/http-client": "^4.4 || ^5.0 || ^6.0", - "symfony/http-kernel": "^4.4 || ^5.0 || ^6.0", - "symfony/mime": "^4.4 || ^5.0 || ^6.0", + "symfony/error-handler": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/http-client": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/http-kernel": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/mime": "^4.4 || ^5.0 || ^6.0 || ^7.0", "yoast/phpunit-polyfills": "^1.0" }, "type": "mink-driver", @@ -282,9 +287,9 @@ ], "support": { "issues": "https://github.com/minkphp/MinkBrowserKitDriver/issues", - "source": "https://github.com/minkphp/MinkBrowserKitDriver/tree/v2.1.0" + "source": "https://github.com/minkphp/MinkBrowserKitDriver/tree/v2.2.0" }, - "time": "2022-03-28T14:33:51+00:00" + "time": "2023-12-09T11:30:50+00:00" }, { "name": "behat/transliterator", @@ -478,23 +483,23 @@ }, { "name": "friends-of-behat/mink-extension", - "version": "v2.7.4", + "version": "v2.7.5", "source": { "type": "git", "url": "https://github.com/FriendsOfBehat/MinkExtension.git", - "reference": "18d5a53dff3e2c8934c53e2db8b02b7ea345fe85" + "reference": "854336030e11983f580f49faad1b49a1238f9846" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfBehat/MinkExtension/zipball/18d5a53dff3e2c8934c53e2db8b02b7ea345fe85", - "reference": "18d5a53dff3e2c8934c53e2db8b02b7ea345fe85", + "url": "https://api.github.com/repos/FriendsOfBehat/MinkExtension/zipball/854336030e11983f580f49faad1b49a1238f9846", + "reference": "854336030e11983f580f49faad1b49a1238f9846", "shasum": "" }, "require": { "behat/behat": "^3.0.5", "behat/mink": "^1.5", "php": ">=7.4", - "symfony/config": "^4.4 || ^5.0 || ^6.0" + "symfony/config": "^4.4 || ^5.0 || ^6.0 || ^7.0" }, "replace": { "behat/mink-extension": "self.version" @@ -537,9 +542,10 @@ "web" ], "support": { - "source": "https://github.com/FriendsOfBehat/MinkExtension/tree/v2.7.4" + "issues": "https://github.com/FriendsOfBehat/MinkExtension/issues", + "source": "https://github.com/FriendsOfBehat/MinkExtension/tree/v2.7.5" }, - "time": "2023-10-03T13:15:12+00:00" + "time": "2024-01-11T09:12:02+00:00" }, { "name": "masterminds/html5", @@ -720,25 +726,27 @@ }, { "name": "nikic/php-parser", - "version": "v4.17.1", + "version": "v5.0.2", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d" + "reference": "139676794dc1e9231bf7bcd123cfc0c99182cb13" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", - "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/139676794dc1e9231bf7bcd123cfc0c99182cb13", + "reference": "139676794dc1e9231bf7bcd123cfc0c99182cb13", "shasum": "" }, "require": { + "ext-ctype": "*", + "ext-json": "*", "ext-tokenizer": "*", - "php": ">=7.0" + "php": ">=7.4" }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" }, "bin": [ "bin/php-parse" @@ -746,7 +754,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.9-dev" + "dev-master": "5.0-dev" } }, "autoload": { @@ -770,22 +778,22 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.17.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.0.2" }, - "time": "2023-08-13T19:53:39+00:00" + "time": "2024-03-05T20:51:40+00:00" }, { "name": "oleg-andreyev/mink-phpwebdriver", - "version": "v1.3.0", + "version": "v1.3.1", "source": { "type": "git", "url": "https://github.com/oleg-andreyev/MinkPhpWebDriver.git", - "reference": "d0d15960a422b6e263276281c83fc681ab75a30a" + "reference": "c5c2e3177515018b1f209d6b0aff1fc73d324155" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/oleg-andreyev/MinkPhpWebDriver/zipball/d0d15960a422b6e263276281c83fc681ab75a30a", - "reference": "d0d15960a422b6e263276281c83fc681ab75a30a", + "url": "https://api.github.com/repos/oleg-andreyev/MinkPhpWebDriver/zipball/c5c2e3177515018b1f209d6b0aff1fc73d324155", + "reference": "c5c2e3177515018b1f209d6b0aff1fc73d324155", "shasum": "" }, "require": { @@ -837,24 +845,25 @@ "issues": "https://github.com/oleg-andreyev/MinkPhpWebDriver/issues", "source": "https://github.com/oleg-andreyev/MinkPhpWebDriver/tree/v1.3.1" }, - "time": "2023-07-11T22:28:02+00:00" + "time": "2024-01-23T18:51:27+00:00" }, { "name": "phar-io/manifest", - "version": "2.0.3", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53" + "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { "ext-dom": "*", + "ext-libxml": "*", "ext-phar": "*", "ext-xmlwriter": "*", "phar-io/version": "^3.0.1", @@ -895,9 +904,15 @@ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.3" + "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, - "time": "2021-07-20T11:28:43+00:00" + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" }, { "name": "phar-io/version", @@ -1018,23 +1033,23 @@ }, { "name": "phpunit/php-code-coverage", - "version": "9.2.29", + "version": "9.2.31", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "6a3a87ac2bbe33b25042753df8195ba4aa534c76" + "reference": "48c34b5d8d983006bd2adc2d0de92963b9155965" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/6a3a87ac2bbe33b25042753df8195ba4aa534c76", - "reference": "6a3a87ac2bbe33b25042753df8195ba4aa534c76", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/48c34b5d8d983006bd2adc2d0de92963b9155965", + "reference": "48c34b5d8d983006bd2adc2d0de92963b9155965", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.15", + "nikic/php-parser": "^4.18 || ^5.0", "php": ">=7.3", "phpunit/php-file-iterator": "^3.0.3", "phpunit/php-text-template": "^2.0.2", @@ -1084,7 +1099,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.29" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.31" }, "funding": [ { @@ -1092,7 +1107,7 @@ "type": "github" } ], - "time": "2023-09-19T04:57:46+00:00" + "time": "2024-03-02T06:37:42+00:00" }, { "name": "phpunit/php-file-iterator", @@ -1337,16 +1352,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.5.28", + "version": "9.6.18", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "954ca3113a03bf780d22f07bf055d883ee04b65e" + "reference": "32c2c2d6580b1d8ab3c10b1e9e4dc263cc69bb04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/954ca3113a03bf780d22f07bf055d883ee04b65e", - "reference": "954ca3113a03bf780d22f07bf055d883ee04b65e", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/32c2c2d6580b1d8ab3c10b1e9e4dc263cc69bb04", + "reference": "32c2c2d6580b1d8ab3c10b1e9e4dc263cc69bb04", "shasum": "" }, "require": { @@ -1361,7 +1376,7 @@ "phar-io/manifest": "^2.0.3", "phar-io/version": "^3.0.2", "php": ">=7.3", - "phpunit/php-code-coverage": "^9.2.13", + "phpunit/php-code-coverage": "^9.2.28", "phpunit/php-file-iterator": "^3.0.5", "phpunit/php-invoker": "^3.1.1", "phpunit/php-text-template": "^2.0.3", @@ -1379,8 +1394,8 @@ "sebastian/version": "^3.0.2" }, "suggest": { - "ext-soap": "*", - "ext-xdebug": "*" + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, "bin": [ "phpunit" @@ -1388,7 +1403,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "9.5-dev" + "dev-master": "9.6-dev" } }, "autoload": { @@ -1419,7 +1434,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.28" + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.18" }, "funding": [ { @@ -1435,7 +1451,7 @@ "type": "tidelift" } ], - "time": "2023-01-14T12:32:24+00:00" + "time": "2024-03-21T12:07:32+00:00" }, { "name": "psr/container", @@ -1592,16 +1608,16 @@ }, { "name": "sebastian/cli-parser", - "version": "1.0.1", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2" + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2", - "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", "shasum": "" }, "require": { @@ -1636,7 +1652,7 @@ "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.1" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" }, "funding": [ { @@ -1644,7 +1660,7 @@ "type": "github" } ], - "time": "2020-09-28T06:08:49+00:00" + "time": "2024-03-02T06:27:43+00:00" }, { "name": "sebastian/code-unit", @@ -1833,20 +1849,20 @@ }, { "name": "sebastian/complexity", - "version": "2.0.2", + "version": "2.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "739b35e53379900cc9ac327b2147867b8b6efd88" + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88", - "reference": "739b35e53379900cc9ac327b2147867b8b6efd88", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", "shasum": "" }, "require": { - "nikic/php-parser": "^4.7", + "nikic/php-parser": "^4.18 || ^5.0", "php": ">=7.3" }, "require-dev": { @@ -1878,7 +1894,7 @@ "homepage": "https://github.com/sebastianbergmann/complexity", "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", - "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2" + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" }, "funding": [ { @@ -1886,20 +1902,20 @@ "type": "github" } ], - "time": "2020-10-26T15:52:27+00:00" + "time": "2023-12-22T06:19:30+00:00" }, { "name": "sebastian/diff", - "version": "4.0.5", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131" + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/74be17022044ebaaecfdf0c5cd504fc9cd5a7131", - "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", "shasum": "" }, "require": { @@ -1944,7 +1960,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", - "source": "https://github.com/sebastianbergmann/diff/tree/4.0.5" + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" }, "funding": [ { @@ -1952,7 +1968,7 @@ "type": "github" } ], - "time": "2023-05-07T05:35:17+00:00" + "time": "2024-03-02T06:30:58+00:00" }, { "name": "sebastian/environment", @@ -2019,16 +2035,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.5", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d" + "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d", - "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", + "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", "shasum": "" }, "require": { @@ -2084,7 +2100,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.5" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" }, "funding": [ { @@ -2092,20 +2108,20 @@ "type": "github" } ], - "time": "2022-09-14T06:03:37+00:00" + "time": "2024-03-02T06:33:00+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.6", + "version": "5.0.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bde739e7565280bda77be70044ac1047bc007e34" + "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bde739e7565280bda77be70044ac1047bc007e34", - "reference": "bde739e7565280bda77be70044ac1047bc007e34", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", "shasum": "" }, "require": { @@ -2148,7 +2164,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.6" + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" }, "funding": [ { @@ -2156,24 +2172,24 @@ "type": "github" } ], - "time": "2023-08-02T09:26:13+00:00" + "time": "2024-03-02T06:35:11+00:00" }, { "name": "sebastian/lines-of-code", - "version": "1.0.3", + "version": "1.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc" + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc", - "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", "shasum": "" }, "require": { - "nikic/php-parser": "^4.6", + "nikic/php-parser": "^4.18 || ^5.0", "php": ">=7.3" }, "require-dev": { @@ -2205,7 +2221,7 @@ "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" }, "funding": [ { @@ -2213,7 +2229,7 @@ "type": "github" } ], - "time": "2020-11-28T06:42:11+00:00" + "time": "2023-12-22T06:20:34+00:00" }, { "name": "sebastian/object-enumerator", @@ -2392,16 +2408,16 @@ }, { "name": "sebastian/resource-operations", - "version": "3.0.3", + "version": "3.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8" + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", - "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", "shasum": "" }, "require": { @@ -2413,7 +2429,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -2434,8 +2450,7 @@ "description": "Provides a list of PHP built-in functions that operate on resources", "homepage": "https://www.github.com/sebastianbergmann/resource-operations", "support": { - "issues": "https://github.com/sebastianbergmann/resource-operations/issues", - "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.3" + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" }, "funding": [ { @@ -2443,7 +2458,7 @@ "type": "github" } ], - "time": "2020-09-28T06:45:17+00:00" + "time": "2024-03-14T16:00:52+00:00" }, { "name": "sebastian/type", @@ -2556,16 +2571,16 @@ }, { "name": "symfony/browser-kit", - "version": "v6.4.0", + "version": "v6.4.3", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "a3bb210e001580ec75e1d02b27fae3452e6bf502" + "reference": "495ffa2e6d17e199213f93768efa01af32bbf70e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/a3bb210e001580ec75e1d02b27fae3452e6bf502", - "reference": "a3bb210e001580ec75e1d02b27fae3452e6bf502", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/495ffa2e6d17e199213f93768efa01af32bbf70e", + "reference": "495ffa2e6d17e199213f93768efa01af32bbf70e", "shasum": "" }, "require": { @@ -2604,7 +2619,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v6.4.0" + "source": "https://github.com/symfony/browser-kit/tree/v6.4.3" }, "funding": [ { @@ -2620,20 +2635,20 @@ "type": "tidelift" } ], - "time": "2023-10-31T08:18:17+00:00" + "time": "2024-01-23T14:51:35+00:00" }, { "name": "symfony/config", - "version": "v6.4.0", + "version": "v6.4.4", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "5d33e0fb707d603330e0edfd4691803a1253572e" + "reference": "6ea4affc27f2086c9d16b92ab5429ce1e3c38047" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/5d33e0fb707d603330e0edfd4691803a1253572e", - "reference": "5d33e0fb707d603330e0edfd4691803a1253572e", + "url": "https://api.github.com/repos/symfony/config/zipball/6ea4affc27f2086c9d16b92ab5429ce1e3c38047", + "reference": "6ea4affc27f2086c9d16b92ab5429ce1e3c38047", "shasum": "" }, "require": { @@ -2679,7 +2694,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v6.4.0" + "source": "https://github.com/symfony/config/tree/v6.4.4" }, "funding": [ { @@ -2695,20 +2710,20 @@ "type": "tidelift" } ], - "time": "2023-11-09T08:28:32+00:00" + "time": "2024-02-26T07:52:26+00:00" }, { "name": "symfony/console", - "version": "v6.4.1", + "version": "v6.4.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "a550a7c99daeedef3f9d23fb82e3531525ff11fd" + "reference": "0d9e4eb5ad413075624378f474c4167ea202de78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/a550a7c99daeedef3f9d23fb82e3531525ff11fd", - "reference": "a550a7c99daeedef3f9d23fb82e3531525ff11fd", + "url": "https://api.github.com/repos/symfony/console/zipball/0d9e4eb5ad413075624378f474c4167ea202de78", + "reference": "0d9e4eb5ad413075624378f474c4167ea202de78", "shasum": "" }, "require": { @@ -2773,7 +2788,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.1" + "source": "https://github.com/symfony/console/tree/v6.4.4" }, "funding": [ { @@ -2789,20 +2804,20 @@ "type": "tidelift" } ], - "time": "2023-11-30T10:54:28+00:00" + "time": "2024-02-22T20:27:10+00:00" }, { "name": "symfony/css-selector", - "version": "v6.4.0", + "version": "v6.4.3", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "d036c6c0d0b09e24a14a35f8292146a658f986e4" + "reference": "ee0f7ed5cf298cc019431bb3b3977ebc52b86229" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/d036c6c0d0b09e24a14a35f8292146a658f986e4", - "reference": "d036c6c0d0b09e24a14a35f8292146a658f986e4", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/ee0f7ed5cf298cc019431bb3b3977ebc52b86229", + "reference": "ee0f7ed5cf298cc019431bb3b3977ebc52b86229", "shasum": "" }, "require": { @@ -2838,7 +2853,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.4.0" + "source": "https://github.com/symfony/css-selector/tree/v6.4.3" }, "funding": [ { @@ -2854,20 +2869,20 @@ "type": "tidelift" } ], - "time": "2023-10-31T08:40:20+00:00" + "time": "2024-01-23T14:51:35+00:00" }, { "name": "symfony/dependency-injection", - "version": "v6.4.1", + "version": "v6.4.4", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "f88ff6428afbeb17cc648c8003bd608534750baf" + "reference": "6236e5e843cb763e9d0f74245678b994afea5363" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f88ff6428afbeb17cc648c8003bd608534750baf", - "reference": "f88ff6428afbeb17cc648c8003bd608534750baf", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/6236e5e843cb763e9d0f74245678b994afea5363", + "reference": "6236e5e843cb763e9d0f74245678b994afea5363", "shasum": "" }, "require": { @@ -2919,7 +2934,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.4.1" + "source": "https://github.com/symfony/dependency-injection/tree/v6.4.4" }, "funding": [ { @@ -2935,7 +2950,7 @@ "type": "tidelift" } ], - "time": "2023-12-01T14:56:37+00:00" + "time": "2024-02-22T20:27:10+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3006,16 +3021,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v6.4.0", + "version": "v6.4.4", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "14ff4fd2a5c8969d6158dbe7ef5b17d6a9c6ba33" + "reference": "f0e7ec3fa17000e2d0cb4557b4b47c88a6a63531" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/14ff4fd2a5c8969d6158dbe7ef5b17d6a9c6ba33", - "reference": "14ff4fd2a5c8969d6158dbe7ef5b17d6a9c6ba33", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/f0e7ec3fa17000e2d0cb4557b4b47c88a6a63531", + "reference": "f0e7ec3fa17000e2d0cb4557b4b47c88a6a63531", "shasum": "" }, "require": { @@ -3053,7 +3068,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.4.0" + "source": "https://github.com/symfony/dom-crawler/tree/v6.4.4" }, "funding": [ { @@ -3069,20 +3084,20 @@ "type": "tidelift" } ], - "time": "2023-11-20T16:41:16+00:00" + "time": "2024-02-07T09:17:57+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v6.4.0", + "version": "v6.4.3", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "d76d2632cfc2206eecb5ad2b26cd5934082941b6" + "reference": "ae9d3a6f3003a6caf56acd7466d8d52378d44fef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d76d2632cfc2206eecb5ad2b26cd5934082941b6", - "reference": "d76d2632cfc2206eecb5ad2b26cd5934082941b6", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ae9d3a6f3003a6caf56acd7466d8d52378d44fef", + "reference": "ae9d3a6f3003a6caf56acd7466d8d52378d44fef", "shasum": "" }, "require": { @@ -3133,7 +3148,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.3" }, "funding": [ { @@ -3149,7 +3164,7 @@ "type": "tidelift" } ], - "time": "2023-07-27T06:52:43+00:00" + "time": "2024-01-23T14:51:35+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -3229,16 +3244,16 @@ }, { "name": "symfony/filesystem", - "version": "v6.4.0", + "version": "v6.4.3", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "952a8cb588c3bc6ce76f6023000fb932f16a6e59" + "reference": "7f3b1755eb49297a0827a7575d5d2b2fd11cc9fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/952a8cb588c3bc6ce76f6023000fb932f16a6e59", - "reference": "952a8cb588c3bc6ce76f6023000fb932f16a6e59", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7f3b1755eb49297a0827a7575d5d2b2fd11cc9fb", + "reference": "7f3b1755eb49297a0827a7575d5d2b2fd11cc9fb", "shasum": "" }, "require": { @@ -3272,7 +3287,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.0" + "source": "https://github.com/symfony/filesystem/tree/v6.4.3" }, "funding": [ { @@ -3288,20 +3303,20 @@ "type": "tidelift" } ], - "time": "2023-07-26T17:27:13+00:00" + "time": "2024-01-23T14:51:35+00:00" }, { "name": "symfony/http-client", - "version": "v6.4.0", + "version": "v6.4.5", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "5c584530b77aa10ae216989ffc48b4bedc9c0b29" + "reference": "f3c86a60a3615f466333a11fd42010d4382a82c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/5c584530b77aa10ae216989ffc48b4bedc9c0b29", - "reference": "5c584530b77aa10ae216989ffc48b4bedc9c0b29", + "url": "https://api.github.com/repos/symfony/http-client/zipball/f3c86a60a3615f466333a11fd42010d4382a82c7", + "reference": "f3c86a60a3615f466333a11fd42010d4382a82c7", "shasum": "" }, "require": { @@ -3365,7 +3380,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.4.0" + "source": "https://github.com/symfony/http-client/tree/v6.4.5" }, "funding": [ { @@ -3381,7 +3396,7 @@ "type": "tidelift" } ], - "time": "2023-11-28T20:55:58+00:00" + "time": "2024-03-02T12:45:30+00:00" }, { "name": "symfony/http-client-contracts", @@ -3463,16 +3478,16 @@ }, { "name": "symfony/mime", - "version": "v6.4.0", + "version": "v6.4.3", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "ca4f58b2ef4baa8f6cecbeca2573f88cd577d205" + "reference": "5017e0a9398c77090b7694be46f20eb796262a34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/ca4f58b2ef4baa8f6cecbeca2573f88cd577d205", - "reference": "ca4f58b2ef4baa8f6cecbeca2573f88cd577d205", + "url": "https://api.github.com/repos/symfony/mime/zipball/5017e0a9398c77090b7694be46f20eb796262a34", + "reference": "5017e0a9398c77090b7694be46f20eb796262a34", "shasum": "" }, "require": { @@ -3527,7 +3542,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.0" + "source": "https://github.com/symfony/mime/tree/v6.4.3" }, "funding": [ { @@ -3543,20 +3558,20 @@ "type": "tidelift" } ], - "time": "2023-10-17T11:49:05+00:00" + "time": "2024-01-30T08:32:12+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.28.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb" + "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", - "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ef4d7e442ca910c4764bce785146269b30cb5fc4", + "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4", "shasum": "" }, "require": { @@ -3570,9 +3585,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" @@ -3609,7 +3621,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.28.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.29.0" }, "funding": [ { @@ -3625,20 +3637,20 @@ "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2024-01-29T20:11:03+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.28.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "875e90aeea2777b6f135677f618529449334a612" + "reference": "32a9da87d7b3245e09ac426c83d334ae9f06f80f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/875e90aeea2777b6f135677f618529449334a612", - "reference": "875e90aeea2777b6f135677f618529449334a612", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/32a9da87d7b3245e09ac426c83d334ae9f06f80f", + "reference": "32a9da87d7b3245e09ac426c83d334ae9f06f80f", "shasum": "" }, "require": { @@ -3649,9 +3661,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" @@ -3690,7 +3699,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.28.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.29.0" }, "funding": [ { @@ -3706,20 +3715,20 @@ "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2024-01-29T20:11:03+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.28.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "ecaafce9f77234a6a449d29e49267ba10499116d" + "reference": "a287ed7475f85bf6f61890146edbc932c0fff919" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/ecaafce9f77234a6a449d29e49267ba10499116d", - "reference": "ecaafce9f77234a6a449d29e49267ba10499116d", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/a287ed7475f85bf6f61890146edbc932c0fff919", + "reference": "a287ed7475f85bf6f61890146edbc932c0fff919", "shasum": "" }, "require": { @@ -3732,9 +3741,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" @@ -3777,7 +3783,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.28.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.29.0" }, "funding": [ { @@ -3793,20 +3799,20 @@ "type": "tidelift" } ], - "time": "2023-01-26T09:30:37+00:00" + "time": "2024-01-29T20:11:03+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.28.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92" + "reference": "bc45c394692b948b4d383a08d7753968bed9a83d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92", - "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/bc45c394692b948b4d383a08d7753968bed9a83d", + "reference": "bc45c394692b948b4d383a08d7753968bed9a83d", "shasum": "" }, "require": { @@ -3817,9 +3823,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" @@ -3861,7 +3864,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.28.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.29.0" }, "funding": [ { @@ -3877,20 +3880,20 @@ "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2024-01-29T20:11:03+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.28.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "42292d99c55abe617799667f454222c54c60e229" + "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/42292d99c55abe617799667f454222c54c60e229", - "reference": "42292d99c55abe617799667f454222c54c60e229", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9773676c8a1bb1f8d4340a62efe641cf76eda7ec", + "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec", "shasum": "" }, "require": { @@ -3904,9 +3907,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" @@ -3944,7 +3944,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.28.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.29.0" }, "funding": [ { @@ -3960,20 +3960,20 @@ "type": "tidelift" } ], - "time": "2023-07-28T09:04:16+00:00" + "time": "2024-01-29T20:11:03+00:00" }, { "name": "symfony/polyfill-php72", - "version": "v1.28.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "70f4aebd92afca2f865444d30a4d2151c13c3179" + "reference": "861391a8da9a04cbad2d232ddd9e4893220d6e25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/70f4aebd92afca2f865444d30a4d2151c13c3179", - "reference": "70f4aebd92afca2f865444d30a4d2151c13c3179", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/861391a8da9a04cbad2d232ddd9e4893220d6e25", + "reference": "861391a8da9a04cbad2d232ddd9e4893220d6e25", "shasum": "" }, "require": { @@ -3981,9 +3981,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" @@ -4020,7 +4017,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php72/tree/v1.28.0" + "source": "https://github.com/symfony/polyfill-php72/tree/v1.29.0" }, "funding": [ { @@ -4036,20 +4033,20 @@ "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2024-01-29T20:11:03+00:00" }, { "name": "symfony/process", - "version": "v6.4.0", + "version": "v6.4.4", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "191703b1566d97a5425dc969e4350d32b8ef17aa" + "reference": "710e27879e9be3395de2b98da3f52a946039f297" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/191703b1566d97a5425dc969e4350d32b8ef17aa", - "reference": "191703b1566d97a5425dc969e4350d32b8ef17aa", + "url": "https://api.github.com/repos/symfony/process/zipball/710e27879e9be3395de2b98da3f52a946039f297", + "reference": "710e27879e9be3395de2b98da3f52a946039f297", "shasum": "" }, "require": { @@ -4081,7 +4078,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.0" + "source": "https://github.com/symfony/process/tree/v6.4.4" }, "funding": [ { @@ -4097,25 +4094,25 @@ "type": "tidelift" } ], - "time": "2023-11-17T21:06:49+00:00" + "time": "2024-02-20T12:31:00+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.4.0", + "version": "v3.4.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "b3313c2dbffaf71c8de2934e2ea56ed2291a3838" + "reference": "fe07cbc8d837f60caf7018068e350cc5163681a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/b3313c2dbffaf71c8de2934e2ea56ed2291a3838", - "reference": "b3313c2dbffaf71c8de2934e2ea56ed2291a3838", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/fe07cbc8d837f60caf7018068e350cc5163681a0", + "reference": "fe07cbc8d837f60caf7018068e350cc5163681a0", "shasum": "" }, "require": { "php": ">=8.1", - "psr/container": "^2.0" + "psr/container": "^1.1|^2.0" }, "conflict": { "ext-psr": "<1.1|>=2" @@ -4163,7 +4160,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.4.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.4.1" }, "funding": [ { @@ -4179,20 +4176,20 @@ "type": "tidelift" } ], - "time": "2023-07-30T20:28:31+00:00" + "time": "2023-12-26T14:02:43+00:00" }, { "name": "symfony/string", - "version": "v6.4.0", + "version": "v6.4.4", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "b45fcf399ea9c3af543a92edf7172ba21174d809" + "reference": "4e465a95bdc32f49cf4c7f07f751b843bbd6dcd9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/b45fcf399ea9c3af543a92edf7172ba21174d809", - "reference": "b45fcf399ea9c3af543a92edf7172ba21174d809", + "url": "https://api.github.com/repos/symfony/string/zipball/4e465a95bdc32f49cf4c7f07f751b843bbd6dcd9", + "reference": "4e465a95bdc32f49cf4c7f07f751b843bbd6dcd9", "shasum": "" }, "require": { @@ -4249,7 +4246,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.0" + "source": "https://github.com/symfony/string/tree/v6.4.4" }, "funding": [ { @@ -4265,20 +4262,20 @@ "type": "tidelift" } ], - "time": "2023-11-28T20:41:49+00:00" + "time": "2024-02-01T13:16:41+00:00" }, { "name": "symfony/translation", - "version": "v6.4.0", + "version": "v6.4.4", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "b1035dbc2a344b21f8fa8ac451c7ecec4ea45f37" + "reference": "bce6a5a78e94566641b2594d17e48b0da3184a8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/b1035dbc2a344b21f8fa8ac451c7ecec4ea45f37", - "reference": "b1035dbc2a344b21f8fa8ac451c7ecec4ea45f37", + "url": "https://api.github.com/repos/symfony/translation/zipball/bce6a5a78e94566641b2594d17e48b0da3184a8e", + "reference": "bce6a5a78e94566641b2594d17e48b0da3184a8e", "shasum": "" }, "require": { @@ -4301,7 +4298,7 @@ "symfony/translation-implementation": "2.3|3.0" }, "require-dev": { - "nikic/php-parser": "^4.13", + "nikic/php-parser": "^4.18|^5.0", "psr/log": "^1|^2|^3", "symfony/config": "^5.4|^6.0|^7.0", "symfony/console": "^5.4|^6.0|^7.0", @@ -4344,7 +4341,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.0" + "source": "https://github.com/symfony/translation/tree/v6.4.4" }, "funding": [ { @@ -4360,20 +4357,20 @@ "type": "tidelift" } ], - "time": "2023-11-29T08:14:36+00:00" + "time": "2024-02-20T13:16:58+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.4.0", + "version": "v3.4.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "dee0c6e5b4c07ce851b462530088e64b255ac9c5" + "reference": "06450585bf65e978026bda220cdebca3f867fde7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/dee0c6e5b4c07ce851b462530088e64b255ac9c5", - "reference": "dee0c6e5b4c07ce851b462530088e64b255ac9c5", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/06450585bf65e978026bda220cdebca3f867fde7", + "reference": "06450585bf65e978026bda220cdebca3f867fde7", "shasum": "" }, "require": { @@ -4422,7 +4419,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.4.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.4.1" }, "funding": [ { @@ -4438,20 +4435,20 @@ "type": "tidelift" } ], - "time": "2023-07-25T15:08:44+00:00" + "time": "2023-12-26T14:02:43+00:00" }, { "name": "symfony/var-exporter", - "version": "v6.4.1", + "version": "v6.4.4", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "2d08ca6b9cc704dce525615d1e6d1788734f36d9" + "reference": "0bd342e24aef49fc82a21bd4eedd3e665d177e5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/2d08ca6b9cc704dce525615d1e6d1788734f36d9", - "reference": "2d08ca6b9cc704dce525615d1e6d1788734f36d9", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/0bd342e24aef49fc82a21bd4eedd3e665d177e5b", + "reference": "0bd342e24aef49fc82a21bd4eedd3e665d177e5b", "shasum": "" }, "require": { @@ -4497,7 +4494,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v6.4.1" + "source": "https://github.com/symfony/var-exporter/tree/v6.4.4" }, "funding": [ { @@ -4513,20 +4510,20 @@ "type": "tidelift" } ], - "time": "2023-11-30T10:32:10+00:00" + "time": "2024-02-26T08:37:45+00:00" }, { "name": "symfony/yaml", - "version": "v6.4.0", + "version": "v6.4.3", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "4f9237a1bb42455d609e6687d2613dde5b41a587" + "reference": "d75715985f0f94f978e3a8fa42533e10db921b90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/4f9237a1bb42455d609e6687d2613dde5b41a587", - "reference": "4f9237a1bb42455d609e6687d2613dde5b41a587", + "url": "https://api.github.com/repos/symfony/yaml/zipball/d75715985f0f94f978e3a8fa42533e10db921b90", + "reference": "d75715985f0f94f978e3a8fa42533e10db921b90", "shasum": "" }, "require": { @@ -4569,7 +4566,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.4.0" + "source": "https://github.com/symfony/yaml/tree/v6.4.3" }, "funding": [ { @@ -4585,20 +4582,20 @@ "type": "tidelift" } ], - "time": "2023-11-06T11:00:25+00:00" + "time": "2024-01-23T14:51:35+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.2", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96" + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b2ad5003ca10d4ee50a12da31de12a5774ba6b96", - "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", "shasum": "" }, "require": { @@ -4627,7 +4624,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.2" + "source": "https://github.com/theseer/tokenizer/tree/1.2.3" }, "funding": [ { @@ -4635,7 +4632,7 @@ "type": "github" } ], - "time": "2023-11-20T00:12:19+00:00" + "time": "2024-03-03T12:36:25+00:00" } ], "aliases": [], diff --git a/config-dist.php b/config-dist.php index 03dc65179536e..bbc9d840089ba 100644 --- a/config-dist.php +++ b/config-dist.php @@ -346,7 +346,9 @@ // // Redis session handler (requires redis server and redis extension): // $CFG->session_handler_class = '\core\session\redis'; -// $CFG->session_redis_host = '127.0.0.1'; +// $CFG->session_redis_host = '127.0.0.1'; or... // If there is only one host, use the single Redis connection. +// $CFG->session_redis_host = '127.0.0.1:7000,127.0.0.1:7001'; // If there are multiple hosts (separated by a comma), +// // use the Redis cluster connection. // Use TLS to connect to Redis. An array of SSL context options. Usually: // $CFG->session_redis_encrypt = ['cafile' => '/path/to/ca.crt']; or... // $CFG->session_redis_encrypt = ['verify_peer' => false, 'verify_peer_name' => false]; diff --git a/course/classes/hook/after_course_created.php b/course/classes/hook/after_course_created.php new file mode 100644 index 0000000000000..cbfdbe6eb6f94 --- /dev/null +++ b/course/classes/hook/after_course_created.php @@ -0,0 +1,43 @@ +. + +namespace core_course\hook; + +use stdClass; + +/** + * Hook after course creation. + * + * This hook will be dispatched after the course is created and events are fired. + * + * @package core_course + * @copyright 2024 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\core\attribute\label('Allows plugins or features to perform actions after a course is created.')] +#[\core\attribute\tags('course')] +class after_course_created { + + /** + * Constructor for the hook. + * + * @param stdClass $course The course instance. + */ + public function __construct( + public readonly stdClass $course, + ) { + } +} diff --git a/course/classes/hook/after_course_updated.php b/course/classes/hook/after_course_updated.php new file mode 100644 index 0000000000000..176b4e6a6c991 --- /dev/null +++ b/course/classes/hook/after_course_updated.php @@ -0,0 +1,45 @@ +. + +namespace core_course\hook; + +use stdClass; + +/** + * Hook after course updates. + * + * @package core_course + * @copyright 2024 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\core\attribute\label('Allows plugins or features to perform actions after a course is updated.')] +#[\core\attribute\tags('course')] +class after_course_updated { + + /** + * Constructor for the hook. + * + * @param stdClass $course The course instance. + * @param stdClass $oldcourse The old course instance. + * @param bool $changeincoursecat Whether the course category has changed. + */ + public function __construct( + public readonly stdClass $course, + public readonly stdClass $oldcourse, + public readonly bool $changeincoursecat = false, + ) { + } +} diff --git a/course/classes/hook/before_course_delete.php b/course/classes/hook/before_course_delete.php new file mode 100644 index 0000000000000..fdd226619b733 --- /dev/null +++ b/course/classes/hook/before_course_delete.php @@ -0,0 +1,59 @@ +. + +namespace core_course\hook; + +use stdClass; +use Psr\EventDispatcher\StoppableEventInterface; + +/** + * Hook before course deletion. + * + * @package core_course + * @copyright 2024 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\core\attribute\label('Allows plugins or features to perform actions before a course is deleted.')] +#[\core\attribute\tags('course')] +class before_course_delete implements + StoppableEventInterface { + + /** + * @var bool Whether the propagation of this event has been stopped. + */ + protected bool $stopped = false; + + /** + * Constructor for the hook. + * + * @param stdClass $course The course instance. + */ + public function __construct( + public readonly stdClass $course, + ) { + } + + public function isPropagationStopped(): bool { + return $this->stopped; + } + + /** + * Stop the propagation of this event. + */ + public function stop(): void { + $this->stopped = true; + } +} diff --git a/course/edit_form.php b/course/edit_form.php index 0c1879ad9bb53..5a60a5604f4ee 100644 --- a/course/edit_form.php +++ b/course/edit_form.php @@ -435,6 +435,16 @@ function definition() { $mform->addElement('hidden', 'id', null); $mform->setType('id', PARAM_INT); + // Communication api call to set the communication data in the form for handling actions for group feature changes. + // We only need to set the data for courses already created. + if (!empty($course->id)) { + $communication = core_communication\helper::load_by_course( + courseid: $course->id, + context: $coursecontext, + ); + $communication->set_data($course); + } + // Prepare custom fields data. $handler->instance_form_before_set_data($course); // Finally set the current form data diff --git a/course/format/templates/local/content/cm/cmicon.mustache b/course/format/templates/local/content/cm/cmicon.mustache index 31ecc99fe5b32..a56a4162e367f 100644 --- a/course/format/templates/local/content/cm/cmicon.mustache +++ b/course/format/templates/local/content/cm/cmicon.mustache @@ -21,8 +21,6 @@ Example context (json): { - "uservisible": true, - "url": "#", "icon": "../../../pix/help.svg", "iconclass": "", "purpose": "content", @@ -32,22 +30,13 @@ "showtooltip": 1 } }} -{{#url}} - {{#uservisible}} - - {{#cleanstr}} activityicon, moodle, {{{pluginname}}} {{/cleanstr}} - - {{/uservisible}} - {{^uservisible}} -
- {{#cleanstr}} activityicon, moodle, {{{pluginname}}} {{/cleanstr}} -
- {{/uservisible}} -{{/url}} +
+ {{#showtooltip}}{{#cleanstr}} activityicon, moodle, {{{pluginname}}} {{/cleanstr}}{{/showtooltip}} +
diff --git a/course/format/tests/behat/activity_icon_tooltip.feature b/course/format/tests/behat/activity_icon_tooltip.feature index 861ec2fb998e2..75a09e8150337 100644 --- a/course/format/tests/behat/activity_icon_tooltip.feature +++ b/course/format/tests/behat/activity_icon_tooltip.feature @@ -25,20 +25,15 @@ Feature: Activity type tooltip. Scenario: Teacher can see the activity type tooltip only while editing. Given I am on the "C1" "Course" page logged in as "teacher1" - And I hover over the "Assignment icon" "link" in the "Activity sample 1" "activity" - And "body>.tooltip" "css_element" should not exist - And I hover over the "Page icon" "link" in the "Activity sample 2" "activity" - And "body>.tooltip" "css_element" should not exist + And the "title" attribute of "Activity sample 1" "core_courseformat > Activity icon" should not be set + And the "title" attribute of "Activity sample 2" "core_courseformat > Activity icon" should not be set And I turn editing mode on - When I hover over the "Assignment icon" "link" in the "Activity sample 1" "activity" - Then I should see "Assignment" in the "body>.tooltip" "css_element" - And I hover over the "Page icon" "link" in the "Activity sample 2" "activity" - And I should see "Page" in the "body>.tooltip" "css_element" + Then the "title" attribute of "Activity sample 1" "core_courseformat > Activity icon" should contain "Assignment" + And the "title" attribute of "Activity sample 2" "core_courseformat > Activity icon" should contain "Page" Scenario: Student cannot see the activity type tooltip. Given I am on the "C1" "Course" page logged in as "student1" - When I hover over the "Assignment icon" "link" in the "Activity sample 1" "activity" - Then "body>.tooltip" "css_element" should not exist + Then the "title" attribute of "Activity sample 1" "core_courseformat > Activity icon" should not be set Scenario: Student cannot see the activity icon link if does not have access. Given I am on the "Activity sample 2" "page activity editing" page logged in as "admin" diff --git a/course/format/tests/behat/behat_courseformat.php b/course/format/tests/behat/behat_courseformat.php index b03bad1ae4fa9..5dc9ea8848e7d 100644 --- a/course/format/tests/behat/behat_courseformat.php +++ b/course/format/tests/behat/behat_courseformat.php @@ -44,6 +44,9 @@ public static function get_partial_named_selectors(): array { new behat_component_named_selector('Activity visibility', [ ".//*[@data-activityname=%locator%]//*[@data-region='visibility']", ]), + new behat_component_named_selector('Activity icon', [ + ".//*[@data-activityname=%locator%]//*[@data-region='activity-icon']", + ]), ]; } } diff --git a/course/format/tests/external/update_course_test.php b/course/format/tests/external/update_course_test.php index 56d64f889bd80..754b355cafb1e 100644 --- a/course/format/tests/external/update_course_test.php +++ b/course/format/tests/external/update_course_test.php @@ -93,7 +93,7 @@ public function test_execute_course_state( $update = $this->find_update($results, $expected['action'], 'cm', $activity->cmid); $this->assertNotEmpty($update); if ($expected['visible'] === null) { - $this->assertObjectNotHasAttribute('visible', $update->fields); + $this->assertObjectNotHasProperty('visible', $update->fields); } else { $this->assertEquals($expected['visible'], $update->fields->visible); } diff --git a/course/format/tests/output/activitybadge_test.php b/course/format/tests/output/activitybadge_test.php index 98f2dfe3fdde9..b20e955b24b44 100644 --- a/course/format/tests/output/activitybadge_test.php +++ b/course/format/tests/output/activitybadge_test.php @@ -176,31 +176,31 @@ private function check_activitybadge( ?array $extra = null ): void { if (is_null($content)) { - $this->assertObjectNotHasAttribute('badgecontent', $result); + $this->assertObjectNotHasProperty('badgecontent', $result); } else { $this->assertEquals($content, $result->badgecontent); } if (is_null($style)) { - $this->assertObjectNotHasAttribute('badgestyle', $result); + $this->assertObjectNotHasProperty('badgestyle', $result); } else { $this->assertEquals($style, $result->badgestyle); } if (is_null($url)) { - $this->assertObjectNotHasAttribute('badgeurl', $result); + $this->assertObjectNotHasProperty('badgeurl', $result); } else { $this->assertEquals($url, $result->badgeurl); } if (is_null($elementid)) { - $this->assertObjectNotHasAttribute('badgeelementid', $result); + $this->assertObjectNotHasProperty('badgeelementid', $result); } else { $this->assertEquals($elementid, $result->badgeelementid); } if (is_null($extra)) { - $this->assertObjectNotHasAttribute('badgeextraattributes', $result); + $this->assertObjectNotHasProperty('badgeextraattributes', $result); } else { $this->assertEquals($extra, $result->badgeextraattributes); } diff --git a/course/lib.php b/course/lib.php index 40010f4f5f9f9..9bd4414569c1b 100644 --- a/course/lib.php +++ b/course/lib.php @@ -2034,6 +2034,14 @@ function create_course($data, $editoroptions = NULL) { $event->trigger(); + $data->id = $newcourseid; + + // Dispatch the hook for post course create actions. + $hook = new \core_course\hook\after_course_created( + course: $data, + ); + \core\di::get(\core\hook\manager::class)->dispatch($hook); + // Setup the blocks blocks_add_default_course_blocks($course); @@ -2056,33 +2064,6 @@ function create_course($data, $editoroptions = NULL) { if (isset($data->tags)) { core_tag_tag::set_item_tags('core', 'course', $course->id, $context, $data->tags); } - // Set up communication. - if (core_communication\api::is_available()) { - // Check for default provider config setting. - $defaultprovider = get_config('moodlecourse', 'coursecommunicationprovider'); - $provider = (isset($data->selectedcommunication)) ? $data->selectedcommunication : $defaultprovider; - - if (!empty($provider)) { - // Prepare the communication api data. - $courseimage = course_get_courseimage($course); - $communicationroomname = !empty($data->communicationroomname) ? $data->communicationroomname : $data->fullname; - - // Communication api call. - $communication = \core_communication\api::load_by_instance( - context: $context, - component: 'core_course', - instancetype: 'coursecommunication', - instanceid: $course->id, - provider: $provider, - ); - $communication->create_and_configure_room( - $communicationroomname, - $courseimage ?: null, - $data, - ); - } - } - // Save custom fields if there are any of them in the form. $handler = core_course\customfield\course_handler::create(); // Make sure to set the handler's parent context first. @@ -2206,139 +2187,6 @@ function update_course($data, $editoroptions = NULL) { if (isset($data->enablecompletion) && $data->enablecompletion == COMPLETION_DISABLED) { $data->showcompletionconditions = null; } - - // Check if provider is selected. - $provider = $data->selectedcommunication ?? null; - // If the course moved to hidden category, set provider to none. - if ($changesincoursecat && empty($data->visible)) { - $provider = 'none'; - } - - // Attempt to get the communication provider if it wasn't provided in the data. - if (empty($provider) && core_communication\api::is_available()) { - $provider = \core_communication\api::load_by_instance( - context: $context, - component: 'core_course', - instancetype: 'coursecommunication', - instanceid: $data->id, - )->get_provider(); - } - - // Communication api call. - if (!empty($provider) && core_communication\api::is_available()) { - // Prepare the communication api data. - $courseimage = course_get_courseimage($data); - - // This nasty logic is here because of hide course doesn't pass anything in the data object. - if (!empty($data->communicationroomname)) { - $communicationroomname = $data->communicationroomname; - } else { - $communicationroomname = $data->fullname ?? $oldcourse->fullname; - } - - // Update communication room membership of enrolled users. - require_once($CFG->libdir . '/enrollib.php'); - $courseusers = enrol_get_course_users($data->id); - $enrolledusers = []; - - foreach ($courseusers as $user) { - $enrolledusers[] = $user->id; - } - - // Existing communication provider. - $communication = \core_communication\api::load_by_instance( - context: $context, - component: 'core_course', - instancetype: 'coursecommunication', - instanceid: $data->id, - ); - $existingprovider = $communication->get_provider(); - $addusersrequired = false; - $enablenewprovider = false; - $instanceexists = true; - - // Action required changes if provider has changed. - if ($provider !== $existingprovider) { - // Provider changed, flag new one to be enabled. - $enablenewprovider = true; - - // If provider set to none, remove all the members from previous provider. - if ($provider === 'none' && $existingprovider !== '') { - $communication->remove_members_from_room($enrolledusers); - } else if ( - // If previous provider was not none and current provider is not none, - // remove members from previous provider. - $existingprovider !== '' && - $existingprovider !== 'none' - ) { - $communication->remove_members_from_room($enrolledusers); - $addusersrequired = true; - } else if ( - // If previous provider was none and current provider is not none, - // remove members from previous provider. - ($existingprovider === '' || $existingprovider === 'none') - ) { - $addusersrequired = true; - } - - // Disable previous provider, if one was enabled. - if ($existingprovider !== '' && $existingprovider !== 'none') { - $communication->update_room( - active: \core_communication\processor::PROVIDER_INACTIVE, - ); - } - - // Switch to the newly selected provider so it can be updated. - if ($provider !== 'none') { - $communication = \core_communication\api::load_by_instance( - context: $context, - component: 'core_course', - instancetype: 'coursecommunication', - instanceid: $data->id, - provider: $provider, - ); - - // Create it if it does not exist. - if ($communication->get_provider() === '') { - $communication->create_and_configure_room( - communicationroomname: $communicationroomname, - avatar: $courseimage, - instance: $data - ); - - $communication = \core_communication\api::load_by_instance( - context: $context, - component: 'core_course', - instancetype: 'coursecommunication', - instanceid: $data->id, - provider: $provider, - ); - - $addusersrequired = true; - $instanceexists = false; - } - } - } - - if ($provider !== 'none' && $instanceexists) { - // Update the currently enabled provider's room data. - // Newly created providers do not need to run this, the create process handles it. - $communication->update_room( - active: $enablenewprovider ? \core_communication\processor::PROVIDER_ACTIVE : null, - communicationroomname: $communicationroomname, - avatar: $courseimage, - instance: $data, - ); - } - - // Complete room membership tasks if required. - // Newly created providers complete the user mapping but do not queue the task - // (it will be handled by the room creation task). - if ($addusersrequired) { - $communication->add_members_to_room($enrolledusers, $instanceexists); - } - } - // Update custom fields if there are any of them in the form. $handler = core_course\customfield\course_handler::create(); $handler->instance_form_save($data); @@ -2399,6 +2247,14 @@ function update_course($data, $editoroptions = NULL) { $event->trigger(); + // Dispatch the hook for post course update actions. + $hook = new \core_course\hook\after_course_updated( + course: $data, + oldcourse: $oldcourse, + changeincoursecat: $changesincoursecat, + ); + \core\di::get(\core\hook\manager::class)->dispatch($hook); + if ($oldcourse->format !== $course->format) { // Remove all options stored for the previous format // We assume that new course format migrated everything it needed watching trigger @@ -5008,7 +4864,10 @@ function course_get_communication_instance_data(int $courseid): array { */ function course_update_communication_instance_data(stdClass $data): void { $data->id = $data->instanceid; // For correct use in update_course. - update_course($data); + core_communication\helper::update_course_communication_instance( + course: $data, + changesincoursecat: false, + ); } /** diff --git a/course/tests/courselib_test.php b/course/tests/courselib_test.php index 0fa63e9af942d..a03babf7a4f90 100644 --- a/course/tests/courselib_test.php +++ b/course/tests/courselib_test.php @@ -7396,35 +7396,6 @@ public function test_course_get_communication_instance_data(): void { $this->assertEquals($course->fullname, $heading); } - /** - * Test the course_update_communication_instance_data() function. - * - * @covers ::course_update_communication_instance_data - */ - public function test_course_update_communication_instance_data(): void { - $this->resetAfterTest(); - $course = $this->getDataGenerator()->create_course(); - - // Set some data to update with. - $data = new stdClass(); - $data->instanceid = $course->id; - $data->fullname = 'newname'; - - // These should not be the same yet. - $this->assertNotEquals($course->fullname, $data->fullname); - - // Use the callback function to update the course with the data. - component_callback( - 'core_course', - 'update_communication_instance_data', - [$data] - ); - - // Get the course and check it was updated. - $course = get_course($course->id); - $this->assertEquals($course->fullname, $data->fullname); - } - /** * Test course_section_view() function * diff --git a/course/tests/exporters_content_item_test.php b/course/tests/exporters_content_item_test.php index 63b10a33f0e46..307a2e7ec3632 100644 --- a/course/tests/exporters_content_item_test.php +++ b/course/tests/exporters_content_item_test.php @@ -54,23 +54,23 @@ public function test_export_course_content_item() { $renderer = $PAGE->get_renderer('core'); $exporteditem = $ciexporter->export($renderer); - $this->assertObjectHasAttribute('id', $exporteditem); + $this->assertObjectHasProperty('id', $exporteditem); $this->assertEquals($exporteditem->id, $contentitem->get_id()); - $this->assertObjectHasAttribute('name', $exporteditem); + $this->assertObjectHasProperty('name', $exporteditem); $this->assertEquals($exporteditem->name, $contentitem->get_name()); - $this->assertObjectHasAttribute('title', $exporteditem); + $this->assertObjectHasProperty('title', $exporteditem); $this->assertEquals($exporteditem->title, $contentitem->get_title()->get_value()); - $this->assertObjectHasAttribute('link', $exporteditem); + $this->assertObjectHasProperty('link', $exporteditem); $this->assertEquals($exporteditem->link, $contentitem->get_link()->out(false)); - $this->assertObjectHasAttribute('icon', $exporteditem); + $this->assertObjectHasProperty('icon', $exporteditem); $this->assertEquals($exporteditem->icon, $contentitem->get_icon()); - $this->assertObjectHasAttribute('help', $exporteditem); + $this->assertObjectHasProperty('help', $exporteditem); $this->assertEquals($exporteditem->help, format_text($contentitem->get_help(), FORMAT_MARKDOWN)); - $this->assertObjectHasAttribute('archetype', $exporteditem); + $this->assertObjectHasProperty('archetype', $exporteditem); $this->assertEquals($exporteditem->archetype, $contentitem->get_archetype()); - $this->assertObjectHasAttribute('componentname', $exporteditem); + $this->assertObjectHasProperty('componentname', $exporteditem); $this->assertEquals($exporteditem->componentname, $contentitem->get_component_name()); - $this->assertObjectHasAttribute('legacyitem', $exporteditem); + $this->assertObjectHasProperty('legacyitem', $exporteditem); $this->assertFalse($exporteditem->legacyitem); $this->assertEquals($exporteditem->purpose, $contentitem->get_purpose()); $this->assertEquals($exporteditem->branded, $contentitem->is_branded()); @@ -102,24 +102,24 @@ public function test_export_course_content_item_legacy() { $renderer = $PAGE->get_renderer('core'); $exporteditem = $ciexporter->export($renderer); - $this->assertObjectHasAttribute('id', $exporteditem); + $this->assertObjectHasProperty('id', $exporteditem); $this->assertEquals($exporteditem->id, $contentitem->get_id()); - $this->assertObjectHasAttribute('name', $exporteditem); + $this->assertObjectHasProperty('name', $exporteditem); $this->assertEquals($exporteditem->name, $contentitem->get_name()); - $this->assertObjectHasAttribute('title', $exporteditem); + $this->assertObjectHasProperty('title', $exporteditem); $this->assertEquals($exporteditem->title, $contentitem->get_title()->get_value()); - $this->assertObjectHasAttribute('link', $exporteditem); + $this->assertObjectHasProperty('link', $exporteditem); $this->assertEquals($exporteditem->link, $contentitem->get_link()->out(false)); - $this->assertObjectHasAttribute('icon', $exporteditem); + $this->assertObjectHasProperty('icon', $exporteditem); $this->assertEquals($exporteditem->icon, $contentitem->get_icon()); - $this->assertObjectHasAttribute('help', $exporteditem); + $this->assertObjectHasProperty('help', $exporteditem); $this->assertEquals($exporteditem->help, format_text($contentitem->get_help(), FORMAT_MARKDOWN)); - $this->assertObjectHasAttribute('archetype', $exporteditem); + $this->assertObjectHasProperty('archetype', $exporteditem); $this->assertEquals($exporteditem->archetype, $contentitem->get_archetype()); - $this->assertObjectHasAttribute('componentname', $exporteditem); + $this->assertObjectHasProperty('componentname', $exporteditem); $this->assertEquals($exporteditem->componentname, $contentitem->get_component_name()); // Most important, is this a legacy item? - $this->assertObjectHasAttribute('legacyitem', $exporteditem); + $this->assertObjectHasProperty('legacyitem', $exporteditem); $this->assertTrue($exporteditem->legacyitem); } } diff --git a/course/tests/exporters_content_items_test.php b/course/tests/exporters_content_items_test.php index 9e4b6db53bec7..9563a7e587faf 100644 --- a/course/tests/exporters_content_items_test.php +++ b/course/tests/exporters_content_items_test.php @@ -53,16 +53,16 @@ public function test_export_course_content_items() { $renderer = $PAGE->get_renderer('core'); $exportedcontentitems = $ciexporter->export($renderer); - $this->assertObjectHasAttribute('content_items', $exportedcontentitems); + $this->assertObjectHasProperty('content_items', $exportedcontentitems); foreach ($exportedcontentitems->content_items as $key => $dto) { - $this->assertObjectHasAttribute('id', $dto); - $this->assertObjectHasAttribute('name', $dto); - $this->assertObjectHasAttribute('title', $dto); - $this->assertObjectHasAttribute('link', $dto); - $this->assertObjectHasAttribute('icon', $dto); - $this->assertObjectHasAttribute('help', $dto); - $this->assertObjectHasAttribute('archetype', $dto); - $this->assertObjectHasAttribute('componentname', $dto); + $this->assertObjectHasProperty('id', $dto); + $this->assertObjectHasProperty('name', $dto); + $this->assertObjectHasProperty('title', $dto); + $this->assertObjectHasProperty('link', $dto); + $this->assertObjectHasProperty('icon', $dto); + $this->assertObjectHasProperty('help', $dto); + $this->assertObjectHasProperty('archetype', $dto); + $this->assertObjectHasProperty('componentname', $dto); } } } diff --git a/course/tests/privacy/provider_test.php b/course/tests/privacy/provider_test.php index 872bd12d986eb..81fd442c946da 100644 --- a/course/tests/privacy/provider_test.php +++ b/course/tests/privacy/provider_test.php @@ -193,10 +193,10 @@ public function test_export_context_data_module_context_only() { $writer = \core_privacy\local\request\writer::with_context($context1); $this->assertTrue($writer->has_any_data()); $writerdata = $writer->get_data(); - $this->assertObjectHasAttribute('fullname', $writerdata); - $this->assertObjectHasAttribute('shortname', $writerdata); - $this->assertObjectHasAttribute('idnumber', $writerdata); - $this->assertObjectHasAttribute('summary', $writerdata); + $this->assertObjectHasProperty('fullname', $writerdata); + $this->assertObjectHasProperty('shortname', $writerdata); + $this->assertObjectHasProperty('idnumber', $writerdata); + $this->assertObjectHasProperty('summary', $writerdata); } /** @@ -226,10 +226,10 @@ public function test_export_context_data_course_and_module_contexts() { $writer = \core_privacy\local\request\writer::with_context($context1); $this->assertTrue($writer->has_any_data()); $writerdata = $writer->get_data(); - $this->assertObjectHasAttribute('fullname', $writerdata); - $this->assertObjectHasAttribute('shortname', $writerdata); - $this->assertObjectHasAttribute('idnumber', $writerdata); - $this->assertObjectHasAttribute('summary', $writerdata); + $this->assertObjectHasProperty('fullname', $writerdata); + $this->assertObjectHasProperty('shortname', $writerdata); + $this->assertObjectHasProperty('idnumber', $writerdata); + $this->assertObjectHasProperty('summary', $writerdata); } /** diff --git a/course/tests/services_content_item_service_test.php b/course/tests/services_content_item_service_test.php index e438a8dcaa9c4..acdb662c5921f 100644 --- a/course/tests/services_content_item_service_test.php +++ b/course/tests/services_content_item_service_test.php @@ -51,14 +51,14 @@ public function test_get_content_items_for_user_in_course_basic() { $contentitems = $cis->get_content_items_for_user_in_course($user, $course); foreach ($contentitems as $key => $contentitem) { - $this->assertObjectHasAttribute('id', $contentitem); - $this->assertObjectHasAttribute('name', $contentitem); - $this->assertObjectHasAttribute('title', $contentitem); - $this->assertObjectHasAttribute('link', $contentitem); - $this->assertObjectHasAttribute('icon', $contentitem); - $this->assertObjectHasAttribute('help', $contentitem); - $this->assertObjectHasAttribute('archetype', $contentitem); - $this->assertObjectHasAttribute('componentname', $contentitem); + $this->assertObjectHasProperty('id', $contentitem); + $this->assertObjectHasProperty('name', $contentitem); + $this->assertObjectHasProperty('title', $contentitem); + $this->assertObjectHasProperty('link', $contentitem); + $this->assertObjectHasProperty('icon', $contentitem); + $this->assertObjectHasProperty('help', $contentitem); + $this->assertObjectHasProperty('archetype', $contentitem); + $this->assertObjectHasProperty('componentname', $contentitem); } } diff --git a/course/tests/task/content_notification_task_test.php b/course/tests/task/content_notification_task_test.php index 9c88025fd11b8..f5ca17005bfb4 100644 --- a/course/tests/task/content_notification_task_test.php +++ b/course/tests/task/content_notification_task_test.php @@ -120,8 +120,8 @@ public function test_execute(): void { $messagecustomdata = json_decode($message->customdata); $this->assertEquals($course->id, $messagecustomdata->courseid); - $this->assertObjectHasAttribute('notificationiconurl', $messagecustomdata); - $this->assertObjectHasAttribute('notificationpictureurl', $messagecustomdata); + $this->assertObjectHasProperty('notificationiconurl', $messagecustomdata); + $this->assertObjectHasProperty('notificationpictureurl', $messagecustomdata); } // Now, set the course to not visible. diff --git a/course/view.php b/course/view.php index e25c664945d34..900607f011211 100644 --- a/course/view.php +++ b/course/view.php @@ -307,14 +307,8 @@ echo $OUTPUT->header(); // Show communication room status notification. -if (core_communication\api::is_available() && has_capability('moodle/course:update', $context)) { - $communication = \core_communication\api::load_by_instance( - $context, - 'core_course', - 'coursecommunication', - $course->id - ); - $communication->show_communication_room_status_notification(); +if (has_capability('moodle/course:update', $context)) { + core_communication\helper::get_course_communication_status_notification($course); } if ($USER->editing == 1) { diff --git a/enrol/classes/hook/after_enrol_instance_status_updated.php b/enrol/classes/hook/after_enrol_instance_status_updated.php new file mode 100644 index 0000000000000..ceda20103a98f --- /dev/null +++ b/enrol/classes/hook/after_enrol_instance_status_updated.php @@ -0,0 +1,43 @@ +. + +namespace core_enrol\hook; + +use stdClass; + +/** + * Hook after enrolment status is changed. + * + * @package core_enrol + * @copyright 2024 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\core\attribute\label('Allows plugins or features to perform actions after the enrolment instance status is changed.')] +#[\core\attribute\tags('enrol')] +class after_enrol_instance_status_updated { + + /** + * Constructor for the hook. + * + * @param stdClass $enrolinstance The enrol instance. + * @param int $newstatus The new status. + */ + public function __construct( + public readonly stdClass $enrolinstance, + public readonly int $newstatus, + ) { + } +} diff --git a/enrol/classes/hook/after_user_enrolled.php b/enrol/classes/hook/after_user_enrolled.php new file mode 100644 index 0000000000000..3d00294d310f6 --- /dev/null +++ b/enrol/classes/hook/after_user_enrolled.php @@ -0,0 +1,52 @@ +. + +namespace core_enrol\hook; + +use stdClass; + +/** + * Hook after a user is enrolled in a course for an enrolment instance. + * + * @package core_enrol + * @copyright 2024 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\core\attribute\label('Allows plugins or features to perform actions after a user is enrolled in a course.')] +#[\core\attribute\tags('enrol', 'user')] +class after_user_enrolled { + + /** + * Constructor for the hook. + * + * @param stdClass $enrolinstance The enrol instance. + * @param stdClass $userenrolmentinstance The user enrolment instance. + */ + public function __construct( + public readonly stdClass $enrolinstance, + public readonly stdClass $userenrolmentinstance, + ) { + } + + /** + * Get the user id. + * + * @return int + */ + public function get_userid(): int { + return $this->userenrolmentinstance->userid; + } +} diff --git a/enrol/classes/hook/before_enrol_instance_delete.php b/enrol/classes/hook/before_enrol_instance_delete.php new file mode 100644 index 0000000000000..03fff59450edc --- /dev/null +++ b/enrol/classes/hook/before_enrol_instance_delete.php @@ -0,0 +1,59 @@ +. + +namespace core_enrol\hook; + +use stdClass; +use Psr\EventDispatcher\StoppableEventInterface; + +/** + * Hook before enrolment instance is deleted. + * + * @package core_enrol + * @copyright 20234 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\core\attribute\label('Allows plugins or features to perform actions before the enrolment instance is deleted.')] +#[\core\attribute\tags('enrol')] +class before_enrol_instance_delete implements + StoppableEventInterface { + + /** + * @var bool Whether the propagation of this event has been stopped. + */ + protected bool $stopped = false; + + /** + * Constructor for the hook. + * + * @param stdClass $enrolinstance The enrol instance. + */ + public function __construct( + public readonly stdClass $enrolinstance, + ) { + } + + public function isPropagationStopped(): bool { + return $this->stopped; + } + + /** + * Stop the propagation of this event. + */ + public function stop(): void { + $this->stopped = true; + } +} diff --git a/enrol/classes/hook/before_user_enrolment_remove.php b/enrol/classes/hook/before_user_enrolment_remove.php new file mode 100644 index 0000000000000..23df64df94d4d --- /dev/null +++ b/enrol/classes/hook/before_user_enrolment_remove.php @@ -0,0 +1,52 @@ +. + +namespace core_enrol\hook; + +use stdClass; + +/** + * Hook before a user is un-enrolled from a course for an enrolment instance. + * + * @package core + * @copyright 2024 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\core\attribute\label('Allows plugins or features to perform actions before a user enrolment is removed.')] +#[\core\attribute\tags('enrol', 'user')] +class before_user_enrolment_remove { + + /** + * Constructor for the hook. + * + * @param stdClass $enrolinstance The enrol instance. + * @param stdClass $userenrolmentinstance The user enrolment instance. + */ + public function __construct( + public readonly stdClass $enrolinstance, + public readonly stdClass $userenrolmentinstance, + ) { + } + + /** + * Get the user id. + * + * @return int + */ + public function get_userid(): int { + return $this->userenrolmentinstance->userid; + } +} diff --git a/enrol/classes/hook/before_user_enrolment_update.php b/enrol/classes/hook/before_user_enrolment_update.php new file mode 100644 index 0000000000000..38d593ed6afa2 --- /dev/null +++ b/enrol/classes/hook/before_user_enrolment_update.php @@ -0,0 +1,56 @@ +. + +namespace core_enrol\hook; + +use stdClass; + +/** + * Hook before a user enrolment is updated. + * + * @package core_enrol + * @copyright 2024 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\core\attribute\label('Allows plugins or features to perform actions before a user enrolment is updated.')] +#[\core\attribute\tags('enrol', 'user')] +class before_user_enrolment_update { + + /** + * Constructor for the hook. + * + * @param stdClass $enrolinstance The enrol instance. + * @param stdClass $userenrolmentinstance The user enrolment instance. + * @param bool $statusmodified Whether the status of the enrolment has been modified. + * @param bool $timeendmodified Whether the time end of the enrolment has been modified. + */ + public function __construct( + public readonly stdClass $enrolinstance, + public readonly stdClass $userenrolmentinstance, + public readonly bool $statusmodified, + public readonly bool $timeendmodified, + ) { + } + + /** + * Get the user id. + * + * @return int + */ + public function get_userid(): int { + return $this->userenrolmentinstance->userid; + } +} diff --git a/enrol/instances.php b/enrol/instances.php index dc6103087a48a..45e7c376d6aeb 100644 --- a/enrol/instances.php +++ b/enrol/instances.php @@ -118,10 +118,6 @@ die(); } } - // Update communication for instance and given action. - if (core_communication\api::is_available() && $instance->enrol !== 'guest') { - $plugin->update_communication($instance->id, 'remove', $course->id); - } $plugin->delete_instance($instance); redirect($PAGE->url); } @@ -172,10 +168,6 @@ die(); } } - // Update communication for instance and given action. - if (core_communication\api::is_available() && $instance->enrol !== 'guest') { - $plugin->update_communication($instance->id, 'remove', $course->id); - } $plugin->update_status($instance, ENROL_INSTANCE_DISABLED); redirect($PAGE->url); } @@ -187,10 +179,6 @@ $plugin = $plugins[$instance->enrol]; if ($plugin->can_hide_show_instance($instance)) { if ($instance->status != ENROL_INSTANCE_ENABLED) { - // Update communication for instance and given action. - if (core_communication\api::is_available() && $instance->enrol !== 'guest') { - $plugin->update_communication($instance->id, 'add', $course->id); - } $plugin->update_status($instance, ENROL_INSTANCE_ENABLED); redirect($PAGE->url); } diff --git a/enrol/lti/tests/local/ltiadvantage/service/tool_launch_service_test.php b/enrol/lti/tests/local/ltiadvantage/service/tool_launch_service_test.php index 6558e44ec4328..c2239b20c01f6 100644 --- a/enrol/lti/tests/local/ltiadvantage/service/tool_launch_service_test.php +++ b/enrol/lti/tests/local/ltiadvantage/service/tool_launch_service_test.php @@ -514,7 +514,7 @@ public function test_user_launches_tool_force_embedding_custom_param() { // Instructors aren't subject to forceembed. $launchservice->user_launches_tool($instructoruser, $mockinstructorlaunch); - $this->assertObjectNotHasAttribute('forcepagelayout', $SESSION); + $this->assertObjectNotHasProperty('forcepagelayout', $SESSION); // Learners are. $launchservice->user_launches_tool($learneruser, $mocklearnerlaunch); diff --git a/enrol/self/lib.php b/enrol/self/lib.php index 3c14687b18f81..5ecfb19aee105 100644 --- a/enrol/self/lib.php +++ b/enrol/self/lib.php @@ -1039,12 +1039,14 @@ public function edit_instance_validation($data, $files, $instance, $context) { } } - if (($data['expirynotify'] > 0 || $data['customint2']) && $data['expirythreshold'] < 86400) { + if (array_key_exists('expirynotify', $data) + && ($data['expirynotify'] > 0 || $data['customint2']) + && $data['expirythreshold'] < 86400) { $errors['expirythreshold'] = get_string('errorthresholdlow', 'core_enrol'); } // Now these ones are checked by quickforms, but we may be called by the upload enrolments tool, or a webservive. - if (core_text::strlen($data['name']) > 255) { + if (array_key_exists('name', $data) && core_text::strlen($data['name']) > 255) { $errors['name'] = get_string('err_maxlength', 'form', 255); } $validstatus = array_keys($this->get_status_options()); @@ -1072,7 +1074,7 @@ public function edit_instance_validation($data, $files, $instance, $context) { 'expirynotify' => $validexpirynotify, 'roleid' => $validroles ); - if ($data['expirynotify'] != 0) { + if (array_key_exists('expirynotify', $data) && $data['expirynotify'] != 0) { $tovalidate['expirythreshold'] = PARAM_INT; } $typeerrors = $this->validate_param_types($data, $tovalidate); diff --git a/enrol/tests/course_enrolment_manager_test.php b/enrol/tests/course_enrolment_manager_test.php index 01fb9a6b98334..70c4bf36c7b0c 100644 --- a/enrol/tests/course_enrolment_manager_test.php +++ b/enrol/tests/course_enrolment_manager_test.php @@ -296,7 +296,7 @@ public function test_get_users_fields() { $this->assertEquals('Smart suit', $user->imagealt); // But not some random other field like city. - $this->assertObjectNotHasAttribute('city', $user); + $this->assertObjectNotHasProperty('city', $user); } /** @@ -330,7 +330,7 @@ public function test_get_other_users_fields() { $this->assertEquals('Smart suit', $user->imagealt); // But not some random other field like city. - $this->assertObjectNotHasAttribute('city', $user); + $this->assertObjectNotHasProperty('city', $user); } /** @@ -367,7 +367,7 @@ public function test_get_potential_users_fields() { $this->assertEquals('Smart suit', $user->imagealt); // But not some random other field like city. - $this->assertObjectNotHasAttribute('city', $user); + $this->assertObjectNotHasProperty('city', $user); } /** diff --git a/enrol/tests/enrollib_test.php b/enrol/tests/enrollib_test.php index f394ddca05e1d..add5167a5094e 100644 --- a/enrol/tests/enrollib_test.php +++ b/enrol/tests/enrollib_test.php @@ -952,26 +952,26 @@ public function test_enrol_get_my_courses_all_accessible() { $this->assertEquals([$course1->id, $course2->id, $course3->id], array_keys($courses)); // Check fields parameter still works. Fields default (certain base fields). - $this->assertObjectHasAttribute('id', $courses[$course3->id]); - $this->assertObjectHasAttribute('shortname', $courses[$course3->id]); - $this->assertObjectNotHasAttribute('summary', $courses[$course3->id]); + $this->assertObjectHasProperty('id', $courses[$course3->id]); + $this->assertObjectHasProperty('shortname', $courses[$course3->id]); + $this->assertObjectNotHasProperty('summary', $courses[$course3->id]); // Specified fields (one, string). $courses = enrol_get_my_courses('summary', 'id', 0, [], true); - $this->assertObjectHasAttribute('id', $courses[$course3->id]); - $this->assertObjectHasAttribute('shortname', $courses[$course3->id]); - $this->assertObjectHasAttribute('summary', $courses[$course3->id]); - $this->assertObjectNotHasAttribute('summaryformat', $courses[$course3->id]); + $this->assertObjectHasProperty('id', $courses[$course3->id]); + $this->assertObjectHasProperty('shortname', $courses[$course3->id]); + $this->assertObjectHasProperty('summary', $courses[$course3->id]); + $this->assertObjectNotHasProperty('summaryformat', $courses[$course3->id]); // Specified fields (two, string). $courses = enrol_get_my_courses('summary, summaryformat', 'id', 0, [], true); - $this->assertObjectHasAttribute('summary', $courses[$course3->id]); - $this->assertObjectHasAttribute('summaryformat', $courses[$course3->id]); + $this->assertObjectHasProperty('summary', $courses[$course3->id]); + $this->assertObjectHasProperty('summaryformat', $courses[$course3->id]); // Specified fields (two, array). $courses = enrol_get_my_courses(['summary', 'summaryformat'], 'id', 0, [], true); - $this->assertObjectHasAttribute('summary', $courses[$course3->id]); - $this->assertObjectHasAttribute('summaryformat', $courses[$course3->id]); + $this->assertObjectHasProperty('summary', $courses[$course3->id]); + $this->assertObjectHasProperty('summaryformat', $courses[$course3->id]); // By default, courses are ordered by sortorder - which by default is most recent first. $courses = enrol_get_my_courses(null, null, 0, [], true); diff --git a/favourites/tests/repository_test.php b/favourites/tests/repository_test.php index c83efcbcc29c0..d949822eadfc2 100644 --- a/favourites/tests/repository_test.php +++ b/favourites/tests/repository_test.php @@ -67,13 +67,13 @@ public function test_add() { // Verify we get the record back. $this->assertInstanceOf(favourite::class, $favourite); - $this->assertObjectHasAttribute('id', $favourite); + $this->assertObjectHasProperty('id', $favourite); $this->assertEquals('core_course', $favourite->component); $this->assertEquals('course', $favourite->itemtype); // Verify the returned object has additional properties, created as part of the add. - $this->assertObjectHasAttribute('ordering', $favourite); - $this->assertObjectHasAttribute('timecreated', $favourite); + $this->assertObjectHasProperty('ordering', $favourite); + $this->assertObjectHasProperty('timecreated', $favourite); $this->assertGreaterThanOrEqual($timenow, $favourite->timecreated); // Try to save the same record again and confirm the store throws an exception. @@ -137,8 +137,8 @@ public function test_add_all_basic() { $this->assertEquals('course', $favourite->itemtype); // Verify the returned object has additional properties, created as part of the add. - $this->assertObjectHasAttribute('ordering', $favourite); - $this->assertObjectHasAttribute('timecreated', $favourite); + $this->assertObjectHasProperty('ordering', $favourite); + $this->assertObjectHasProperty('timecreated', $favourite); $this->assertGreaterThanOrEqual($timenow, $favourite->timecreated); } @@ -167,7 +167,7 @@ public function test_find() { // Now, from the repo, get the single favourite we just created, by id. $userfavourite = $favouritesrepo->find($favourite->id); $this->assertInstanceOf(favourite::class, $userfavourite); - $this->assertObjectHasAttribute('timecreated', $userfavourite); + $this->assertObjectHasProperty('timecreated', $userfavourite); // Try to get a favourite we know doesn't exist. // We expect an exception in this case. @@ -209,8 +209,8 @@ public function test_find_all() { $this->assertCount(4, $favourites); foreach ($favourites as $fav) { $this->assertInstanceOf(favourite::class, $fav); - $this->assertObjectHasAttribute('id', $fav); - $this->assertObjectHasAttribute('timecreated', $fav); + $this->assertObjectHasProperty('id', $fav); + $this->assertObjectHasProperty('timecreated', $fav); } } diff --git a/favourites/tests/user_favourite_service_test.php b/favourites/tests/user_favourite_service_test.php index 580ba3b8910f2..3743a6583c9a1 100644 --- a/favourites/tests/user_favourite_service_test.php +++ b/favourites/tests/user_favourite_service_test.php @@ -200,7 +200,7 @@ public function test_create_favourite_basic() { // Favourite a course. $favourite1 = $user1service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context); - $this->assertObjectHasAttribute('id', $favourite1); + $this->assertObjectHasProperty('id', $favourite1); // Try to favourite the same course again. $this->expectException('moodle_exception'); diff --git a/grade/amd/build/comboboxsearch/grade.min.js b/grade/amd/build/comboboxsearch/grade.min.js index bba3b15579061..3fd01ec603281 100644 --- a/grade/amd/build/comboboxsearch/grade.min.js +++ b/grade/amd/build/comboboxsearch/grade.min.js @@ -1,3 +1,3 @@ -define("core_grades/comboboxsearch/grade",["exports","core/comboboxsearch/search_combobox","core_grades/searchwidget/repository","core/templates","core/utils"],(function(_exports,_search_combobox,Repository,_templates,_utils){var obj;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)}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_search_combobox=(obj=_search_combobox)&&obj.__esModule?obj:{default:obj},Repository=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}(Repository);class GradeItemSearch extends _search_combobox.default{constructor(){super(),function(obj,key,value){key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value}(this,"courseID",void 0),this.selectors={...this.selectors,courseid:'[data-region="courseid"]',placeholder:'.gradesearchdropdown [data-region="searchplaceholder"]'};const component=document.querySelector(this.componentSelector());this.courseID=component.querySelector(this.selectors.courseid).dataset.courseid,this.renderDefault()}static init(){return new GradeItemSearch}componentSelector(){return".grade-search"}dropdownSelector(){return".gradesearchdropdown"}triggerSelector(){return".gradesearchwidget"}async renderDropdown(){const{html:html,js:js}=await(0,_templates.renderForPromise)("core/local/comboboxsearch/resultset",{results:this.getMatchedResults(),hasresults:this.getMatchedResults().length>0,searchterm:this.getSearchTerm()});(0,_templates.replaceNodeContents)(this.selectors.placeholder,html,js)}async renderDefault(){this.setMatchedResults(await this.filterDataset(await this.getDataset())),this.filterMatchDataset(),await this.renderDropdown(),this.updateNodes(),this.registerInputEvents(),this.$component.on("shown.bs.dropdown",(()=>{this.searchInput.focus({preventScroll:!0})}))}async fetchDataset(){return await Repository.gradeitemFetch(this.courseID).then((r=>r.gradeitems))}async filterDataset(filterableData){return""===this.getPreppedSearchTerm()?filterableData:filterableData.filter((grade=>Object.keys(grade).some((key=>""!==grade[key]&&grade[key].toString().toLowerCase().includes(this.getPreppedSearchTerm())))))}filterMatchDataset(){this.setMatchedResults(this.getMatchedResults().map((grade=>({id:grade.id,name:grade.name,link:this.selectOneLink(grade.id)}))))}registerInputEvents(){this.searchInput.addEventListener("input",(0,_utils.debounce)((async()=>{this.setSearchTerms(this.searchInput.value),""===this.searchInput.value?this.clearSearchButton.classList.add("d-none"):this.clearSearchButton.classList.remove("d-none"),await this.filterrenderpipe()}),300))}async clickHandler(e){e.target.closest(this.selectors.dropdown)&&e.stopImmediatePropagation(),this.clearSearchButton.addEventListener("click",(async()=>{this.searchInput.value="",this.setSearchTerms(this.searchInput.value),await this.filterrenderpipe()})),e.target.closest(".dropdown-item")&&0===e.button&&(window.location=e.target.closest(".dropdown-item").href)}keyHandler(e){switch(super.keyHandler(e),e.key){case"Tab":e.target.closest(this.selectors.input)&&(e.preventDefault(),this.clearSearchButton.focus({preventScroll:!0}));break;case"Escape":if("option"===document.activeElement.getAttribute("role"))e.stopPropagation(),this.searchInput.focus({preventScroll:!0});else if(e.target.closest(this.selectors.input)){this.component.querySelector(this.selectors.trigger).focus({preventScroll:!0})}}}registerInputHandlers(){this.searchInput.addEventListener("input",(0,_utils.debounce)((()=>{this.setSearchTerms(this.searchInput.value),""===this.getSearchTerm()?this.clearSearchButton.classList.add("d-none"):this.clearSearchButton.classList.remove("d-none")}),300))}selectOneLink(gradeID){throw new Error("selectOneLink(".concat(gradeID,") must be implemented in ").concat(this.constructor.name))}}return _exports.default=GradeItemSearch,_exports.default})); +define("core_grades/comboboxsearch/grade",["exports","core/comboboxsearch/search_combobox","core_grades/searchwidget/repository","core/templates","core/utils"],(function(_exports,_search_combobox,Repository,_templates,_utils){var obj;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)}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_search_combobox=(obj=_search_combobox)&&obj.__esModule?obj:{default:obj},Repository=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}(Repository);class GradeItemSearch extends _search_combobox.default{constructor(){super(),function(obj,key,value){key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value}(this,"courseID",void 0),this.selectors={...this.selectors,courseid:'[data-region="courseid"]',placeholder:'.gradesearchdropdown [data-region="searchplaceholder"]'};const component=document.querySelector(this.componentSelector());this.courseID=component.querySelector(this.selectors.courseid).dataset.courseid,this.instance=this.component.querySelector(this.selectors.instance).dataset.instance;const searchValueElement=this.component.querySelector("#".concat(this.searchInput.dataset.inputElement));searchValueElement.addEventListener("change",(()=>{this.toggleDropdown();const valueElement=this.component.querySelector("#".concat(this.combobox.dataset.inputElement));valueElement.value!==searchValueElement.value&&(valueElement.value=searchValueElement.value,valueElement.dispatchEvent(new Event("change",{bubbles:!0}))),searchValueElement.value=""})),this.$component.on("hide.bs.dropdown",(()=>{this.searchInput.removeAttribute("aria-activedescendant");const listbox=document.querySelector("#".concat(this.searchInput.getAttribute("aria-controls"),'[role="listbox"]'));listbox.querySelectorAll('.active[role="option"]').forEach((option=>{option.classList.remove("active")})),listbox.scrollTop=0,setTimeout((()=>{""!==this.searchInput.value&&(this.searchInput.value="",this.searchInput.dispatchEvent(new Event("input",{bubbles:!0})))}))})),this.renderDefault()}static init(){return new GradeItemSearch}componentSelector(){return".grade-search"}dropdownSelector(){return".gradesearchdropdown"}async renderDropdown(){const{html:html,js:js}=await(0,_templates.renderForPromise)("core/local/comboboxsearch/resultset",{instance:this.instance,results:this.getMatchedResults(),hasresults:this.getMatchedResults().length>0,searchterm:this.getSearchTerm()});(0,_templates.replaceNodeContents)(this.selectors.placeholder,html,js),this.searchInput.removeAttribute("aria-activedescendant")}async renderDefault(){this.setMatchedResults(await this.filterDataset(await this.getDataset())),this.filterMatchDataset(),await this.renderDropdown(),this.updateNodes(),this.registerInputEvents()}async fetchDataset(){return await Repository.gradeitemFetch(this.courseID).then((r=>r.gradeitems))}async filterDataset(filterableData){return""===this.getPreppedSearchTerm()?filterableData:filterableData.filter((grade=>Object.keys(grade).some((key=>""!==grade[key]&&grade[key].toString().toLowerCase().includes(this.getPreppedSearchTerm())))))}filterMatchDataset(){this.setMatchedResults(this.getMatchedResults().map((grade=>({id:grade.id,name:grade.name}))))}registerInputEvents(){this.searchInput.addEventListener("input",(0,_utils.debounce)((async()=>{this.setSearchTerms(this.searchInput.value),""===this.searchInput.value?this.clearSearchButton.classList.add("d-none"):this.clearSearchButton.classList.remove("d-none"),await this.filterrenderpipe()}),300))}async clickHandler(e){e.target.closest(this.selectors.clearSearch)&&(e.stopPropagation(),this.searchInput.value="",this.setSearchTerms(this.searchInput.value),this.searchInput.focus(),this.clearSearchButton.classList.add("d-none"),await this.filterrenderpipe())}changeHandler(e){window.location=this.selectOneLink(e.target.value)}registerInputHandlers(){this.searchInput.addEventListener("input",(0,_utils.debounce)((()=>{this.setSearchTerms(this.searchInput.value),""===this.getSearchTerm()?this.clearSearchButton.classList.add("d-none"):this.clearSearchButton.classList.remove("d-none")}),300))}selectOneLink(gradeID){throw new Error("selectOneLink(".concat(gradeID,") must be implemented in ").concat(this.constructor.name))}}return _exports.default=GradeItemSearch,_exports.default})); //# sourceMappingURL=grade.min.js.map \ No newline at end of file diff --git a/grade/amd/build/comboboxsearch/grade.min.js.map b/grade/amd/build/comboboxsearch/grade.min.js.map index 464efafb1a60e..9b0ed4097ed61 100644 --- a/grade/amd/build/comboboxsearch/grade.min.js.map +++ b/grade/amd/build/comboboxsearch/grade.min.js.map @@ -1 +1 @@ -{"version":3,"file":"grade.min.js","sources":["../../src/comboboxsearch/grade.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 * Allow the user to search for grades within the grade area.\n *\n * @module core_grades/comboboxsearch/grade\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport search_combobox from 'core/comboboxsearch/search_combobox';\nimport * as Repository from 'core_grades/searchwidget/repository';\nimport {renderForPromise, replaceNodeContents} from 'core/templates';\nimport {debounce} from 'core/utils';\n\nexport default class GradeItemSearch extends search_combobox {\n\n courseID;\n\n constructor() {\n super();\n\n // Define our standard lookups.\n this.selectors = {\n ...this.selectors,\n courseid: '[data-region=\"courseid\"]',\n placeholder: '.gradesearchdropdown [data-region=\"searchplaceholder\"]',\n };\n const component = document.querySelector(this.componentSelector());\n this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;\n\n this.renderDefault();\n }\n\n static init() {\n return new GradeItemSearch();\n }\n\n /**\n * The overall div that contains the searching widget.\n *\n * @returns {string}\n */\n componentSelector() {\n return '.grade-search';\n }\n\n /**\n * The dropdown div that contains the searching widget result space.\n *\n * @returns {string}\n */\n dropdownSelector() {\n return '.gradesearchdropdown';\n }\n\n /**\n * The triggering div that contains the searching widget.\n *\n * @returns {string}\n */\n triggerSelector() {\n return '.gradesearchwidget';\n }\n\n /**\n * Build the content then replace the node.\n */\n async renderDropdown() {\n const {html, js} = await renderForPromise('core/local/comboboxsearch/resultset', {\n results: this.getMatchedResults(),\n hasresults: this.getMatchedResults().length > 0,\n searchterm: this.getSearchTerm(),\n });\n replaceNodeContents(this.selectors.placeholder, html, js);\n }\n\n /**\n * Build the content then replace the node by default we want our form to exist.\n */\n async renderDefault() {\n this.setMatchedResults(await this.filterDataset(await this.getDataset()));\n this.filterMatchDataset();\n\n await this.renderDropdown();\n\n this.updateNodes();\n this.registerInputEvents();\n\n // Add a small BS listener so that we can set the focus correctly on open.\n this.$component.on('shown.bs.dropdown', () => {\n this.searchInput.focus({preventScroll: true});\n });\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {Promise<*>}\n */\n async fetchDataset() {\n return await Repository.gradeitemFetch(this.courseID).then((r) => r.gradeitems);\n }\n\n /**\n * Dictate to the search component how and what we want to match upon.\n *\n * @param {Array} filterableData\n * @returns {Array} The users that match the given criteria.\n */\n async filterDataset(filterableData) {\n // Sometimes we just want to show everything.\n if (this.getPreppedSearchTerm() === '') {\n return filterableData;\n }\n return filterableData.filter((grade) => Object.keys(grade).some((key) => {\n if (grade[key] === \"\") {\n return false;\n }\n return grade[key].toString().toLowerCase().includes(this.getPreppedSearchTerm());\n }));\n }\n\n /**\n * Given we have a subset of the dataset, set the field that we matched upon to inform the end user.\n */\n filterMatchDataset() {\n this.setMatchedResults(\n this.getMatchedResults().map((grade) => {\n return {\n id: grade.id,\n name: grade.name,\n link: this.selectOneLink(grade.id),\n };\n })\n );\n }\n\n /**\n * Handle any keyboard inputs.\n */\n registerInputEvents() {\n // Register & handle the text input.\n this.searchInput.addEventListener('input', debounce(async() => {\n this.setSearchTerms(this.searchInput.value);\n // We can also require a set amount of input before search.\n if (this.searchInput.value === '') {\n // Hide the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.add('d-none');\n } else {\n // Display the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.remove('d-none');\n }\n // User has given something for us to filter against.\n await this.filterrenderpipe();\n }, 300));\n }\n\n /**\n * The handler for when a user interacts with the component.\n *\n * @param {MouseEvent} e The triggering event that we are working with.\n */\n async clickHandler(e) {\n if (e.target.closest(this.selectors.dropdown)) {\n // Forcibly prevent BS events so that we can control the open and close.\n // Really needed because by default input elements cant trigger a dropdown.\n e.stopImmediatePropagation();\n }\n this.clearSearchButton.addEventListener('click', async() => {\n this.searchInput.value = '';\n this.setSearchTerms(this.searchInput.value);\n await this.filterrenderpipe();\n });\n // Prevent normal key presses activating this.\n if (e.target.closest('.dropdown-item') && e.button === 0) {\n window.location = e.target.closest('.dropdown-item').href;\n }\n }\n\n /**\n * The handler for when a user presses a key within the component.\n *\n * @param {KeyboardEvent} e The triggering event that we are working with.\n */\n keyHandler(e) {\n super.keyHandler(e);\n // Switch the key presses to handle keyboard nav.\n switch (e.key) {\n case 'Tab':\n if (e.target.closest(this.selectors.input)) {\n e.preventDefault();\n this.clearSearchButton.focus({preventScroll: true});\n }\n break;\n case 'Escape':\n if (document.activeElement.getAttribute('role') === 'option') {\n e.stopPropagation();\n this.searchInput.focus({preventScroll: true});\n } else if (e.target.closest(this.selectors.input)) {\n const trigger = this.component.querySelector(this.selectors.trigger);\n trigger.focus({preventScroll: true});\n }\n }\n }\n\n /**\n * Override the input event listener for the text input area.\n */\n registerInputHandlers() {\n // Register & handle the text input.\n this.searchInput.addEventListener('input', debounce(() => {\n this.setSearchTerms(this.searchInput.value);\n // We can also require a set amount of input before search.\n if (this.getSearchTerm() === '') {\n // Hide the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.add('d-none');\n } else {\n // Display the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.remove('d-none');\n }\n }, 300));\n }\n\n /**\n * Build up the view all link that is dedicated to a particular result.\n *\n * @param {Number} gradeID The ID of the grade item selected.\n */\n selectOneLink(gradeID) {\n throw new Error(`selectOneLink(${gradeID}) must be implemented in ${this.constructor.name}`);\n }\n}\n"],"names":["GradeItemSearch","search_combobox","constructor","selectors","this","courseid","placeholder","component","document","querySelector","componentSelector","courseID","dataset","renderDefault","dropdownSelector","triggerSelector","html","js","results","getMatchedResults","hasresults","length","searchterm","getSearchTerm","setMatchedResults","filterDataset","getDataset","filterMatchDataset","renderDropdown","updateNodes","registerInputEvents","$component","on","searchInput","focus","preventScroll","Repository","gradeitemFetch","then","r","gradeitems","filterableData","getPreppedSearchTerm","filter","grade","Object","keys","some","key","toString","toLowerCase","includes","map","id","name","link","selectOneLink","addEventListener","async","setSearchTerms","value","clearSearchButton","classList","add","remove","filterrenderpipe","e","target","closest","dropdown","stopImmediatePropagation","button","window","location","href","keyHandler","input","preventDefault","activeElement","getAttribute","stopPropagation","trigger","registerInputHandlers","gradeID","Error"],"mappings":"i0CA2BqBA,wBAAwBC,yBAIzCC,6LAISC,UAAY,IACVC,KAAKD,UACRE,SAAU,2BACVC,YAAa,gEAEXC,UAAYC,SAASC,cAAcL,KAAKM,0BACzCC,SAAWJ,UAAUE,cAAcL,KAAKD,UAAUE,UAAUO,QAAQP,cAEpEQ,qCAIE,IAAIb,gBAQfU,0BACW,gBAQXI,yBACW,uBAQXC,wBACW,kDAODC,KAACA,KAADC,GAAOA,UAAY,+BAAiB,sCAAuC,CAC7EC,QAASd,KAAKe,oBACdC,WAAYhB,KAAKe,oBAAoBE,OAAS,EAC9CC,WAAYlB,KAAKmB,qDAEDnB,KAAKD,UAAUG,YAAaU,KAAMC,+BAOjDO,wBAAwBpB,KAAKqB,oBAAoBrB,KAAKsB,oBACtDC,2BAECvB,KAAKwB,sBAENC,mBACAC,2BAGAC,WAAWC,GAAG,qBAAqB,UAC/BC,YAAYC,MAAM,CAACC,eAAe,yCAU9BC,WAAWC,eAAejC,KAAKO,UAAU2B,MAAMC,GAAMA,EAAEC,iCASpDC,sBAEoB,KAAhCrC,KAAKsC,uBACED,eAEJA,eAAeE,QAAQC,OAAUC,OAAOC,KAAKF,OAAOG,MAAMC,KAC1C,KAAfJ,MAAMI,MAGHJ,MAAMI,KAAKC,WAAWC,cAAcC,SAAS/C,KAAKsC,4BAOjEf,0BACSH,kBACDpB,KAAKe,oBAAoBiC,KAAKR,QACnB,CACHS,GAAIT,MAAMS,GACVC,KAAMV,MAAMU,KACZC,KAAMnD,KAAKoD,cAAcZ,MAAMS,SAS/CvB,2BAESG,YAAYwB,iBAAiB,SAAS,oBAASC,eAC3CC,eAAevD,KAAK6B,YAAY2B,OAEN,KAA3BxD,KAAK6B,YAAY2B,WAEZC,kBAAkBC,UAAUC,IAAI,eAGhCF,kBAAkBC,UAAUE,OAAO,gBAGtC5D,KAAK6D,qBACZ,yBAQYC,GACXA,EAAEC,OAAOC,QAAQhE,KAAKD,UAAUkE,WAGhCH,EAAEI,gCAEDT,kBAAkBJ,iBAAiB,SAASC,eACxCzB,YAAY2B,MAAQ,QACpBD,eAAevD,KAAK6B,YAAY2B,aAC/BxD,KAAK6D,sBAGXC,EAAEC,OAAOC,QAAQ,mBAAkC,IAAbF,EAAEK,SACxCC,OAAOC,SAAWP,EAAEC,OAAOC,QAAQ,kBAAkBM,MAS7DC,WAAWT,gBACDS,WAAWT,GAETA,EAAElB,SACD,MACGkB,EAAEC,OAAOC,QAAQhE,KAAKD,UAAUyE,SAChCV,EAAEW,sBACGhB,kBAAkB3B,MAAM,CAACC,eAAe,eAGhD,YACmD,WAAhD3B,SAASsE,cAAcC,aAAa,QACpCb,EAAEc,uBACG/C,YAAYC,MAAM,CAACC,eAAe,SACpC,GAAI+B,EAAEC,OAAOC,QAAQhE,KAAKD,UAAUyE,OAAQ,CAC/BxE,KAAKG,UAAUE,cAAcL,KAAKD,UAAU8E,SACpD/C,MAAM,CAACC,eAAe,MAQ9C+C,6BAESjD,YAAYwB,iBAAiB,SAAS,oBAAS,UAC3CE,eAAevD,KAAK6B,YAAY2B,OAER,KAAzBxD,KAAKmB,qBAEAsC,kBAAkBC,UAAUC,IAAI,eAGhCF,kBAAkBC,UAAUE,OAAO,YAE7C,MAQPR,cAAc2B,eACJ,IAAIC,8BAAuBD,4CAAmC/E,KAAKF,YAAYoD"} \ No newline at end of file +{"version":3,"file":"grade.min.js","sources":["../../src/comboboxsearch/grade.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 * Allow the user to search for grades within the grade area.\n *\n * @module core_grades/comboboxsearch/grade\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport search_combobox from 'core/comboboxsearch/search_combobox';\nimport * as Repository from 'core_grades/searchwidget/repository';\nimport {renderForPromise, replaceNodeContents} from 'core/templates';\nimport {debounce} from 'core/utils';\n\nexport default class GradeItemSearch extends search_combobox {\n\n courseID;\n\n constructor() {\n super();\n\n // Define our standard lookups.\n this.selectors = {\n ...this.selectors,\n courseid: '[data-region=\"courseid\"]',\n placeholder: '.gradesearchdropdown [data-region=\"searchplaceholder\"]',\n };\n const component = document.querySelector(this.componentSelector());\n this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;\n this.instance = this.component.querySelector(this.selectors.instance).dataset.instance;\n\n const searchValueElement = this.component.querySelector(`#${this.searchInput.dataset.inputElement}`);\n searchValueElement.addEventListener('change', () => {\n this.toggleDropdown(); // Otherwise the dropdown stays open when user choose an option using keyboard.\n\n const valueElement = this.component.querySelector(`#${this.combobox.dataset.inputElement}`);\n if (valueElement.value !== searchValueElement.value) {\n valueElement.value = searchValueElement.value;\n valueElement.dispatchEvent(new Event('change', {bubbles: true}));\n }\n\n searchValueElement.value = '';\n });\n\n this.$component.on('hide.bs.dropdown', () => {\n this.searchInput.removeAttribute('aria-activedescendant');\n\n const listbox = document.querySelector(`#${this.searchInput.getAttribute('aria-controls')}[role=\"listbox\"]`);\n listbox.querySelectorAll('.active[role=\"option\"]').forEach(option => {\n option.classList.remove('active');\n });\n listbox.scrollTop = 0;\n\n // Use setTimeout to make sure the following code is executed after the click event is handled.\n setTimeout(() => {\n if (this.searchInput.value !== '') {\n this.searchInput.value = '';\n this.searchInput.dispatchEvent(new Event('input', {bubbles: true}));\n }\n });\n });\n\n this.renderDefault();\n }\n\n static init() {\n return new GradeItemSearch();\n }\n\n /**\n * The overall div that contains the searching widget.\n *\n * @returns {string}\n */\n componentSelector() {\n return '.grade-search';\n }\n\n /**\n * The dropdown div that contains the searching widget result space.\n *\n * @returns {string}\n */\n dropdownSelector() {\n return '.gradesearchdropdown';\n }\n\n /**\n * Build the content then replace the node.\n */\n async renderDropdown() {\n const {html, js} = await renderForPromise('core/local/comboboxsearch/resultset', {\n instance: this.instance,\n results: this.getMatchedResults(),\n hasresults: this.getMatchedResults().length > 0,\n searchterm: this.getSearchTerm(),\n });\n replaceNodeContents(this.selectors.placeholder, html, js);\n // Remove aria-activedescendant when the available options change.\n this.searchInput.removeAttribute('aria-activedescendant');\n }\n\n /**\n * Build the content then replace the node by default we want our form to exist.\n */\n async renderDefault() {\n this.setMatchedResults(await this.filterDataset(await this.getDataset()));\n this.filterMatchDataset();\n\n await this.renderDropdown();\n\n this.updateNodes();\n this.registerInputEvents();\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {Promise<*>}\n */\n async fetchDataset() {\n return await Repository.gradeitemFetch(this.courseID).then((r) => r.gradeitems);\n }\n\n /**\n * Dictate to the search component how and what we want to match upon.\n *\n * @param {Array} filterableData\n * @returns {Array} The users that match the given criteria.\n */\n async filterDataset(filterableData) {\n // Sometimes we just want to show everything.\n if (this.getPreppedSearchTerm() === '') {\n return filterableData;\n }\n return filterableData.filter((grade) => Object.keys(grade).some((key) => {\n if (grade[key] === \"\") {\n return false;\n }\n return grade[key].toString().toLowerCase().includes(this.getPreppedSearchTerm());\n }));\n }\n\n /**\n * Given we have a subset of the dataset, set the field that we matched upon to inform the end user.\n */\n filterMatchDataset() {\n this.setMatchedResults(\n this.getMatchedResults().map((grade) => {\n return {\n id: grade.id,\n name: grade.name,\n };\n })\n );\n }\n\n /**\n * Handle any keyboard inputs.\n */\n registerInputEvents() {\n // Register & handle the text input.\n this.searchInput.addEventListener('input', debounce(async() => {\n this.setSearchTerms(this.searchInput.value);\n // We can also require a set amount of input before search.\n if (this.searchInput.value === '') {\n // Hide the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.add('d-none');\n } else {\n // Display the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.remove('d-none');\n }\n // User has given something for us to filter against.\n await this.filterrenderpipe();\n }, 300));\n }\n\n /**\n * The handler for when a user interacts with the component.\n *\n * @param {MouseEvent} e The triggering event that we are working with.\n */\n async clickHandler(e) {\n if (e.target.closest(this.selectors.clearSearch)) {\n e.stopPropagation();\n // Clear the entered search query in the search bar.\n this.searchInput.value = '';\n this.setSearchTerms(this.searchInput.value);\n this.searchInput.focus();\n this.clearSearchButton.classList.add('d-none');\n // Display results.\n await this.filterrenderpipe();\n }\n }\n\n /**\n * The handler for when a user changes the value of the component (selects an option from the dropdown).\n *\n * @param {Event} e The change event.\n */\n changeHandler(e) {\n window.location = this.selectOneLink(e.target.value);\n }\n\n /**\n * Override the input event listener for the text input area.\n */\n registerInputHandlers() {\n // Register & handle the text input.\n this.searchInput.addEventListener('input', debounce(() => {\n this.setSearchTerms(this.searchInput.value);\n // We can also require a set amount of input before search.\n if (this.getSearchTerm() === '') {\n // Hide the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.add('d-none');\n } else {\n // Display the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.remove('d-none');\n }\n }, 300));\n }\n\n /**\n * Build up the view all link that is dedicated to a particular result.\n * We will call this function when a user interacts with the combobox to redirect them to show their results in the page.\n *\n * @param {Number} gradeID The ID of the grade item selected.\n */\n selectOneLink(gradeID) {\n throw new Error(`selectOneLink(${gradeID}) must be implemented in ${this.constructor.name}`);\n }\n}\n"],"names":["GradeItemSearch","search_combobox","constructor","selectors","this","courseid","placeholder","component","document","querySelector","componentSelector","courseID","dataset","instance","searchValueElement","searchInput","inputElement","addEventListener","toggleDropdown","valueElement","combobox","value","dispatchEvent","Event","bubbles","$component","on","removeAttribute","listbox","getAttribute","querySelectorAll","forEach","option","classList","remove","scrollTop","setTimeout","renderDefault","dropdownSelector","html","js","results","getMatchedResults","hasresults","length","searchterm","getSearchTerm","setMatchedResults","filterDataset","getDataset","filterMatchDataset","renderDropdown","updateNodes","registerInputEvents","Repository","gradeitemFetch","then","r","gradeitems","filterableData","getPreppedSearchTerm","filter","grade","Object","keys","some","key","toString","toLowerCase","includes","map","id","name","async","setSearchTerms","clearSearchButton","add","filterrenderpipe","e","target","closest","clearSearch","stopPropagation","focus","changeHandler","window","location","selectOneLink","registerInputHandlers","gradeID","Error"],"mappings":"i0CA2BqBA,wBAAwBC,yBAIzCC,6LAISC,UAAY,IACVC,KAAKD,UACRE,SAAU,2BACVC,YAAa,gEAEXC,UAAYC,SAASC,cAAcL,KAAKM,0BACzCC,SAAWJ,UAAUE,cAAcL,KAAKD,UAAUE,UAAUO,QAAQP,cACpEQ,SAAWT,KAAKG,UAAUE,cAAcL,KAAKD,UAAUU,UAAUD,QAAQC,eAExEC,mBAAqBV,KAAKG,UAAUE,yBAAkBL,KAAKW,YAAYH,QAAQI,eACrFF,mBAAmBG,iBAAiB,UAAU,UACrCC,uBAECC,aAAef,KAAKG,UAAUE,yBAAkBL,KAAKgB,SAASR,QAAQI,eACxEG,aAAaE,QAAUP,mBAAmBO,QAC1CF,aAAaE,MAAQP,mBAAmBO,MACxCF,aAAaG,cAAc,IAAIC,MAAM,SAAU,CAACC,SAAS,MAG7DV,mBAAmBO,MAAQ,WAG1BI,WAAWC,GAAG,oBAAoB,UAC9BX,YAAYY,gBAAgB,+BAE3BC,QAAUpB,SAASC,yBAAkBL,KAAKW,YAAYc,aAAa,sCACzED,QAAQE,iBAAiB,0BAA0BC,SAAQC,SACvDA,OAAOC,UAAUC,OAAO,aAE5BN,QAAQO,UAAY,EAGpBC,YAAW,KACwB,KAA3BhC,KAAKW,YAAYM,aACZN,YAAYM,MAAQ,QACpBN,YAAYO,cAAc,IAAIC,MAAM,QAAS,CAACC,SAAS,iBAKnEa,qCAIE,IAAIrC,gBAQfU,0BACW,gBAQX4B,yBACW,oDAODC,KAACA,KAADC,GAAOA,UAAY,+BAAiB,sCAAuC,CAC7E3B,SAAUT,KAAKS,SACf4B,QAASrC,KAAKsC,oBACdC,WAAYvC,KAAKsC,oBAAoBE,OAAS,EAC9CC,WAAYzC,KAAK0C,qDAED1C,KAAKD,UAAUG,YAAaiC,KAAMC,SAEjDzB,YAAYY,gBAAgB,oDAO5BoB,wBAAwB3C,KAAK4C,oBAAoB5C,KAAK6C,oBACtDC,2BAEC9C,KAAK+C,sBAENC,mBACAC,wDASQC,WAAWC,eAAenD,KAAKO,UAAU6C,MAAMC,GAAMA,EAAEC,iCASpDC,sBAEoB,KAAhCvD,KAAKwD,uBACED,eAEJA,eAAeE,QAAQC,OAAUC,OAAOC,KAAKF,OAAOG,MAAMC,KAC1C,KAAfJ,MAAMI,MAGHJ,MAAMI,KAAKC,WAAWC,cAAcC,SAASjE,KAAKwD,4BAOjEV,0BACSH,kBACD3C,KAAKsC,oBAAoB4B,KAAKR,QACnB,CACHS,GAAIT,MAAMS,GACVC,KAAMV,MAAMU,UAS5BnB,2BAEStC,YAAYE,iBAAiB,SAAS,oBAASwD,eAC3CC,eAAetE,KAAKW,YAAYM,OAEN,KAA3BjB,KAAKW,YAAYM,WAEZsD,kBAAkB1C,UAAU2C,IAAI,eAGhCD,kBAAkB1C,UAAUC,OAAO,gBAGtC9B,KAAKyE,qBACZ,yBAQYC,GACXA,EAAEC,OAAOC,QAAQ5E,KAAKD,UAAU8E,eAChCH,EAAEI,uBAEGnE,YAAYM,MAAQ,QACpBqD,eAAetE,KAAKW,YAAYM,YAChCN,YAAYoE,aACZR,kBAAkB1C,UAAU2C,IAAI,gBAE/BxE,KAAKyE,oBASnBO,cAAcN,GACVO,OAAOC,SAAWlF,KAAKmF,cAAcT,EAAEC,OAAO1D,OAMlDmE,6BAESzE,YAAYE,iBAAiB,SAAS,oBAAS,UAC3CyD,eAAetE,KAAKW,YAAYM,OAER,KAAzBjB,KAAK0C,qBAEA6B,kBAAkB1C,UAAU2C,IAAI,eAGhCD,kBAAkB1C,UAAUC,OAAO,YAE7C,MASPqD,cAAcE,eACJ,IAAIC,8BAAuBD,4CAAmCrF,KAAKF,YAAYsE"} \ No newline at end of file diff --git a/grade/amd/src/comboboxsearch/grade.js b/grade/amd/src/comboboxsearch/grade.js index 93172d4924db3..611258173a7b7 100644 --- a/grade/amd/src/comboboxsearch/grade.js +++ b/grade/amd/src/comboboxsearch/grade.js @@ -40,6 +40,38 @@ export default class GradeItemSearch extends search_combobox { }; const component = document.querySelector(this.componentSelector()); this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid; + this.instance = this.component.querySelector(this.selectors.instance).dataset.instance; + + const searchValueElement = this.component.querySelector(`#${this.searchInput.dataset.inputElement}`); + searchValueElement.addEventListener('change', () => { + this.toggleDropdown(); // Otherwise the dropdown stays open when user choose an option using keyboard. + + const valueElement = this.component.querySelector(`#${this.combobox.dataset.inputElement}`); + if (valueElement.value !== searchValueElement.value) { + valueElement.value = searchValueElement.value; + valueElement.dispatchEvent(new Event('change', {bubbles: true})); + } + + searchValueElement.value = ''; + }); + + this.$component.on('hide.bs.dropdown', () => { + this.searchInput.removeAttribute('aria-activedescendant'); + + const listbox = document.querySelector(`#${this.searchInput.getAttribute('aria-controls')}[role="listbox"]`); + listbox.querySelectorAll('.active[role="option"]').forEach(option => { + option.classList.remove('active'); + }); + listbox.scrollTop = 0; + + // Use setTimeout to make sure the following code is executed after the click event is handled. + setTimeout(() => { + if (this.searchInput.value !== '') { + this.searchInput.value = ''; + this.searchInput.dispatchEvent(new Event('input', {bubbles: true})); + } + }); + }); this.renderDefault(); } @@ -66,25 +98,19 @@ export default class GradeItemSearch extends search_combobox { return '.gradesearchdropdown'; } - /** - * The triggering div that contains the searching widget. - * - * @returns {string} - */ - triggerSelector() { - return '.gradesearchwidget'; - } - /** * Build the content then replace the node. */ async renderDropdown() { const {html, js} = await renderForPromise('core/local/comboboxsearch/resultset', { + instance: this.instance, results: this.getMatchedResults(), hasresults: this.getMatchedResults().length > 0, searchterm: this.getSearchTerm(), }); replaceNodeContents(this.selectors.placeholder, html, js); + // Remove aria-activedescendant when the available options change. + this.searchInput.removeAttribute('aria-activedescendant'); } /** @@ -98,11 +124,6 @@ export default class GradeItemSearch extends search_combobox { this.updateNodes(); this.registerInputEvents(); - - // Add a small BS listener so that we can set the focus correctly on open. - this.$component.on('shown.bs.dropdown', () => { - this.searchInput.focus({preventScroll: true}); - }); } /** @@ -142,7 +163,6 @@ export default class GradeItemSearch extends search_combobox { return { id: grade.id, name: grade.name, - link: this.selectOneLink(grade.id), }; }) ); @@ -174,46 +194,25 @@ export default class GradeItemSearch extends search_combobox { * @param {MouseEvent} e The triggering event that we are working with. */ async clickHandler(e) { - if (e.target.closest(this.selectors.dropdown)) { - // Forcibly prevent BS events so that we can control the open and close. - // Really needed because by default input elements cant trigger a dropdown. - e.stopImmediatePropagation(); - } - this.clearSearchButton.addEventListener('click', async() => { + if (e.target.closest(this.selectors.clearSearch)) { + e.stopPropagation(); + // Clear the entered search query in the search bar. this.searchInput.value = ''; this.setSearchTerms(this.searchInput.value); + this.searchInput.focus(); + this.clearSearchButton.classList.add('d-none'); + // Display results. await this.filterrenderpipe(); - }); - // Prevent normal key presses activating this. - if (e.target.closest('.dropdown-item') && e.button === 0) { - window.location = e.target.closest('.dropdown-item').href; } } /** - * The handler for when a user presses a key within the component. + * The handler for when a user changes the value of the component (selects an option from the dropdown). * - * @param {KeyboardEvent} e The triggering event that we are working with. + * @param {Event} e The change event. */ - keyHandler(e) { - super.keyHandler(e); - // Switch the key presses to handle keyboard nav. - switch (e.key) { - case 'Tab': - if (e.target.closest(this.selectors.input)) { - e.preventDefault(); - this.clearSearchButton.focus({preventScroll: true}); - } - break; - case 'Escape': - if (document.activeElement.getAttribute('role') === 'option') { - e.stopPropagation(); - this.searchInput.focus({preventScroll: true}); - } else if (e.target.closest(this.selectors.input)) { - const trigger = this.component.querySelector(this.selectors.trigger); - trigger.focus({preventScroll: true}); - } - } + changeHandler(e) { + window.location = this.selectOneLink(e.target.value); } /** @@ -236,6 +235,7 @@ export default class GradeItemSearch extends search_combobox { /** * Build up the view all link that is dedicated to a particular result. + * We will call this function when a user interacts with the combobox to redirect them to show their results in the page. * * @param {Number} gradeID The ID of the grade item selected. */ diff --git a/grade/renderer.php b/grade/renderer.php index 202843e24a2ef..8beccd2549f7a 100644 --- a/grade/renderer.php +++ b/grade/renderer.php @@ -45,12 +45,18 @@ public function render_action_bar(action_bar $actionbar): string { * Renders the group selector trigger element. * * @param object $course The course object. - * @param string|null $groupactionbaseurl The base URL for the group action. + * @param string|null $groupactionbaseurl This parameter has been deprecated since 4.4 and should not be used anymore. * @return string|null The raw HTML to render. */ public function group_selector(object $course, ?string $groupactionbaseurl = null): ?string { global $USER; + if ($groupactionbaseurl !== null) { + debugging( + 'The $groupactionbaseurl argument has been deprecated. Please remove it from your method calls.', + DEBUG_DEVELOPER, + ); + } // Make sure that group mode is enabled. if (!$groupmode = $course->groupmode) { return null; @@ -59,17 +65,12 @@ public function group_selector(object $course, ?string $groupactionbaseurl = nul $sbody = $this->render_from_template('core_group/comboboxsearch/searchbody', [ 'courseid' => $course->id, 'currentvalue' => optional_param('groupsearchvalue', '', PARAM_NOTAGS), + 'instance' => rand(), ]); - $label = $groupmode == VISIBLEGROUPS ? get_string('selectgroupsvisible') : - get_string('selectgroupsseparate'); + $label = $groupmode == VISIBLEGROUPS ? get_string('selectgroupsvisible') : get_string('selectgroupsseparate'); - $data = [ - 'name' => 'group', - 'label' => $label, - 'courseid' => $course->id, - 'groupactionbaseurl' => $groupactionbaseurl - ]; + $buttondata = ['label' => $label]; $context = context_course::instance($course->id); @@ -80,22 +81,27 @@ public function group_selector(object $course, ?string $groupactionbaseurl = nul } $activegroup = groups_get_course_group($course, true, $allowedgroups); - $data['group'] = $activegroup; + $buttondata['group'] = $activegroup; if ($activegroup) { $group = groups_get_group($activegroup); - $data['selectedgroup'] = format_string($group->name, true, ['context' => $context]); + $buttondata['selectedgroup'] = format_string($group->name, true, ['context' => $context]); } else if ($activegroup === 0) { - $data['selectedgroup'] = get_string('allparticipants'); + $buttondata['selectedgroup'] = get_string('allparticipants'); } $groupdropdown = new comboboxsearch( false, - $this->render_from_template('core_group/comboboxsearch/group_selector', $data), + $this->render_from_template('core_group/comboboxsearch/group_selector', $buttondata), $sbody, 'group-search', 'groupsearchwidget', - 'groupsearchdropdown overflow-auto w-100', + 'groupsearchdropdown overflow-auto', + null, + true, + $label, + 'group', + $activegroup ); return $this->render_from_template($groupdropdown->get_template(), $groupdropdown->export_for_template($this)); } diff --git a/grade/report/grader/amd/build/collapse.min.js b/grade/report/grader/amd/build/collapse.min.js index d465a83330ea2..feb982860fd86 100644 --- a/grade/report/grader/amd/build/collapse.min.js +++ b/grade/report/grader/amd/build/collapse.min.js @@ -1,3 +1,3 @@ -define("gradereport_grader/collapse",["exports","gradereport_grader/collapse/repository","core/comboboxsearch/search_combobox","core/templates","core/utils","jquery","core/str","core/custom_interaction_events","core/localstorage","core/loadingicon","core/notification","core/pending"],(function(_exports,Repository,_search_combobox,_templates,_utils,_jquery,_str,_custom_interaction_events,_localstorage,_loadingicon,_notification,_pending){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}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)}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,Repository=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}(Repository),_search_combobox=_interopRequireDefault(_search_combobox),_jquery=_interopRequireDefault(_jquery),_custom_interaction_events=_interopRequireDefault(_custom_interaction_events),_localstorage=_interopRequireDefault(_localstorage),_notification=_interopRequireDefault(_notification),_pending=_interopRequireDefault(_pending);const selectors_component=".collapse-columns",selectors_formDropdown=".columnsdropdownform",selectors_formItems={cancel:"cancel",save:"save",checked:'input[type="checkbox"]:checked',currentlyUnchecked:'input[type="checkbox"]:not([data-action="selectall"])'},selectors_hider="hide",selectors_expand="expand",selectors_colVal="[data-col]",selectors_itemVal="[data-itemid]",selectors_content='[data-collapse="content"]',selectors_sort='[data-collapse="sort"]',selectors_expandbutton='[data-collapse="expandbutton"]',selectors_rangerowcell='[data-collapse="rangerowcell"]',selectors_avgrowcell='[data-collapse="avgrowcell"]',selectors_menu='[data-collapse="menu"]',selectors_icons=".data-collapse_gradeicons",selectors_count='[data-collapse="count"]',selectors_placeholder='.collapsecolumndropdown [data-region="placeholder"]',selectors_fullDropdown=".collapsecolumndropdown",countIndicator=document.querySelector(selectors_count);class ColumnSearch extends _search_combobox.default{static init(userID,courseID,defaultSort){return new ColumnSearch(userID,courseID,defaultSort)}constructor(userID,courseID,defaultSort){super(),_defineProperty(this,"userID",-1),_defineProperty(this,"courseID",null),_defineProperty(this,"defaultSort",""),_defineProperty(this,"nodes",[]),_defineProperty(this,"gradeStrings",null),_defineProperty(this,"userStrings",null),_defineProperty(this,"stringMap",[]),this.userID=userID,this.courseID=courseID,this.defaultSort=defaultSort,this.component=document.querySelector(selectors_component);const pendingPromise=new _pending.default;(0,_loadingicon.addIconToContainer)(document.querySelector(".gradeparent")).then((loader=>{setTimeout((()=>{this.getDataset().forEach((item=>{this.nodesUpdate(item)})),this.renderDefault(),loader.remove(),document.querySelector(".gradereport-grader-table").classList.remove("d-none")}),10)})).then((()=>pendingPromise.resolve())).catch(_notification.default.exception)}componentSelector(){return".collapse-columns"}dropdownSelector(){return".searchresultitemscontainer"}triggerSelector(){return".collapsecolumn"}getDataset(){if(!this.dataset){const cols=this.fetchDataset();this.dataset=JSON.parse(cols)?JSON.parse(cols).split(","):[]}return this.datasetSize=this.dataset.length,this.dataset}fetchDataset(){return _localstorage.default.get("gradereport_grader_collapseditems_".concat(this.courseID,"_").concat(this.userID))}setPreferences(){_localstorage.default.set("gradereport_grader_collapseditems_".concat(this.courseID,"_").concat(this.userID),JSON.stringify(this.getDataset().join(",")))}registerClickHandlers(){this.component.addEventListener("click",this.clickHandler.bind(this)),document.addEventListener("click",this.docClickHandler.bind(this))}clickHandler(e){super.clickHandler(e),e.target.closest(selectors_fullDropdown)&&e.stopPropagation()}async docClickHandler(e){var _e$target$closest3;if(e.target.dataset.hider===selectors_hider){var _e$target$closest,_e$target$closest2;e.preventDefault();const desiredToHide=e.target.closest(selectors_colVal)?null===(_e$target$closest=e.target.closest(selectors_colVal))||void 0===_e$target$closest?void 0:_e$target$closest.dataset.col:null===(_e$target$closest2=e.target.closest(selectors_itemVal))||void 0===_e$target$closest2?void 0:_e$target$closest2.dataset.itemid;-1===this.getDataset().indexOf(desiredToHide)&&this.getDataset().push(desiredToHide),await this.prefcountpipe(),this.nodesUpdate(desiredToHide)}if((null===(_e$target$closest3=e.target.closest("button"))||void 0===_e$target$closest3?void 0:_e$target$closest3.dataset.hider)===selectors_expand){var _e$target$closest4,_e$target$closest5,_e$target$closest6,_e$target$closest7;e.preventDefault();const desiredToHide=e.target.closest(selectors_colVal)?null===(_e$target$closest4=e.target.closest(selectors_colVal))||void 0===_e$target$closest4?void 0:_e$target$closest4.dataset.col:null===(_e$target$closest5=e.target.closest(selectors_itemVal))||void 0===_e$target$closest5?void 0:_e$target$closest5.dataset.itemid,idx=this.getDataset().indexOf(desiredToHide);this.getDataset().splice(idx,1),await this.prefcountpipe(),this.nodesUpdate(null===(_e$target$closest6=e.target.closest(selectors_colVal))||void 0===_e$target$closest6?void 0:_e$target$closest6.dataset.col),this.nodesUpdate(null===(_e$target$closest7=e.target.closest(selectors_colVal))||void 0===_e$target$closest7?void 0:_e$target$closest7.dataset.itemid)}}async keyHandler(e){if(super.keyHandler(e),"Tab"===e.key)e.target.closest(this.selectors.input)&&(e.preventDefault(),this.clearSearchButton.focus({preventScroll:!0}))}registerInputEvents(){this.searchInput.addEventListener("input",(0,_utils.debounce)((async()=>{if(this.getSearchTerm()===this.searchInput.value&&this.searchResultsVisible())return void window.console.warn("Search term matches input value - skipping");this.setSearchTerms(this.searchInput.value),""===this.searchInput.value?this.clearSearchButton.classList.add("d-none"):this.clearSearchButton.classList.remove("d-none");const pendingPromise=new _pending.default;await this.filterrenderpipe().then((()=>(pendingPromise.resolve(),!0)))}),300,{pending:!0}))}registerFormEvents(){const form=this.component.querySelector(selectors_formDropdown),events=["click",_custom_interaction_events.default.events.activate,_custom_interaction_events.default.events.keyboardActivate];_custom_interaction_events.default.define(document,events);const selectall=form.querySelector('[data-action="selectall"]');events.forEach((event=>{const submitBtn=form.querySelector('[data-action="'.concat(selectors_formItems.save,'"'));form.addEventListener(event,(e=>{e.stopPropagation();const input=e.target.closest("input");if(input){selectall.checked&&!input.checked&&(selectall.checked=!1);const checkedCount=Array.from(form.querySelectorAll(selectors_formItems.checked)).length;submitBtn.disabled=checkedCount<=0}}),!1),this.searchInput.addEventListener(event,(e=>e.stopPropagation())),this.clearSearchButton.addEventListener(event,(async e=>{e.stopPropagation(),this.searchInput.value="",this.setSearchTerms(this.searchInput.value),await this.filterrenderpipe()})),selectall.addEventListener(event,(e=>{if(e.stopPropagation(),selectall.checked){Array.from(form.querySelectorAll(selectors_formItems.currentlyUnchecked)).forEach((item=>{item.checked=!0})),submitBtn.disabled=!1}else{Array.from(form.querySelectorAll(selectors_formItems.checked)).forEach((item=>{item.checked=!1})),submitBtn.disabled=!0}}))})),form.addEventListener("submit",(async e=>{if(e.preventDefault(),e.submitter.dataset.action===selectors_formItems.cancel)return void(0,_jquery.default)(this.component).dropdown("toggle");[...form.elements].filter((item=>item.checked)).forEach((item=>{const idx=this.getDataset().indexOf(item.dataset.collapse);this.getDataset().splice(idx,1),this.nodesUpdate(item.dataset.collapse)})),selectall.checked=!1,e.submitter.disabled=!0,await this.prefcountpipe()}))}nodesUpdate(item){const colNodesToHide=[...document.querySelectorAll('[data-col="'.concat(item,'"]'))],itemIDNodesToHide=[...document.querySelectorAll('[data-itemid="'.concat(item,'"]'))];this.nodes=[...colNodesToHide,...itemIDNodesToHide],this.updateDisplay()}async prefcountpipe(){this.setPreferences(),this.countUpdate(),await this.filterrenderpipe()}async filterDataset(filterableData){const stringUserMap=await this.fetchRequiredUserStrings(),stringGradeMap=await this.fetchRequiredGradeStrings(),customFieldMap=this.fetchCustomFieldValues();this.stringMap=new Map([...stringGradeMap,...stringUserMap,...customFieldMap]);const searching=filterableData.map((s=>{var _mapObj$itemname,_mapObj$category;const mapObj=this.stringMap.get(s);return void 0===mapObj?{key:s,string:s}:{key:s,string:null!==(_mapObj$itemname=mapObj.itemname)&&void 0!==_mapObj$itemname?_mapObj$itemname:this.stringMap.get(s),category:null!==(_mapObj$category=mapObj.category)&&void 0!==_mapObj$category?_mapObj$category:""}}));return""===this.getPreppedSearchTerm()?searching:searching.filter((col=>col.string.toString().toLowerCase().includes(this.getPreppedSearchTerm())))}filterMatchDataset(){this.setMatchedResults(this.getMatchedResults().map((column=>{var _column$string,_column$category;return{name:column.key,displayName:null!==(_column$string=column.string)&&void 0!==_column$string?_column$string:column.key,category:null!==(_column$category=column.category)&&void 0!==_column$category?_column$category:""}})))}updateDisplay(){this.nodes.forEach((element=>{const content=element.querySelector(selectors_content),sort=element.querySelector(selectors_sort),expandButton=element.querySelector(selectors_expandbutton),rangeRowCell=element.querySelector(selectors_rangerowcell),avgRowCell=element.querySelector(selectors_avgrowcell),nodeSet=[element.querySelector(selectors_menu),element.querySelector(selectors_icons),content];if(element.classList.contains("cell"))if(null!==sort&&(window.location=this.defaultSort),null===content){const rowCell=null!=avgRowCell?avgRowCell:rangeRowCell;null==rowCell||rowCell.classList.toggle("d-none"),null==rowCell||rowCell.setAttribute("aria-hidden",null!=rowCell&&rowCell.classList.contains("d-none")?"true":"false")}else content.classList.contains("d-none")?(element.classList.remove("collapsed"),content.childNodes.length>1&&content.classList.add("d-flex"),nodeSet.forEach((node=>{null==node||node.classList.remove("d-none"),null==node||node.setAttribute("aria-hidden","false")})),null==expandButton||expandButton.classList.add("d-none"),null==expandButton||expandButton.setAttribute("aria-hidden","true")):(element.classList.add("collapsed"),content.classList.remove("d-flex"),nodeSet.forEach((node=>{null==node||node.classList.add("d-none"),null==node||node.setAttribute("aria-hidden","true")})),null==expandButton||expandButton.classList.remove("d-none"),null==expandButton||expandButton.setAttribute("aria-hidden","false"))}))}countUpdate(){countIndicator.textContent=this.getDatasetSize(),this.getDatasetSize()>0?(this.component.parentElement.classList.add("d-flex"),this.component.parentElement.classList.remove("d-none")):(this.component.parentElement.classList.remove("d-flex"),this.component.parentElement.classList.add("d-none"))}async renderDefault(){this.setMatchedResults(await this.filterDataset(this.getDataset())),this.filterMatchDataset(),this.countUpdate();const{html:html,js:js}=await(0,_templates.renderForPromise)("gradereport_grader/collapse/collapsebody",{results:this.getMatchedResults(),userid:this.userID});(0,_templates.replaceNode)(selectors_placeholder,html,js),this.updateNodes(),this.registerFormEvents(),this.registerInputEvents(),this.$component.on("shown.bs.dropdown",(()=>{this.searchInput.focus({preventScroll:!0})}))}async renderDropdown(){const selectall=this.component.querySelector(selectors_formDropdown).querySelector('[data-action="selectall"]'),{html:html,js:js}=await(0,_templates.renderForPromise)("gradereport_grader/collapse/collapseresults",{results:this.getMatchedResults(),searchTerm:this.getSearchTerm()});selectall.disabled=0===this.getMatchedResults().length,(0,_templates.replaceNodeContents)(this.getHTMLElements().searchDropdown,html,js)}fetchCustomFieldValues(){return[...document.querySelectorAll("[data-collapse-name]")].map((field=>[field.parentElement.dataset.col,field.dataset.collapseName]))}fetchRequiredUserStrings(){if(!this.userStrings){const requiredStrings=["username","firstname","lastname","email","city","country","department","institution","idnumber","phone1","phone2"];this.userStrings=(0,_str.getStrings)(requiredStrings.map((key=>({key:key})))).then((stringArray=>new Map(requiredStrings.map(((key,index)=>[key,stringArray[index]])))))}return this.userStrings}fetchRequiredGradeStrings(){return this.gradeStrings||(this.gradeStrings=Repository.gradeItems(this.courseID).then((result=>new Map(result.gradeItems.map((key=>[key.id,key])))))),this.gradeStrings}}return _exports.default=ColumnSearch,_exports.default})); +define("gradereport_grader/collapse",["exports","gradereport_grader/collapse/repository","core/comboboxsearch/search_combobox","core/templates","core/utils","jquery","core/str","core/custom_interaction_events","core/localstorage","core/loadingicon","core/notification","core/pending"],(function(_exports,Repository,_search_combobox,_templates,_utils,_jquery,_str,_custom_interaction_events,_localstorage,_loadingicon,_notification,_pending){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}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)}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,Repository=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}(Repository),_search_combobox=_interopRequireDefault(_search_combobox),_jquery=_interopRequireDefault(_jquery),_custom_interaction_events=_interopRequireDefault(_custom_interaction_events),_localstorage=_interopRequireDefault(_localstorage),_notification=_interopRequireDefault(_notification),_pending=_interopRequireDefault(_pending);const selectors_component=".collapse-columns",selectors_formDropdown=".columnsdropdownform",selectors_formItems={cancel:"cancel",save:"save",checked:'input[type="checkbox"]:checked',currentlyUnchecked:'input[type="checkbox"]:not([data-action="selectall"])'},selectors_hider="hide",selectors_expand="expand",selectors_colVal="[data-col]",selectors_itemVal="[data-itemid]",selectors_content='[data-collapse="content"]',selectors_sort='[data-collapse="sort"]',selectors_expandbutton='[data-collapse="expandbutton"]',selectors_rangerowcell='[data-collapse="rangerowcell"]',selectors_avgrowcell='[data-collapse="avgrowcell"]',selectors_menu='[data-collapse="menu"]',selectors_icons=".data-collapse_gradeicons",selectors_count='[data-collapse="count"]',selectors_placeholder='.collapsecolumndropdown [data-region="placeholder"]',selectors_fullDropdown=".collapsecolumndropdown",selectors_searchResultContainer=".searchresultitemscontainer",countIndicator=document.querySelector(selectors_count);class ColumnSearch extends _search_combobox.default{static init(userID,courseID,defaultSort){return new ColumnSearch(userID,courseID,defaultSort)}constructor(userID,courseID,defaultSort){super(),_defineProperty(this,"userID",-1),_defineProperty(this,"courseID",null),_defineProperty(this,"defaultSort",""),_defineProperty(this,"nodes",[]),_defineProperty(this,"gradeStrings",null),_defineProperty(this,"userStrings",null),_defineProperty(this,"stringMap",[]),this.userID=userID,this.courseID=courseID,this.defaultSort=defaultSort,this.component=document.querySelector(selectors_component);const pendingPromise=new _pending.default;(0,_loadingicon.addIconToContainer)(document.querySelector(".gradeparent")).then((loader=>{setTimeout((()=>{this.getDataset().forEach((item=>{this.nodesUpdate(item)})),this.renderDefault(),loader.remove(),document.querySelector(".gradereport-grader-table").classList.remove("d-none")}),10)})).then((()=>pendingPromise.resolve())).catch(_notification.default.exception),this.$component.on("hide.bs.dropdown",(()=>{this.component.querySelector(selectors_searchResultContainer).scrollTop=0,setTimeout((()=>{""!==this.searchInput.value&&(this.searchInput.value="",this.searchInput.dispatchEvent(new Event("input",{bubbles:!0})))}))}))}componentSelector(){return".collapse-columns"}dropdownSelector(){return".searchresultitemscontainer"}getDataset(){if(!this.dataset){const cols=this.fetchDataset();this.dataset=JSON.parse(cols)?JSON.parse(cols).split(","):[]}return this.datasetSize=this.dataset.length,this.dataset}fetchDataset(){return _localstorage.default.get("gradereport_grader_collapseditems_".concat(this.courseID,"_").concat(this.userID))}setPreferences(){_localstorage.default.set("gradereport_grader_collapseditems_".concat(this.courseID,"_").concat(this.userID),JSON.stringify(this.getDataset().join(",")))}registerClickHandlers(){this.component.addEventListener("click",this.clickHandler.bind(this)),document.addEventListener("click",this.docClickHandler.bind(this))}clickHandler(e){super.clickHandler(e),e.target.closest(selectors_fullDropdown)&&e.stopPropagation()}async docClickHandler(e){var _e$target$closest3;if(e.target.dataset.hider===selectors_hider){var _e$target$closest,_e$target$closest2;e.preventDefault();const desiredToHide=e.target.closest(selectors_colVal)?null===(_e$target$closest=e.target.closest(selectors_colVal))||void 0===_e$target$closest?void 0:_e$target$closest.dataset.col:null===(_e$target$closest2=e.target.closest(selectors_itemVal))||void 0===_e$target$closest2?void 0:_e$target$closest2.dataset.itemid;-1===this.getDataset().indexOf(desiredToHide)&&this.getDataset().push(desiredToHide),await this.prefcountpipe(),this.nodesUpdate(desiredToHide)}if((null===(_e$target$closest3=e.target.closest("button"))||void 0===_e$target$closest3?void 0:_e$target$closest3.dataset.hider)===selectors_expand){var _e$target$closest4,_e$target$closest5,_e$target$closest6,_e$target$closest7;e.preventDefault();const desiredToHide=e.target.closest(selectors_colVal)?null===(_e$target$closest4=e.target.closest(selectors_colVal))||void 0===_e$target$closest4?void 0:_e$target$closest4.dataset.col:null===(_e$target$closest5=e.target.closest(selectors_itemVal))||void 0===_e$target$closest5?void 0:_e$target$closest5.dataset.itemid,idx=this.getDataset().indexOf(desiredToHide);this.getDataset().splice(idx,1),await this.prefcountpipe(),this.nodesUpdate(null===(_e$target$closest6=e.target.closest(selectors_colVal))||void 0===_e$target$closest6?void 0:_e$target$closest6.dataset.col),this.nodesUpdate(null===(_e$target$closest7=e.target.closest(selectors_colVal))||void 0===_e$target$closest7?void 0:_e$target$closest7.dataset.itemid)}}registerInputEvents(){this.searchInput.addEventListener("input",(0,_utils.debounce)((async()=>{if(this.getSearchTerm()===this.searchInput.value&&this.searchResultsVisible())return void window.console.warn("Search term matches input value - skipping");this.setSearchTerms(this.searchInput.value),""===this.searchInput.value?this.clearSearchButton.classList.add("d-none"):this.clearSearchButton.classList.remove("d-none");const pendingPromise=new _pending.default;await this.filterrenderpipe().then((()=>(pendingPromise.resolve(),!0)))}),300,{pending:!0}))}registerFormEvents(){const form=this.component.querySelector(selectors_formDropdown),events=["click",_custom_interaction_events.default.events.activate,_custom_interaction_events.default.events.keyboardActivate];_custom_interaction_events.default.define(document,events);const selectall=form.querySelector('[data-action="selectall"]');events.forEach((event=>{const submitBtn=form.querySelector('[data-action="'.concat(selectors_formItems.save,'"'));form.addEventListener(event,(e=>{e.stopPropagation();const input=e.target.closest("input");if(input){selectall.checked&&!input.checked&&(selectall.checked=!1);const checkedCount=Array.from(form.querySelectorAll(selectors_formItems.checked)).length;submitBtn.disabled=checkedCount<=0}}),!1),this.searchInput.addEventListener(event,(e=>e.stopPropagation())),this.clearSearchButton.addEventListener(event,(async e=>{e.stopPropagation(),this.searchInput.value="",this.setSearchTerms(this.searchInput.value),await this.filterrenderpipe()})),selectall.addEventListener(event,(e=>{if(e.stopPropagation(),selectall.checked){Array.from(form.querySelectorAll(selectors_formItems.currentlyUnchecked)).forEach((item=>{item.checked=!0})),submitBtn.disabled=!1}else{Array.from(form.querySelectorAll(selectors_formItems.checked)).forEach((item=>{item.checked=!1})),submitBtn.disabled=!0}}))})),form.addEventListener("submit",(async e=>{if(e.preventDefault(),e.submitter.dataset.action===selectors_formItems.cancel)return void(0,_jquery.default)(this.component).dropdown("toggle");[...form.elements].filter((item=>item.checked)).forEach((item=>{const idx=this.getDataset().indexOf(item.dataset.collapse);this.getDataset().splice(idx,1),this.nodesUpdate(item.dataset.collapse)})),selectall.checked=!1,e.submitter.disabled=!0,await this.prefcountpipe()}))}nodesUpdate(item){const colNodesToHide=[...document.querySelectorAll('[data-col="'.concat(item,'"]'))],itemIDNodesToHide=[...document.querySelectorAll('[data-itemid="'.concat(item,'"]'))];this.nodes=[...colNodesToHide,...itemIDNodesToHide],this.updateDisplay()}async prefcountpipe(){this.setPreferences(),this.countUpdate(),await this.filterrenderpipe()}async filterDataset(filterableData){const stringUserMap=await this.fetchRequiredUserStrings(),stringGradeMap=await this.fetchRequiredGradeStrings(),customFieldMap=this.fetchCustomFieldValues();this.stringMap=new Map([...stringGradeMap,...stringUserMap,...customFieldMap]);const searching=filterableData.map((s=>{var _mapObj$itemname,_mapObj$category;const mapObj=this.stringMap.get(s);return void 0===mapObj?{key:s,string:s}:{key:s,string:null!==(_mapObj$itemname=mapObj.itemname)&&void 0!==_mapObj$itemname?_mapObj$itemname:this.stringMap.get(s),category:null!==(_mapObj$category=mapObj.category)&&void 0!==_mapObj$category?_mapObj$category:""}}));return""===this.getPreppedSearchTerm()?searching:searching.filter((col=>col.string.toString().toLowerCase().includes(this.getPreppedSearchTerm())))}filterMatchDataset(){this.setMatchedResults(this.getMatchedResults().map((column=>{var _column$string,_column$category;return{name:column.key,displayName:null!==(_column$string=column.string)&&void 0!==_column$string?_column$string:column.key,category:null!==(_column$category=column.category)&&void 0!==_column$category?_column$category:""}})))}updateDisplay(){this.nodes.forEach((element=>{const content=element.querySelector(selectors_content),sort=element.querySelector(selectors_sort),expandButton=element.querySelector(selectors_expandbutton),rangeRowCell=element.querySelector(selectors_rangerowcell),avgRowCell=element.querySelector(selectors_avgrowcell),nodeSet=[element.querySelector(selectors_menu),element.querySelector(selectors_icons),content];if(element.classList.contains("cell"))if(null!==sort&&(window.location=this.defaultSort),null===content){const rowCell=null!=avgRowCell?avgRowCell:rangeRowCell;null==rowCell||rowCell.classList.toggle("d-none"),null==rowCell||rowCell.setAttribute("aria-hidden",null!=rowCell&&rowCell.classList.contains("d-none")?"true":"false")}else content.classList.contains("d-none")?(element.classList.remove("collapsed"),content.childNodes.length>1&&content.classList.add("d-flex"),nodeSet.forEach((node=>{null==node||node.classList.remove("d-none"),null==node||node.setAttribute("aria-hidden","false")})),null==expandButton||expandButton.classList.add("d-none"),null==expandButton||expandButton.setAttribute("aria-hidden","true")):(element.classList.add("collapsed"),content.classList.remove("d-flex"),nodeSet.forEach((node=>{null==node||node.classList.add("d-none"),null==node||node.setAttribute("aria-hidden","true")})),null==expandButton||expandButton.classList.remove("d-none"),null==expandButton||expandButton.setAttribute("aria-hidden","false"))}))}countUpdate(){countIndicator.textContent=this.getDatasetSize(),this.getDatasetSize()>0?(this.component.parentElement.classList.add("d-flex"),this.component.parentElement.classList.remove("d-none")):(this.component.parentElement.classList.remove("d-flex"),this.component.parentElement.classList.add("d-none"))}async renderDefault(){this.setMatchedResults(await this.filterDataset(this.getDataset())),this.filterMatchDataset(),this.countUpdate();const{html:html,js:js}=await(0,_templates.renderForPromise)("gradereport_grader/collapse/collapsebody",{instance:this.instance,results:this.getMatchedResults(),userid:this.userID});(0,_templates.replaceNode)(selectors_placeholder,html,js),this.updateNodes(),this.registerFormEvents(),this.registerInputEvents(),this.$component.on("shown.bs.dropdown",(()=>{this.searchInput.focus({preventScroll:!0}),this.selectallEnable()}))}async renderDropdown(){const{html:html,js:js}=await(0,_templates.renderForPromise)("gradereport_grader/collapse/collapseresults",{instance:this.instance,results:this.getMatchedResults(),searchTerm:this.getSearchTerm()});(0,_templates.replaceNodeContents)(this.getHTMLElements().searchDropdown,html,js),this.selectallEnable();this.component.querySelector(selectors_formDropdown).querySelector('[data-action="'.concat(selectors_formItems.save,'"')).disabled=!0}selectallEnable(){this.component.querySelector(selectors_formDropdown).querySelector('[data-action="selectall"]').disabled=0===this.getMatchedResults().length}fetchCustomFieldValues(){return[...document.querySelectorAll("[data-collapse-name]")].map((field=>[field.parentElement.dataset.col,field.dataset.collapseName]))}fetchRequiredUserStrings(){if(!this.userStrings){const requiredStrings=["username","firstname","lastname","email","city","country","department","institution","idnumber","phone1","phone2"];this.userStrings=(0,_str.getStrings)(requiredStrings.map((key=>({key:key})))).then((stringArray=>new Map(requiredStrings.map(((key,index)=>[key,stringArray[index]])))))}return this.userStrings}fetchRequiredGradeStrings(){return this.gradeStrings||(this.gradeStrings=Repository.gradeItems(this.courseID).then((result=>new Map(result.gradeItems.map((key=>[key.id,key])))))),this.gradeStrings}}return _exports.default=ColumnSearch,_exports.default})); //# sourceMappingURL=collapse.min.js.map \ No newline at end of file diff --git a/grade/report/grader/amd/build/collapse.min.js.map b/grade/report/grader/amd/build/collapse.min.js.map index 877f26953a490..0530684ac1c93 100644 --- a/grade/report/grader/amd/build/collapse.min.js.map +++ b/grade/report/grader/amd/build/collapse.min.js.map @@ -1 +1 @@ -{"version":3,"file":"collapse.min.js","sources":["../src/collapse.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 * Allow the user to show and hide columns of the report at will.\n *\n * @module gradereport_grader/collapse\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport * as Repository from 'gradereport_grader/collapse/repository';\nimport search_combobox from 'core/comboboxsearch/search_combobox';\nimport {renderForPromise, replaceNodeContents, replaceNode} from 'core/templates';\nimport {debounce} from 'core/utils';\nimport $ from 'jquery';\nimport {getStrings} from 'core/str';\nimport CustomEvents from \"core/custom_interaction_events\";\nimport storage from 'core/localstorage';\nimport {addIconToContainer} from 'core/loadingicon';\nimport Notification from 'core/notification';\nimport Pending from 'core/pending';\n\n// Contain our selectors within this file until they could be of use elsewhere.\nconst selectors = {\n component: '.collapse-columns',\n formDropdown: '.columnsdropdownform',\n formItems: {\n cancel: 'cancel',\n save: 'save',\n checked: 'input[type=\"checkbox\"]:checked',\n currentlyUnchecked: 'input[type=\"checkbox\"]:not([data-action=\"selectall\"])',\n },\n hider: 'hide',\n expand: 'expand',\n colVal: '[data-col]',\n itemVal: '[data-itemid]',\n content: '[data-collapse=\"content\"]',\n sort: '[data-collapse=\"sort\"]',\n expandbutton: '[data-collapse=\"expandbutton\"]',\n rangerowcell: '[data-collapse=\"rangerowcell\"]',\n avgrowcell: '[data-collapse=\"avgrowcell\"]',\n menu: '[data-collapse=\"menu\"]',\n icons: '.data-collapse_gradeicons',\n count: '[data-collapse=\"count\"]',\n placeholder: '.collapsecolumndropdown [data-region=\"placeholder\"]',\n fullDropdown: '.collapsecolumndropdown',\n};\n\nconst countIndicator = document.querySelector(selectors.count);\n\nexport default class ColumnSearch extends search_combobox {\n\n userID = -1;\n courseID = null;\n defaultSort = '';\n\n nodes = [];\n\n gradeStrings = null;\n userStrings = null;\n stringMap = [];\n\n static init(userID, courseID, defaultSort) {\n return new ColumnSearch(userID, courseID, defaultSort);\n }\n\n constructor(userID, courseID, defaultSort) {\n super();\n this.userID = userID;\n this.courseID = courseID;\n this.defaultSort = defaultSort;\n this.component = document.querySelector(selectors.component);\n\n const pendingPromise = new Pending();\n // Display a loader whilst collapsing appropriate columns (based on the locally stored state for the current user).\n addIconToContainer(document.querySelector('.gradeparent')).then((loader) => {\n setTimeout(() => {\n // Get the users' checked columns to change.\n this.getDataset().forEach((item) => {\n this.nodesUpdate(item);\n });\n this.renderDefault();\n\n // Once the grade categories have been re-collapsed, remove the loader and display the Gradebook setup content.\n loader.remove();\n document.querySelector('.gradereport-grader-table').classList.remove('d-none');\n }, 10);\n }).then(() => pendingPromise.resolve()).catch(Notification.exception);\n }\n\n /**\n * The overall div that contains the searching widget.\n *\n * @returns {string}\n */\n componentSelector() {\n return '.collapse-columns';\n }\n\n /**\n * The dropdown div that contains the searching widget result space.\n *\n * @returns {string}\n */\n dropdownSelector() {\n return '.searchresultitemscontainer';\n }\n\n /**\n * The triggering div that contains the searching widget.\n *\n * @returns {string}\n */\n triggerSelector() {\n return '.collapsecolumn';\n }\n\n /**\n * Return the dataset that we will be searching upon.\n *\n * @returns {Array}\n */\n getDataset() {\n if (!this.dataset) {\n const cols = this.fetchDataset();\n this.dataset = JSON.parse(cols) ? JSON.parse(cols).split(',') : [];\n }\n this.datasetSize = this.dataset.length;\n return this.dataset;\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {string}\n */\n fetchDataset() {\n return storage.get(`gradereport_grader_collapseditems_${this.courseID}_${this.userID}`);\n }\n\n /**\n * Given a user performs an action, update the users' preferences.\n */\n setPreferences() {\n storage.set(`gradereport_grader_collapseditems_${this.courseID}_${this.userID}`,\n JSON.stringify(this.getDataset().join(','))\n );\n }\n\n /**\n * Register clickable event listeners.\n */\n registerClickHandlers() {\n // Register click events within the component.\n this.component.addEventListener('click', this.clickHandler.bind(this));\n\n document.addEventListener('click', this.docClickHandler.bind(this));\n }\n\n /**\n * The handler for when a user interacts with the component.\n *\n * @param {MouseEvent} e The triggering event that we are working with.\n */\n clickHandler(e) {\n super.clickHandler(e);\n // Prevent BS from closing the dropdown if they click elsewhere within the dropdown besides the form.\n if (e.target.closest(selectors.fullDropdown)) {\n e.stopPropagation();\n }\n }\n\n /**\n * Externally defined click function to improve memory handling.\n *\n * @param {MouseEvent} e\n * @returns {Promise}\n */\n async docClickHandler(e) {\n if (e.target.dataset.hider === selectors.hider) {\n e.preventDefault();\n const desiredToHide = e.target.closest(selectors.colVal) ?\n e.target.closest(selectors.colVal)?.dataset.col :\n e.target.closest(selectors.itemVal)?.dataset.itemid;\n const idx = this.getDataset().indexOf(desiredToHide);\n if (idx === -1) {\n this.getDataset().push(desiredToHide);\n }\n await this.prefcountpipe();\n\n this.nodesUpdate(desiredToHide);\n }\n\n if (e.target.closest('button')?.dataset.hider === selectors.expand) {\n e.preventDefault();\n const desiredToHide = e.target.closest(selectors.colVal) ?\n e.target.closest(selectors.colVal)?.dataset.col :\n e.target.closest(selectors.itemVal)?.dataset.itemid;\n const idx = this.getDataset().indexOf(desiredToHide);\n this.getDataset().splice(idx, 1);\n\n await this.prefcountpipe();\n\n this.nodesUpdate(e.target.closest(selectors.colVal)?.dataset.col);\n this.nodesUpdate(e.target.closest(selectors.colVal)?.dataset.itemid);\n }\n }\n\n /**\n * The handler for when a user presses a key within the component.\n *\n * @param {KeyboardEvent} e The triggering event that we are working with.\n */\n async keyHandler(e) {\n super.keyHandler(e);\n\n // Switch the key presses to handle keyboard nav.\n switch (e.key) {\n case 'Tab':\n if (e.target.closest(this.selectors.input)) {\n e.preventDefault();\n this.clearSearchButton.focus({preventScroll: true});\n }\n break;\n }\n }\n\n /**\n * Handle any keyboard inputs.\n */\n registerInputEvents() {\n // Register & handle the text input.\n this.searchInput.addEventListener('input', debounce(async() => {\n if (this.getSearchTerm() === this.searchInput.value && this.searchResultsVisible()) {\n window.console.warn(`Search term matches input value - skipping`);\n // Debounce can happen multiple times quickly.\n return;\n }\n this.setSearchTerms(this.searchInput.value);\n // We can also require a set amount of input before search.\n if (this.searchInput.value === '') {\n // Hide the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.add('d-none');\n } else {\n // Display the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.remove('d-none');\n }\n const pendingPromise = new Pending();\n // User has given something for us to filter against.\n await this.filterrenderpipe().then(() => {\n pendingPromise.resolve();\n return true;\n });\n }, 300, {pending: true}));\n }\n\n /**\n * Handle the form submission within the dropdown.\n */\n registerFormEvents() {\n const form = this.component.querySelector(selectors.formDropdown);\n const events = [\n 'click',\n CustomEvents.events.activate,\n CustomEvents.events.keyboardActivate\n ];\n CustomEvents.define(document, events);\n\n const selectall = form.querySelector('[data-action=\"selectall\"]');\n\n // Register clicks & keyboard form handling.\n events.forEach((event) => {\n const submitBtn = form.querySelector(`[data-action=\"${selectors.formItems.save}\"`);\n form.addEventListener(event, (e) => {\n // Stop Bootstrap from being clever.\n e.stopPropagation();\n const input = e.target.closest('input');\n if (input) {\n // If the user is unchecking an item, we need to uncheck the select all if it's checked.\n if (selectall.checked && !input.checked) {\n selectall.checked = false;\n }\n const checkedCount = Array.from(form.querySelectorAll(selectors.formItems.checked)).length;\n // Check if any are clicked or not then change disabled.\n submitBtn.disabled = checkedCount <= 0;\n }\n }, false);\n\n // Stop Bootstrap from being clever.\n this.searchInput.addEventListener(event, e => e.stopPropagation());\n this.clearSearchButton.addEventListener(event, async(e) => {\n e.stopPropagation();\n this.searchInput.value = '';\n this.setSearchTerms(this.searchInput.value);\n await this.filterrenderpipe();\n });\n selectall.addEventListener(event, (e) => {\n // Stop Bootstrap from being clever.\n e.stopPropagation();\n if (!selectall.checked) {\n const touncheck = Array.from(form.querySelectorAll(selectors.formItems.checked));\n touncheck.forEach(item => {\n item.checked = false;\n });\n submitBtn.disabled = true;\n } else {\n const currentUnchecked = Array.from(form.querySelectorAll(selectors.formItems.currentlyUnchecked));\n currentUnchecked.forEach(item => {\n item.checked = true;\n });\n submitBtn.disabled = false;\n }\n });\n });\n\n form.addEventListener('submit', async(e) => {\n e.preventDefault();\n if (e.submitter.dataset.action === selectors.formItems.cancel) {\n $(this.component).dropdown('toggle');\n return;\n }\n // Get the users' checked columns to change.\n const checkedItems = [...form.elements].filter(item => item.checked);\n checkedItems.forEach((item) => {\n const idx = this.getDataset().indexOf(item.dataset.collapse);\n this.getDataset().splice(idx, 1);\n this.nodesUpdate(item.dataset.collapse);\n });\n // Reset the check all & submit to false just in case.\n selectall.checked = false;\n e.submitter.disabled = true;\n await this.prefcountpipe();\n });\n }\n\n nodesUpdate(item) {\n const colNodesToHide = [...document.querySelectorAll(`[data-col=\"${item}\"]`)];\n const itemIDNodesToHide = [...document.querySelectorAll(`[data-itemid=\"${item}\"]`)];\n this.nodes = [...colNodesToHide, ...itemIDNodesToHide];\n this.updateDisplay();\n }\n\n /**\n * Update the user preferences, count display then render the results.\n *\n * @returns {Promise}\n */\n async prefcountpipe() {\n this.setPreferences();\n this.countUpdate();\n await this.filterrenderpipe();\n }\n\n /**\n * Dictate to the search component how and what we want to match upon.\n *\n * @param {Array} filterableData\n * @returns {Array} An array of objects containing the system reference and the user readable value.\n */\n async filterDataset(filterableData) {\n const stringUserMap = await this.fetchRequiredUserStrings();\n const stringGradeMap = await this.fetchRequiredGradeStrings();\n // Custom user profile fields are not in our string map and need a bit of extra love.\n const customFieldMap = this.fetchCustomFieldValues();\n this.stringMap = new Map([...stringGradeMap, ...stringUserMap, ...customFieldMap]);\n\n const searching = filterableData.map(s => {\n const mapObj = this.stringMap.get(s);\n if (mapObj === undefined) {\n return {key: s, string: s};\n }\n return {\n key: s,\n string: mapObj.itemname ?? this.stringMap.get(s),\n category: mapObj.category ?? '',\n };\n });\n // Sometimes we just want to show everything.\n if (this.getPreppedSearchTerm() === '') {\n return searching;\n }\n // Other times we want to actually filter the content.\n return searching.filter((col) => {\n return col.string.toString().toLowerCase().includes(this.getPreppedSearchTerm());\n });\n }\n\n /**\n * Given we have a subset of the dataset, set the field that we matched upon to inform the end user.\n */\n filterMatchDataset() {\n this.setMatchedResults(\n this.getMatchedResults().map((column) => {\n return {\n name: column.key,\n displayName: column.string ?? column.key,\n category: column.category ?? '',\n };\n })\n );\n }\n\n /**\n * With an array of nodes, switch their classes and values.\n */\n updateDisplay() {\n this.nodes.forEach((element) => {\n const content = element.querySelector(selectors.content);\n const sort = element.querySelector(selectors.sort);\n const expandButton = element.querySelector(selectors.expandbutton);\n const rangeRowCell = element.querySelector(selectors.rangerowcell);\n const avgRowCell = element.querySelector(selectors.avgrowcell);\n const nodeSet = [\n element.querySelector(selectors.menu),\n element.querySelector(selectors.icons),\n content\n ];\n\n // This can be further improved to reduce redundant similar calls.\n if (element.classList.contains('cell')) {\n // The column is actively being sorted, lets reset that and reload the page.\n if (sort !== null) {\n window.location = this.defaultSort;\n }\n if (content === null) {\n // If it's not a content cell, it must be an overall average or a range cell.\n const rowCell = avgRowCell ?? rangeRowCell;\n\n rowCell?.classList.toggle('d-none');\n rowCell?.setAttribute('aria-hidden',\n rowCell?.classList.contains('d-none') ? 'true' : 'false');\n } else if (content.classList.contains('d-none')) {\n // We should always have content but some cells do not contain menus or other actions.\n element.classList.remove('collapsed');\n // If there are many nodes, apply the following.\n if (content.childNodes.length > 1) {\n content.classList.add('d-flex');\n }\n nodeSet.forEach(node => {\n node?.classList.remove('d-none');\n node?.setAttribute('aria-hidden', 'false');\n });\n expandButton?.classList.add('d-none');\n expandButton?.setAttribute('aria-hidden', 'true');\n } else {\n element.classList.add('collapsed');\n content.classList.remove('d-flex');\n nodeSet.forEach(node => {\n node?.classList.add('d-none');\n node?.setAttribute('aria-hidden', 'true');\n });\n expandButton?.classList.remove('d-none');\n expandButton?.setAttribute('aria-hidden', 'false');\n }\n }\n });\n }\n\n /**\n * Update the visual count of collapsed columns or hide the count all together.\n */\n countUpdate() {\n countIndicator.textContent = this.getDatasetSize();\n if (this.getDatasetSize() > 0) {\n this.component.parentElement.classList.add('d-flex');\n this.component.parentElement.classList.remove('d-none');\n } else {\n this.component.parentElement.classList.remove('d-flex');\n this.component.parentElement.classList.add('d-none');\n }\n }\n\n /**\n * Build the content then replace the node by default we want our form to exist.\n */\n async renderDefault() {\n this.setMatchedResults(await this.filterDataset(this.getDataset()));\n this.filterMatchDataset();\n\n // Update the collapsed button pill.\n this.countUpdate();\n const {html, js} = await renderForPromise('gradereport_grader/collapse/collapsebody', {\n 'results': this.getMatchedResults(),\n 'userid': this.userID,\n });\n replaceNode(selectors.placeholder, html, js);\n this.updateNodes();\n\n // Given we now have the body, we can set up more triggers.\n this.registerFormEvents();\n this.registerInputEvents();\n\n // Add a small BS listener so that we can set the focus correctly on open.\n this.$component.on('shown.bs.dropdown', () => {\n this.searchInput.focus({preventScroll: true});\n });\n }\n\n /**\n * Build the content then replace the node.\n */\n async renderDropdown() {\n const form = this.component.querySelector(selectors.formDropdown);\n const selectall = form.querySelector('[data-action=\"selectall\"]');\n const {html, js} = await renderForPromise('gradereport_grader/collapse/collapseresults', {\n 'results': this.getMatchedResults(),\n 'searchTerm': this.getSearchTerm(),\n });\n selectall.disabled = this.getMatchedResults().length === 0;\n replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);\n }\n\n /**\n * If we have any custom user profile fields, grab their system & readable names to add to our string map.\n *\n * @returns {array} An array of associated string arrays ready for our map.\n */\n fetchCustomFieldValues() {\n const customFields = document.querySelectorAll('[data-collapse-name]');\n // Cast from NodeList to array to grab all the values.\n return [...customFields].map(field => [field.parentElement.dataset.col, field.dataset.collapseName]);\n }\n\n /**\n * Given the set of profile fields we can possibly search, fetch their strings,\n * so we can report to screen readers the field that matched.\n *\n * @returns {Promise}\n */\n fetchRequiredUserStrings() {\n if (!this.userStrings) {\n const requiredStrings = [\n 'username',\n 'firstname',\n 'lastname',\n 'email',\n 'city',\n 'country',\n 'department',\n 'institution',\n 'idnumber',\n 'phone1',\n 'phone2',\n ];\n this.userStrings = getStrings(requiredStrings.map((key) => ({key})))\n .then((stringArray) => new Map(\n requiredStrings.map((key, index) => ([key, stringArray[index]]))\n ));\n }\n return this.userStrings;\n }\n\n /**\n * Given the set of gradable items we can possibly search, fetch their strings,\n * so we can report to screen readers the field that matched.\n *\n * @returns {Promise}\n */\n fetchRequiredGradeStrings() {\n if (!this.gradeStrings) {\n this.gradeStrings = Repository.gradeItems(this.courseID)\n .then((result) => new Map(\n result.gradeItems.map(key => ([key.id, key]))\n ));\n }\n return this.gradeStrings;\n }\n}\n"],"names":["selectors","cancel","save","checked","currentlyUnchecked","countIndicator","document","querySelector","ColumnSearch","search_combobox","userID","courseID","defaultSort","constructor","component","pendingPromise","Pending","then","loader","setTimeout","getDataset","forEach","item","nodesUpdate","renderDefault","remove","classList","resolve","catch","Notification","exception","componentSelector","dropdownSelector","triggerSelector","this","dataset","cols","fetchDataset","JSON","parse","split","datasetSize","length","storage","get","setPreferences","set","stringify","join","registerClickHandlers","addEventListener","clickHandler","bind","docClickHandler","e","target","closest","stopPropagation","hider","preventDefault","desiredToHide","_e$target$closest","col","_e$target$closest2","itemid","indexOf","push","prefcountpipe","_e$target$closest4","_e$target$closest5","idx","splice","_e$target$closest6","_e$target$closest7","keyHandler","key","input","clearSearchButton","focus","preventScroll","registerInputEvents","searchInput","async","getSearchTerm","value","searchResultsVisible","window","console","warn","setSearchTerms","add","filterrenderpipe","pending","registerFormEvents","form","events","CustomEvents","activate","keyboardActivate","define","selectall","event","submitBtn","checkedCount","Array","from","querySelectorAll","disabled","submitter","action","dropdown","elements","filter","collapse","colNodesToHide","itemIDNodesToHide","nodes","updateDisplay","countUpdate","filterableData","stringUserMap","fetchRequiredUserStrings","stringGradeMap","fetchRequiredGradeStrings","customFieldMap","fetchCustomFieldValues","stringMap","Map","searching","map","s","mapObj","undefined","string","itemname","category","getPreppedSearchTerm","toString","toLowerCase","includes","filterMatchDataset","setMatchedResults","getMatchedResults","column","name","displayName","element","content","sort","expandButton","rangeRowCell","avgRowCell","nodeSet","contains","location","rowCell","toggle","setAttribute","childNodes","node","textContent","getDatasetSize","parentElement","filterDataset","html","js","updateNodes","$component","on","getHTMLElements","searchDropdown","field","collapseName","userStrings","requiredStrings","stringArray","index","gradeStrings","Repository","gradeItems","result","id"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"collapse.min.js","sources":["../src/collapse.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 * Allow the user to show and hide columns of the report at will.\n *\n * @module gradereport_grader/collapse\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport * as Repository from 'gradereport_grader/collapse/repository';\nimport search_combobox from 'core/comboboxsearch/search_combobox';\nimport {renderForPromise, replaceNodeContents, replaceNode} from 'core/templates';\nimport {debounce} from 'core/utils';\nimport $ from 'jquery';\nimport {getStrings} from 'core/str';\nimport CustomEvents from \"core/custom_interaction_events\";\nimport storage from 'core/localstorage';\nimport {addIconToContainer} from 'core/loadingicon';\nimport Notification from 'core/notification';\nimport Pending from 'core/pending';\n\n// Contain our selectors within this file until they could be of use elsewhere.\nconst selectors = {\n component: '.collapse-columns',\n formDropdown: '.columnsdropdownform',\n formItems: {\n cancel: 'cancel',\n save: 'save',\n checked: 'input[type=\"checkbox\"]:checked',\n currentlyUnchecked: 'input[type=\"checkbox\"]:not([data-action=\"selectall\"])',\n },\n hider: 'hide',\n expand: 'expand',\n colVal: '[data-col]',\n itemVal: '[data-itemid]',\n content: '[data-collapse=\"content\"]',\n sort: '[data-collapse=\"sort\"]',\n expandbutton: '[data-collapse=\"expandbutton\"]',\n rangerowcell: '[data-collapse=\"rangerowcell\"]',\n avgrowcell: '[data-collapse=\"avgrowcell\"]',\n menu: '[data-collapse=\"menu\"]',\n icons: '.data-collapse_gradeicons',\n count: '[data-collapse=\"count\"]',\n placeholder: '.collapsecolumndropdown [data-region=\"placeholder\"]',\n fullDropdown: '.collapsecolumndropdown',\n searchResultContainer: '.searchresultitemscontainer',\n};\n\nconst countIndicator = document.querySelector(selectors.count);\n\nexport default class ColumnSearch extends search_combobox {\n\n userID = -1;\n courseID = null;\n defaultSort = '';\n\n nodes = [];\n\n gradeStrings = null;\n userStrings = null;\n stringMap = [];\n\n static init(userID, courseID, defaultSort) {\n return new ColumnSearch(userID, courseID, defaultSort);\n }\n\n constructor(userID, courseID, defaultSort) {\n super();\n this.userID = userID;\n this.courseID = courseID;\n this.defaultSort = defaultSort;\n this.component = document.querySelector(selectors.component);\n\n const pendingPromise = new Pending();\n // Display a loader whilst collapsing appropriate columns (based on the locally stored state for the current user).\n addIconToContainer(document.querySelector('.gradeparent')).then((loader) => {\n setTimeout(() => {\n // Get the users' checked columns to change.\n this.getDataset().forEach((item) => {\n this.nodesUpdate(item);\n });\n this.renderDefault();\n\n // Once the grade categories have been re-collapsed, remove the loader and display the Gradebook setup content.\n loader.remove();\n document.querySelector('.gradereport-grader-table').classList.remove('d-none');\n }, 10);\n }).then(() => pendingPromise.resolve()).catch(Notification.exception);\n\n this.$component.on('hide.bs.dropdown', () => {\n const searchResultContainer = this.component.querySelector(selectors.searchResultContainer);\n searchResultContainer.scrollTop = 0;\n\n // Use setTimeout to make sure the following code is executed after the click event is handled.\n setTimeout(() => {\n if (this.searchInput.value !== '') {\n this.searchInput.value = '';\n this.searchInput.dispatchEvent(new Event('input', {bubbles: true}));\n }\n });\n });\n }\n\n /**\n * The overall div that contains the searching widget.\n *\n * @returns {string}\n */\n componentSelector() {\n return '.collapse-columns';\n }\n\n /**\n * The dropdown div that contains the searching widget result space.\n *\n * @returns {string}\n */\n dropdownSelector() {\n return '.searchresultitemscontainer';\n }\n\n /**\n * Return the dataset that we will be searching upon.\n *\n * @returns {Array}\n */\n getDataset() {\n if (!this.dataset) {\n const cols = this.fetchDataset();\n this.dataset = JSON.parse(cols) ? JSON.parse(cols).split(',') : [];\n }\n this.datasetSize = this.dataset.length;\n return this.dataset;\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {string}\n */\n fetchDataset() {\n return storage.get(`gradereport_grader_collapseditems_${this.courseID}_${this.userID}`);\n }\n\n /**\n * Given a user performs an action, update the users' preferences.\n */\n setPreferences() {\n storage.set(`gradereport_grader_collapseditems_${this.courseID}_${this.userID}`,\n JSON.stringify(this.getDataset().join(','))\n );\n }\n\n /**\n * Register clickable event listeners.\n */\n registerClickHandlers() {\n // Register click events within the component.\n this.component.addEventListener('click', this.clickHandler.bind(this));\n\n document.addEventListener('click', this.docClickHandler.bind(this));\n }\n\n /**\n * The handler for when a user interacts with the component.\n *\n * @param {MouseEvent} e The triggering event that we are working with.\n */\n clickHandler(e) {\n super.clickHandler(e);\n // Prevent BS from closing the dropdown if they click elsewhere within the dropdown besides the form.\n if (e.target.closest(selectors.fullDropdown)) {\n e.stopPropagation();\n }\n }\n\n /**\n * Externally defined click function to improve memory handling.\n *\n * @param {MouseEvent} e\n * @returns {Promise}\n */\n async docClickHandler(e) {\n if (e.target.dataset.hider === selectors.hider) {\n e.preventDefault();\n const desiredToHide = e.target.closest(selectors.colVal) ?\n e.target.closest(selectors.colVal)?.dataset.col :\n e.target.closest(selectors.itemVal)?.dataset.itemid;\n const idx = this.getDataset().indexOf(desiredToHide);\n if (idx === -1) {\n this.getDataset().push(desiredToHide);\n }\n await this.prefcountpipe();\n\n this.nodesUpdate(desiredToHide);\n }\n\n if (e.target.closest('button')?.dataset.hider === selectors.expand) {\n e.preventDefault();\n const desiredToHide = e.target.closest(selectors.colVal) ?\n e.target.closest(selectors.colVal)?.dataset.col :\n e.target.closest(selectors.itemVal)?.dataset.itemid;\n const idx = this.getDataset().indexOf(desiredToHide);\n this.getDataset().splice(idx, 1);\n\n await this.prefcountpipe();\n\n this.nodesUpdate(e.target.closest(selectors.colVal)?.dataset.col);\n this.nodesUpdate(e.target.closest(selectors.colVal)?.dataset.itemid);\n }\n }\n\n /**\n * Handle any keyboard inputs.\n */\n registerInputEvents() {\n // Register & handle the text input.\n this.searchInput.addEventListener('input', debounce(async() => {\n if (this.getSearchTerm() === this.searchInput.value && this.searchResultsVisible()) {\n window.console.warn(`Search term matches input value - skipping`);\n // Debounce can happen multiple times quickly.\n return;\n }\n this.setSearchTerms(this.searchInput.value);\n // We can also require a set amount of input before search.\n if (this.searchInput.value === '') {\n // Hide the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.add('d-none');\n } else {\n // Display the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.remove('d-none');\n }\n const pendingPromise = new Pending();\n // User has given something for us to filter against.\n await this.filterrenderpipe().then(() => {\n pendingPromise.resolve();\n return true;\n });\n }, 300, {pending: true}));\n }\n\n /**\n * Handle the form submission within the dropdown.\n */\n registerFormEvents() {\n const form = this.component.querySelector(selectors.formDropdown);\n const events = [\n 'click',\n CustomEvents.events.activate,\n CustomEvents.events.keyboardActivate\n ];\n CustomEvents.define(document, events);\n\n const selectall = form.querySelector('[data-action=\"selectall\"]');\n\n // Register clicks & keyboard form handling.\n events.forEach((event) => {\n const submitBtn = form.querySelector(`[data-action=\"${selectors.formItems.save}\"`);\n form.addEventListener(event, (e) => {\n // Stop Bootstrap from being clever.\n e.stopPropagation();\n const input = e.target.closest('input');\n if (input) {\n // If the user is unchecking an item, we need to uncheck the select all if it's checked.\n if (selectall.checked && !input.checked) {\n selectall.checked = false;\n }\n const checkedCount = Array.from(form.querySelectorAll(selectors.formItems.checked)).length;\n // Check if any are clicked or not then change disabled.\n submitBtn.disabled = checkedCount <= 0;\n }\n }, false);\n\n // Stop Bootstrap from being clever.\n this.searchInput.addEventListener(event, e => e.stopPropagation());\n this.clearSearchButton.addEventListener(event, async(e) => {\n e.stopPropagation();\n this.searchInput.value = '';\n this.setSearchTerms(this.searchInput.value);\n await this.filterrenderpipe();\n });\n selectall.addEventListener(event, (e) => {\n // Stop Bootstrap from being clever.\n e.stopPropagation();\n if (!selectall.checked) {\n const touncheck = Array.from(form.querySelectorAll(selectors.formItems.checked));\n touncheck.forEach(item => {\n item.checked = false;\n });\n submitBtn.disabled = true;\n } else {\n const currentUnchecked = Array.from(form.querySelectorAll(selectors.formItems.currentlyUnchecked));\n currentUnchecked.forEach(item => {\n item.checked = true;\n });\n submitBtn.disabled = false;\n }\n });\n });\n\n form.addEventListener('submit', async(e) => {\n e.preventDefault();\n if (e.submitter.dataset.action === selectors.formItems.cancel) {\n $(this.component).dropdown('toggle');\n return;\n }\n // Get the users' checked columns to change.\n const checkedItems = [...form.elements].filter(item => item.checked);\n checkedItems.forEach((item) => {\n const idx = this.getDataset().indexOf(item.dataset.collapse);\n this.getDataset().splice(idx, 1);\n this.nodesUpdate(item.dataset.collapse);\n });\n // Reset the check all & submit to false just in case.\n selectall.checked = false;\n e.submitter.disabled = true;\n await this.prefcountpipe();\n });\n }\n\n nodesUpdate(item) {\n const colNodesToHide = [...document.querySelectorAll(`[data-col=\"${item}\"]`)];\n const itemIDNodesToHide = [...document.querySelectorAll(`[data-itemid=\"${item}\"]`)];\n this.nodes = [...colNodesToHide, ...itemIDNodesToHide];\n this.updateDisplay();\n }\n\n /**\n * Update the user preferences, count display then render the results.\n *\n * @returns {Promise}\n */\n async prefcountpipe() {\n this.setPreferences();\n this.countUpdate();\n await this.filterrenderpipe();\n }\n\n /**\n * Dictate to the search component how and what we want to match upon.\n *\n * @param {Array} filterableData\n * @returns {Array} An array of objects containing the system reference and the user readable value.\n */\n async filterDataset(filterableData) {\n const stringUserMap = await this.fetchRequiredUserStrings();\n const stringGradeMap = await this.fetchRequiredGradeStrings();\n // Custom user profile fields are not in our string map and need a bit of extra love.\n const customFieldMap = this.fetchCustomFieldValues();\n this.stringMap = new Map([...stringGradeMap, ...stringUserMap, ...customFieldMap]);\n\n const searching = filterableData.map(s => {\n const mapObj = this.stringMap.get(s);\n if (mapObj === undefined) {\n return {key: s, string: s};\n }\n return {\n key: s,\n string: mapObj.itemname ?? this.stringMap.get(s),\n category: mapObj.category ?? '',\n };\n });\n // Sometimes we just want to show everything.\n if (this.getPreppedSearchTerm() === '') {\n return searching;\n }\n // Other times we want to actually filter the content.\n return searching.filter((col) => {\n return col.string.toString().toLowerCase().includes(this.getPreppedSearchTerm());\n });\n }\n\n /**\n * Given we have a subset of the dataset, set the field that we matched upon to inform the end user.\n */\n filterMatchDataset() {\n this.setMatchedResults(\n this.getMatchedResults().map((column) => {\n return {\n name: column.key,\n displayName: column.string ?? column.key,\n category: column.category ?? '',\n };\n })\n );\n }\n\n /**\n * With an array of nodes, switch their classes and values.\n */\n updateDisplay() {\n this.nodes.forEach((element) => {\n const content = element.querySelector(selectors.content);\n const sort = element.querySelector(selectors.sort);\n const expandButton = element.querySelector(selectors.expandbutton);\n const rangeRowCell = element.querySelector(selectors.rangerowcell);\n const avgRowCell = element.querySelector(selectors.avgrowcell);\n const nodeSet = [\n element.querySelector(selectors.menu),\n element.querySelector(selectors.icons),\n content\n ];\n\n // This can be further improved to reduce redundant similar calls.\n if (element.classList.contains('cell')) {\n // The column is actively being sorted, lets reset that and reload the page.\n if (sort !== null) {\n window.location = this.defaultSort;\n }\n if (content === null) {\n // If it's not a content cell, it must be an overall average or a range cell.\n const rowCell = avgRowCell ?? rangeRowCell;\n\n rowCell?.classList.toggle('d-none');\n rowCell?.setAttribute('aria-hidden',\n rowCell?.classList.contains('d-none') ? 'true' : 'false');\n } else if (content.classList.contains('d-none')) {\n // We should always have content but some cells do not contain menus or other actions.\n element.classList.remove('collapsed');\n // If there are many nodes, apply the following.\n if (content.childNodes.length > 1) {\n content.classList.add('d-flex');\n }\n nodeSet.forEach(node => {\n node?.classList.remove('d-none');\n node?.setAttribute('aria-hidden', 'false');\n });\n expandButton?.classList.add('d-none');\n expandButton?.setAttribute('aria-hidden', 'true');\n } else {\n element.classList.add('collapsed');\n content.classList.remove('d-flex');\n nodeSet.forEach(node => {\n node?.classList.add('d-none');\n node?.setAttribute('aria-hidden', 'true');\n });\n expandButton?.classList.remove('d-none');\n expandButton?.setAttribute('aria-hidden', 'false');\n }\n }\n });\n }\n\n /**\n * Update the visual count of collapsed columns or hide the count all together.\n */\n countUpdate() {\n countIndicator.textContent = this.getDatasetSize();\n if (this.getDatasetSize() > 0) {\n this.component.parentElement.classList.add('d-flex');\n this.component.parentElement.classList.remove('d-none');\n } else {\n this.component.parentElement.classList.remove('d-flex');\n this.component.parentElement.classList.add('d-none');\n }\n }\n\n /**\n * Build the content then replace the node by default we want our form to exist.\n */\n async renderDefault() {\n this.setMatchedResults(await this.filterDataset(this.getDataset()));\n this.filterMatchDataset();\n\n // Update the collapsed button pill.\n this.countUpdate();\n const {html, js} = await renderForPromise('gradereport_grader/collapse/collapsebody', {\n 'instance': this.instance,\n 'results': this.getMatchedResults(),\n 'userid': this.userID,\n });\n replaceNode(selectors.placeholder, html, js);\n this.updateNodes();\n\n // Given we now have the body, we can set up more triggers.\n this.registerFormEvents();\n this.registerInputEvents();\n\n // Add a small BS listener so that we can set the focus correctly on open.\n this.$component.on('shown.bs.dropdown', () => {\n this.searchInput.focus({preventScroll: true});\n this.selectallEnable();\n });\n }\n\n /**\n * Build the content then replace the node.\n */\n async renderDropdown() {\n const {html, js} = await renderForPromise('gradereport_grader/collapse/collapseresults', {\n instance: this.instance,\n 'results': this.getMatchedResults(),\n 'searchTerm': this.getSearchTerm(),\n });\n replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);\n this.selectallEnable();\n // Reset the expand button to be disabled as we have re-rendered the dropdown.\n const form = this.component.querySelector(selectors.formDropdown);\n const expandButton = form.querySelector(`[data-action=\"${selectors.formItems.save}\"`);\n expandButton.disabled = true;\n }\n\n /**\n * Given we render the dropdown, Determine if we want to enable the select all checkbox.\n */\n selectallEnable() {\n const form = this.component.querySelector(selectors.formDropdown);\n const selectall = form.querySelector('[data-action=\"selectall\"]');\n selectall.disabled = this.getMatchedResults().length === 0;\n }\n\n /**\n * If we have any custom user profile fields, grab their system & readable names to add to our string map.\n *\n * @returns {array} An array of associated string arrays ready for our map.\n */\n fetchCustomFieldValues() {\n const customFields = document.querySelectorAll('[data-collapse-name]');\n // Cast from NodeList to array to grab all the values.\n return [...customFields].map(field => [field.parentElement.dataset.col, field.dataset.collapseName]);\n }\n\n /**\n * Given the set of profile fields we can possibly search, fetch their strings,\n * so we can report to screen readers the field that matched.\n *\n * @returns {Promise}\n */\n fetchRequiredUserStrings() {\n if (!this.userStrings) {\n const requiredStrings = [\n 'username',\n 'firstname',\n 'lastname',\n 'email',\n 'city',\n 'country',\n 'department',\n 'institution',\n 'idnumber',\n 'phone1',\n 'phone2',\n ];\n this.userStrings = getStrings(requiredStrings.map((key) => ({key})))\n .then((stringArray) => new Map(\n requiredStrings.map((key, index) => ([key, stringArray[index]]))\n ));\n }\n return this.userStrings;\n }\n\n /**\n * Given the set of gradable items we can possibly search, fetch their strings,\n * so we can report to screen readers the field that matched.\n *\n * @returns {Promise}\n */\n fetchRequiredGradeStrings() {\n if (!this.gradeStrings) {\n this.gradeStrings = Repository.gradeItems(this.courseID)\n .then((result) => new Map(\n result.gradeItems.map(key => ([key.id, key]))\n ));\n }\n return this.gradeStrings;\n }\n}\n"],"names":["selectors","cancel","save","checked","currentlyUnchecked","countIndicator","document","querySelector","ColumnSearch","search_combobox","userID","courseID","defaultSort","constructor","component","pendingPromise","Pending","then","loader","setTimeout","getDataset","forEach","item","nodesUpdate","renderDefault","remove","classList","resolve","catch","Notification","exception","$component","on","this","scrollTop","searchInput","value","dispatchEvent","Event","bubbles","componentSelector","dropdownSelector","dataset","cols","fetchDataset","JSON","parse","split","datasetSize","length","storage","get","setPreferences","set","stringify","join","registerClickHandlers","addEventListener","clickHandler","bind","docClickHandler","e","target","closest","stopPropagation","hider","preventDefault","desiredToHide","_e$target$closest","col","_e$target$closest2","itemid","indexOf","push","prefcountpipe","_e$target$closest4","_e$target$closest5","idx","splice","_e$target$closest6","_e$target$closest7","registerInputEvents","async","getSearchTerm","searchResultsVisible","window","console","warn","setSearchTerms","clearSearchButton","add","filterrenderpipe","pending","registerFormEvents","form","events","CustomEvents","activate","keyboardActivate","define","selectall","event","submitBtn","input","checkedCount","Array","from","querySelectorAll","disabled","submitter","action","dropdown","elements","filter","collapse","colNodesToHide","itemIDNodesToHide","nodes","updateDisplay","countUpdate","filterableData","stringUserMap","fetchRequiredUserStrings","stringGradeMap","fetchRequiredGradeStrings","customFieldMap","fetchCustomFieldValues","stringMap","Map","searching","map","s","mapObj","undefined","key","string","itemname","category","getPreppedSearchTerm","toString","toLowerCase","includes","filterMatchDataset","setMatchedResults","getMatchedResults","column","name","displayName","element","content","sort","expandButton","rangeRowCell","avgRowCell","nodeSet","contains","location","rowCell","toggle","setAttribute","childNodes","node","textContent","getDatasetSize","parentElement","filterDataset","html","js","instance","updateNodes","focus","preventScroll","selectallEnable","getHTMLElements","searchDropdown","field","collapseName","userStrings","requiredStrings","stringArray","index","gradeStrings","Repository","gradeItems","result","id"],"mappings":"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"} \ No newline at end of file diff --git a/grade/report/grader/amd/build/group.min.js.map b/grade/report/grader/amd/build/group.min.js.map index bdde7d2115d74..d3833d64d7811 100644 --- a/grade/report/grader/amd/build/group.min.js.map +++ b/grade/report/grader/amd/build/group.min.js.map @@ -1 +1 @@ -{"version":3,"file":"group.min.js","sources":["../src/group.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 * Allow the user to search for groups within the grader report.\n *\n * @module gradereport_grader/group\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport GroupSearch from 'core_group/comboboxsearch/group';\nimport Url from 'core/url';\n\nexport default class Group extends GroupSearch {\n\n courseID;\n\n constructor() {\n super();\n\n // Define our standard lookups.\n this.selectors = {...this.selectors,\n courseid: '[data-region=\"courseid\"]',\n };\n const component = document.querySelector(this.componentSelector());\n this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;\n }\n\n static init() {\n return new Group();\n }\n\n /**\n * Build up the view all link that is dedicated to a particular result.\n *\n * @param {Number} groupID The ID of the group selected.\n * @returns {string|*}\n */\n selectOneLink(groupID) {\n return Url.relativeUrl('/grade/report/grader/index.php', {\n id: this.courseID,\n groupsearchvalue: this.getSearchTerm(),\n group: groupID,\n }, false);\n }\n}\n"],"names":["Group","GroupSearch","constructor","selectors","this","courseid","component","document","querySelector","componentSelector","courseID","dataset","selectOneLink","groupID","Url","relativeUrl","id","groupsearchvalue","getSearchTerm","group"],"mappings":"2WAyBqBA,cAAcC,eAI/BC,6LAISC,UAAY,IAAIC,KAAKD,UACtBE,SAAU,kCAERC,UAAYC,SAASC,cAAcJ,KAAKK,0BACzCC,SAAWJ,UAAUE,cAAcJ,KAAKD,UAAUE,UAAUM,QAAQN,8BAIlE,IAAIL,MASfY,cAAcC,gBACHC,aAAIC,YAAY,iCAAkC,CACrDC,GAAIZ,KAAKM,SACTO,iBAAkBb,KAAKc,gBACvBC,MAAON,UACR"} \ No newline at end of file +{"version":3,"file":"group.min.js","sources":["../src/group.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 * Allow the user to search for groups within the grader report.\n *\n * @module gradereport_grader/group\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport GroupSearch from 'core_group/comboboxsearch/group';\nimport Url from 'core/url';\n\nexport default class Group extends GroupSearch {\n\n courseID;\n\n constructor() {\n super();\n\n // Define our standard lookups.\n this.selectors = {...this.selectors,\n courseid: '[data-region=\"courseid\"]',\n };\n const component = document.querySelector(this.componentSelector());\n this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;\n }\n\n static init() {\n return new Group();\n }\n\n /**\n * Build up the link that is dedicated to a particular result.\n *\n * @param {Number} groupID The ID of the group selected.\n * @returns {string|*}\n */\n selectOneLink(groupID) {\n return Url.relativeUrl('/grade/report/grader/index.php', {\n id: this.courseID,\n groupsearchvalue: this.getSearchTerm(),\n group: groupID,\n }, false);\n }\n}\n"],"names":["Group","GroupSearch","constructor","selectors","this","courseid","component","document","querySelector","componentSelector","courseID","dataset","selectOneLink","groupID","Url","relativeUrl","id","groupsearchvalue","getSearchTerm","group"],"mappings":"2WAyBqBA,cAAcC,eAI/BC,6LAISC,UAAY,IAAIC,KAAKD,UACtBE,SAAU,kCAERC,UAAYC,SAASC,cAAcJ,KAAKK,0BACzCC,SAAWJ,UAAUE,cAAcJ,KAAKD,UAAUE,UAAUM,QAAQN,8BAIlE,IAAIL,MASfY,cAAcC,gBACHC,aAAIC,YAAY,iCAAkC,CACrDC,GAAIZ,KAAKM,SACTO,iBAAkBb,KAAKc,gBACvBC,MAAON,UACR"} \ No newline at end of file diff --git a/grade/report/grader/amd/build/user.min.js.map b/grade/report/grader/amd/build/user.min.js.map index cc8b285891de2..4da4df8ac4b25 100644 --- a/grade/report/grader/amd/build/user.min.js.map +++ b/grade/report/grader/amd/build/user.min.js.map @@ -1 +1 @@ -{"version":3,"file":"user.min.js","sources":["../src/user.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 * Allow the user to search for learners within the grader report.\n *\n * @module gradereport_grader/user\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport UserSearch from 'core_user/comboboxsearch/user';\nimport Url from 'core/url';\nimport * as Repository from 'gradereport_grader/local/user/repository';\n\n// Define our standard lookups.\nconst selectors = {\n component: '.user-search',\n courseid: '[data-region=\"courseid\"]',\n};\nconst component = document.querySelector(selectors.component);\nconst courseID = component.querySelector(selectors.courseid).dataset.courseid;\n\nexport default class User extends UserSearch {\n\n constructor() {\n super();\n }\n\n static init() {\n return new User();\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {Promise<*>}\n */\n fetchDataset() {\n return Repository.userFetch(courseID).then((r) => r.users);\n }\n\n /**\n * Build up the view all link.\n *\n * @returns {string|*}\n */\n selectAllResultsLink() {\n return Url.relativeUrl('/grade/report/grader/index.php', {\n id: courseID,\n gpr_search: this.getSearchTerm()\n }, false);\n }\n\n /**\n * Build up the view all link that is dedicated to a particular result.\n *\n * @param {Number} userID The ID of the user selected.\n * @returns {string|*}\n */\n selectOneLink(userID) {\n return Url.relativeUrl('/grade/report/grader/index.php', {\n id: courseID,\n gpr_search: this.getSearchTerm(),\n gpr_userid: userID,\n }, false);\n }\n}\n"],"names":["selectors","courseID","document","querySelector","dataset","courseid","User","UserSearch","constructor","fetchDataset","Repository","userFetch","then","r","users","selectAllResultsLink","Url","relativeUrl","id","gpr_search","this","getSearchTerm","selectOneLink","userID","gpr_userid"],"mappings":";;;;;;;q0BA2BMA,oBACS,eADTA,mBAEQ,2BAGRC,SADYC,SAASC,cAAcH,qBACdG,cAAcH,oBAAoBI,QAAQC,eAEhDC,aAAaC,cAE9BC,2CAKW,IAAIF,KAQfG,sBACWC,WAAWC,UAAUV,UAAUW,MAAMC,GAAMA,EAAEC,QAQxDC,8BACWC,aAAIC,YAAY,iCAAkC,CACrDC,GAAIjB,SACJkB,WAAYC,KAAKC,kBAClB,GASPC,cAAcC,eACHP,aAAIC,YAAY,iCAAkC,CACrDC,GAAIjB,SACJkB,WAAYC,KAAKC,gBACjBG,WAAYD,SACb"} \ No newline at end of file +{"version":3,"file":"user.min.js","sources":["../src/user.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 * Allow the user to search for learners within the grader report.\n *\n * @module gradereport_grader/user\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport UserSearch from 'core_user/comboboxsearch/user';\nimport Url from 'core/url';\nimport * as Repository from 'gradereport_grader/local/user/repository';\n\n// Define our standard lookups.\nconst selectors = {\n component: '.user-search',\n courseid: '[data-region=\"courseid\"]',\n};\nconst component = document.querySelector(selectors.component);\nconst courseID = component.querySelector(selectors.courseid).dataset.courseid;\n\nexport default class User extends UserSearch {\n\n constructor() {\n super();\n }\n\n static init() {\n return new User();\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {Promise<*>}\n */\n fetchDataset() {\n return Repository.userFetch(courseID).then((r) => r.users);\n }\n\n /**\n * Build up the view all link.\n *\n * @returns {string|*}\n */\n selectAllResultsLink() {\n return Url.relativeUrl('/grade/report/grader/index.php', {\n id: courseID,\n gpr_search: this.getSearchTerm()\n }, false);\n }\n\n /**\n * Build up the link that is dedicated to a particular result.\n *\n * @param {Number} userID The ID of the user selected.\n * @returns {string|*}\n */\n selectOneLink(userID) {\n return Url.relativeUrl('/grade/report/grader/index.php', {\n id: courseID,\n gpr_search: this.getSearchTerm(),\n gpr_userid: userID,\n }, false);\n }\n}\n"],"names":["selectors","courseID","document","querySelector","dataset","courseid","User","UserSearch","constructor","fetchDataset","Repository","userFetch","then","r","users","selectAllResultsLink","Url","relativeUrl","id","gpr_search","this","getSearchTerm","selectOneLink","userID","gpr_userid"],"mappings":";;;;;;;q0BA2BMA,oBACS,eADTA,mBAEQ,2BAGRC,SADYC,SAASC,cAAcH,qBACdG,cAAcH,oBAAoBI,QAAQC,eAEhDC,aAAaC,cAE9BC,2CAKW,IAAIF,KAQfG,sBACWC,WAAWC,UAAUV,UAAUW,MAAMC,GAAMA,EAAEC,QAQxDC,8BACWC,aAAIC,YAAY,iCAAkC,CACrDC,GAAIjB,SACJkB,WAAYC,KAAKC,kBAClB,GASPC,cAAcC,eACHP,aAAIC,YAAY,iCAAkC,CACrDC,GAAIjB,SACJkB,WAAYC,KAAKC,gBACjBG,WAAYD,SACb"} \ No newline at end of file diff --git a/grade/report/grader/amd/src/collapse.js b/grade/report/grader/amd/src/collapse.js index 7599203cef48b..971819396af73 100644 --- a/grade/report/grader/amd/src/collapse.js +++ b/grade/report/grader/amd/src/collapse.js @@ -56,6 +56,7 @@ const selectors = { count: '[data-collapse="count"]', placeholder: '.collapsecolumndropdown [data-region="placeholder"]', fullDropdown: '.collapsecolumndropdown', + searchResultContainer: '.searchresultitemscontainer', }; const countIndicator = document.querySelector(selectors.count); @@ -98,6 +99,19 @@ export default class ColumnSearch extends search_combobox { document.querySelector('.gradereport-grader-table').classList.remove('d-none'); }, 10); }).then(() => pendingPromise.resolve()).catch(Notification.exception); + + this.$component.on('hide.bs.dropdown', () => { + const searchResultContainer = this.component.querySelector(selectors.searchResultContainer); + searchResultContainer.scrollTop = 0; + + // Use setTimeout to make sure the following code is executed after the click event is handled. + setTimeout(() => { + if (this.searchInput.value !== '') { + this.searchInput.value = ''; + this.searchInput.dispatchEvent(new Event('input', {bubbles: true})); + } + }); + }); } /** @@ -118,15 +132,6 @@ export default class ColumnSearch extends search_combobox { return '.searchresultitemscontainer'; } - /** - * The triggering div that contains the searching widget. - * - * @returns {string} - */ - triggerSelector() { - return '.collapsecolumn'; - } - /** * Return the dataset that we will be searching upon. * @@ -218,25 +223,6 @@ export default class ColumnSearch extends search_combobox { } } - /** - * The handler for when a user presses a key within the component. - * - * @param {KeyboardEvent} e The triggering event that we are working with. - */ - async keyHandler(e) { - super.keyHandler(e); - - // Switch the key presses to handle keyboard nav. - switch (e.key) { - case 'Tab': - if (e.target.closest(this.selectors.input)) { - e.preventDefault(); - this.clearSearchButton.focus({preventScroll: true}); - } - break; - } - } - /** * Handle any keyboard inputs. */ @@ -492,6 +478,7 @@ export default class ColumnSearch extends search_combobox { // Update the collapsed button pill. this.countUpdate(); const {html, js} = await renderForPromise('gradereport_grader/collapse/collapsebody', { + 'instance': this.instance, 'results': this.getMatchedResults(), 'userid': this.userID, }); @@ -505,6 +492,7 @@ export default class ColumnSearch extends search_combobox { // Add a small BS listener so that we can set the focus correctly on open. this.$component.on('shown.bs.dropdown', () => { this.searchInput.focus({preventScroll: true}); + this.selectallEnable(); }); } @@ -512,14 +500,26 @@ export default class ColumnSearch extends search_combobox { * Build the content then replace the node. */ async renderDropdown() { - const form = this.component.querySelector(selectors.formDropdown); - const selectall = form.querySelector('[data-action="selectall"]'); const {html, js} = await renderForPromise('gradereport_grader/collapse/collapseresults', { + instance: this.instance, 'results': this.getMatchedResults(), 'searchTerm': this.getSearchTerm(), }); - selectall.disabled = this.getMatchedResults().length === 0; replaceNodeContents(this.getHTMLElements().searchDropdown, html, js); + this.selectallEnable(); + // Reset the expand button to be disabled as we have re-rendered the dropdown. + const form = this.component.querySelector(selectors.formDropdown); + const expandButton = form.querySelector(`[data-action="${selectors.formItems.save}"`); + expandButton.disabled = true; + } + + /** + * Given we render the dropdown, Determine if we want to enable the select all checkbox. + */ + selectallEnable() { + const form = this.component.querySelector(selectors.formDropdown); + const selectall = form.querySelector('[data-action="selectall"]'); + selectall.disabled = this.getMatchedResults().length === 0; } /** diff --git a/grade/report/grader/amd/src/group.js b/grade/report/grader/amd/src/group.js index 9e15167e3cabe..1a22571c64111 100644 --- a/grade/report/grader/amd/src/group.js +++ b/grade/report/grader/amd/src/group.js @@ -43,7 +43,7 @@ export default class Group extends GroupSearch { } /** - * Build up the view all link that is dedicated to a particular result. + * Build up the link that is dedicated to a particular result. * * @param {Number} groupID The ID of the group selected. * @returns {string|*} diff --git a/grade/report/grader/amd/src/user.js b/grade/report/grader/amd/src/user.js index ab375e751d57e..82c5992d760ee 100644 --- a/grade/report/grader/amd/src/user.js +++ b/grade/report/grader/amd/src/user.js @@ -64,7 +64,7 @@ export default class User extends UserSearch { } /** - * Build up the view all link that is dedicated to a particular result. + * Build up the link that is dedicated to a particular result. * * @param {Number} userID The ID of the user selected. * @returns {string|*} diff --git a/grade/report/grader/classes/output/action_bar.php b/grade/report/grader/classes/output/action_bar.php index ceed096d53cd6..240ef44faced6 100644 --- a/grade/report/grader/classes/output/action_bar.php +++ b/grade/report/grader/classes/output/action_bar.php @@ -32,6 +32,9 @@ class action_bar extends \core_grades\output\action_bar { /** @var string $usersearch The content that the current user is looking for. */ protected string $usersearch = ''; + /** @var int $userid The ID of the user that the current user is looking for. */ + protected int $userid = 0; + /** * The class constructor. * @@ -40,7 +43,13 @@ class action_bar extends \core_grades\output\action_bar { public function __construct(\context_course $context) { parent::__construct($context); + $this->userid = optional_param('gpr_userid', 0, PARAM_INT); $this->usersearch = optional_param('gpr_search', '', PARAM_NOTAGS); + + if ($this->userid) { + $user = \core_user::get_user($this->userid); + $this->usersearch = fullname($user); + } } /** @@ -80,6 +89,10 @@ public function export_for_template(\renderer_base $output): array { $this->context, '/grade/report/grader/index.php' ); + + $firstnameinitial = $SESSION->gradereport["filterfirstname-{$this->context->id}"] ?? ''; + $lastnameinitial = $SESSION->gradereport["filtersurname-{$this->context->id}"] ?? ''; + $initialselector = new comboboxsearch( false, $initialscontent->buttoncontent, @@ -88,6 +101,13 @@ public function export_for_template(\renderer_base $output): array { 'initialswidget', 'initialsdropdown', $initialscontent->buttonheader, + true, + get_string('filterbyname', 'core_grades'), + 'nameinitials', + json_encode([ + 'first' => $firstnameinitial, + 'last' => $lastnameinitial, + ]) ); $data['initialselector'] = $initialselector->export_for_template($output); $data['groupselector'] = $gradesrenderer->group_selector($course); @@ -96,14 +116,20 @@ public function export_for_template(\renderer_base $output): array { $searchinput = $OUTPUT->render_from_template('core_user/comboboxsearch/user_selector', [ 'currentvalue' => $this->usersearch, 'courseid' => $courseid, + 'instance' => rand(), 'resetlink' => $resetlink->out(false), 'group' => 0, + 'name' => 'usersearch', + 'value' => json_encode([ + 'userid' => $this->userid, + 'search' => $this->usersearch, + ]), ]); $searchdropdown = new comboboxsearch( true, $searchinput, null, - 'user-search dropdown d-flex', + 'user-search d-flex', null, 'usersearchdropdown overflow-auto', null, @@ -123,6 +149,8 @@ public function export_for_template(\renderer_base $output): array { 'collapsecolumndropdown p-3 flex-column ' . $collapsemenudirection, null, true, + get_string('aria:dropdowncolumns', 'gradereport_grader'), + 'collapsedcolumns' ); $data['collapsedcolumns'] = [ 'classes' => 'd-none', @@ -135,10 +163,12 @@ public function export_for_template(\renderer_base $output): array { $allowedgroups = groups_get_all_groups($course->id, $USER->id, $course->defaultgroupingid); } - if (!empty($SESSION->gradereport["filterfirstname-{$this->context->id}"]) || - !empty($SESSION->gradereport["filterlastname-{$this->context->id}"]) || + if ( + $firstnameinitial || + $lastnameinitial || groups_get_course_group($course, true, $allowedgroups) || - $this->usersearch) { + $this->usersearch + ) { $reset = new moodle_url('/grade/report/grader/index.php', [ 'id' => $courseid, 'group' => 0, diff --git a/grade/report/grader/templates/collapse/collapsebody.mustache b/grade/report/grader/templates/collapse/collapsebody.mustache index a919154717a14..e8983247496d6 100644 --- a/grade/report/grader/templates/collapse/collapsebody.mustache +++ b/grade/report/grader/templates/collapse/collapsebody.mustache @@ -18,6 +18,7 @@ Example context (json): { + "instance": 25, "results": [ { "name": "42", @@ -39,24 +40,18 @@ {{$placeholder}}{{#str}} searchcollapsedcolumns, core_grades {{/str}}{{/placeholder}} - {{$additionalattributes}} - role="combobox" - aria-expanded="true" - aria-controls="collapse-listbox" - aria-autocomplete="list" - data-input-element="collapse-input-{{uniqid}}" - {{/additionalattributes}} {{/ core/search_input_auto }} - -
-
    - {{>gradereport_grader/collapse/collapseresults}} -
+
+ {{#str}} aria:dropdowncolumns, gradereport_grader {{/str}} +
    + {{>gradereport_grader/collapse/collapseresults}} +
+
-
diff --git a/grade/report/grader/templates/collapse/collapseresultitems.mustache b/grade/report/grader/templates/collapse/collapseresultitems.mustache index d883c1ff02b79..ff567358c895b 100644 --- a/grade/report/grader/templates/collapse/collapseresultitems.mustache +++ b/grade/report/grader/templates/collapse/collapseresultitems.mustache @@ -26,9 +26,9 @@ "category": "Hitchhikers grade category" } }} -
  • - -
  • +
  • - - {{$content}} - - {{name}} - - {{/content}} - +
  • diff --git a/lib/templates/local/comboboxsearch/resultset.mustache b/lib/templates/local/comboboxsearch/resultset.mustache index e80444afabca5..455c768fa0242 100644 --- a/lib/templates/local/comboboxsearch/resultset.mustache +++ b/lib/templates/local/comboboxsearch/resultset.mustache @@ -17,23 +17,22 @@ Wrapping template for returned result items. Context variables required for this template: + * instance - The instance ID of the combo box. * results - Our returned results to render. * searchterm - The entered text to find these results. * hasresult - Allow the handling where no results exist for the returned search term. - * noresults - Our fall through case if nothing matches. Example context (json): { + "instance": 25, "results": [ { "id": 2, - "name": "Foo bar", - "link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2" + "name": "Foo bar" }, { "id": 3, - "name": "Bar Foo", - "link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=3" + "name": "Bar Foo" } ], "searchterm": "Foo", @@ -41,17 +40,22 @@ } }}
    - {{#hasresults}} -
      +
        + {{#hasresults}} {{$results}} {{#results}} {{>core/local/comboboxsearch/resultitem}} {{/results}} {{/results}} {{$selectall}}{{/selectall}} -
      - {{/hasresults}} + {{/hasresults}} +
    {{^hasresults}} - {{#str}} noresultsfor, core, {{searchterm}}{{/str}} + {{#str}} noresultsfor, core, {{searchterm}}{{/str}} {{/hasresults}}
    diff --git a/lib/templates/local/comboboxsearch/searchbody.mustache b/lib/templates/local/comboboxsearch/searchbody.mustache index 70d2f9aefb0d5..c4ad31508d243 100644 --- a/lib/templates/local/comboboxsearch/searchbody.mustache +++ b/lib/templates/local/comboboxsearch/searchbody.mustache @@ -28,39 +28,22 @@ }}
    - - {{#currentvalue}} - {{< core/search_input_auto }} - {{$label}}{{#str}} - searchitems, core - {{/str}}{{/label}} - {{$value}}{{currentvalue}}{{/value}} - {{$additionalattributes}} - role="combobox" - aria-expanded="true" - aria-controls="list-result-listbox" - aria-autocomplete="list" - data-input-element="result-input-{{uniqid}}" - {{/additionalattributes}} - {{/ core/search_input_auto }} - {{/currentvalue}} - {{^currentvalue}} - {{< core/search_input_auto }} - {{$label}}{{#str}} - searchitems, core - {{/str}}{{/label}} - {{$placeholder}}{{#str}} - searchitems, core - {{/str}}{{/placeholder}} - {{$additionalattributes}} - role="combobox" - aria-expanded="true" - aria-controls="list-result-listbox" - aria-autocomplete="list" - data-input-element="result-input-{{uniqid}}" - {{/additionalattributes}} - {{/ core/search_input_auto }} - {{/currentvalue}} - + {{< core/search_input_auto }} + {{$label}}{{#str}} + searchitems, core + {{/str}}{{/label}} + {{$placeholder}}{{#str}} + searchitems, core + {{/str}}{{/placeholder}} + {{$value}}{{currentvalue}}{{/value}} + {{$additionalattributes}} + role="combobox" + aria-expanded="true" + aria-controls="list-{{instance}}-result-listbox" + aria-autocomplete="list" + data-input-element="result-input-{{uniqid}}-{{instance}}" + {{/additionalattributes}} + {{/ core/search_input_auto }} +
    diff --git a/lib/tests/accesslib_test.php b/lib/tests/accesslib_test.php index 8b72994b1cc69..cb38c113e7037 100644 --- a/lib/tests/accesslib_test.php +++ b/lib/tests/accesslib_test.php @@ -1685,23 +1685,23 @@ public function test_get_role_users() { $this->assertDebuggingCalled('get_role_users() adding u.lastname, u.firstname to the query result because they were required by $sort but missing in $fields'); $this->assertCount(2, $users); $this->assertArrayHasKey($user1->id, $users); - $this->assertObjectHasAttribute('lastname', $users[$user1->id]); - $this->assertObjectHasAttribute('firstname', $users[$user1->id]); + $this->assertObjectHasProperty('lastname', $users[$user1->id]); + $this->assertObjectHasProperty('firstname', $users[$user1->id]); $this->assertArrayHasKey($user3->id, $users); - $this->assertObjectHasAttribute('lastname', $users[$user3->id]); - $this->assertObjectHasAttribute('firstname', $users[$user3->id]); + $this->assertObjectHasProperty('lastname', $users[$user3->id]); + $this->assertObjectHasProperty('firstname', $users[$user3->id]); $users = get_role_users($teacherrole->id, $coursecontext, false, 'u.id AS id_alias'); $this->assertDebuggingCalled('get_role_users() adding u.lastname, u.firstname to the query result because they were required by $sort but missing in $fields'); $this->assertCount(2, $users); $this->assertArrayHasKey($user1->id, $users); - $this->assertObjectHasAttribute('id_alias', $users[$user1->id]); - $this->assertObjectHasAttribute('lastname', $users[$user1->id]); - $this->assertObjectHasAttribute('firstname', $users[$user1->id]); + $this->assertObjectHasProperty('id_alias', $users[$user1->id]); + $this->assertObjectHasProperty('lastname', $users[$user1->id]); + $this->assertObjectHasProperty('firstname', $users[$user1->id]); $this->assertArrayHasKey($user3->id, $users); - $this->assertObjectHasAttribute('id_alias', $users[$user3->id]); - $this->assertObjectHasAttribute('lastname', $users[$user3->id]); - $this->assertObjectHasAttribute('firstname', $users[$user3->id]); + $this->assertObjectHasProperty('id_alias', $users[$user3->id]); + $this->assertObjectHasProperty('lastname', $users[$user3->id]); + $this->assertObjectHasProperty('firstname', $users[$user3->id]); $users = get_role_users($teacherrole->id, $coursecontext, false, 'u.id, u.email, u.idnumber', 'u.idnumber', null, $group->id); $this->assertCount(1, $users); diff --git a/lib/tests/cron_test.php b/lib/tests/cron_test.php index b8760a3f4ed76..cc2673da217c4 100644 --- a/lib/tests/cron_test.php +++ b/lib/tests/cron_test.php @@ -76,7 +76,7 @@ public function test_setup_user(): void { $this->assertSame($user1->id, $USER->id); $this->assertSame($PAGE->context, \context_course::instance($SITE->id)); $this->assertNotSame($adminsession, $SESSION); - $this->assertObjectNotHasAttribute('test1', $SESSION); + $this->assertObjectNotHasProperty('test1', $SESSION); $this->assertEmpty((array)$SESSION); $usersession1 = $SESSION; $SESSION->test2 = true; diff --git a/lib/tests/datalib_test.php b/lib/tests/datalib_test.php index 134176da19c35..29e04102ad24a 100644 --- a/lib/tests/datalib_test.php +++ b/lib/tests/datalib_test.php @@ -418,7 +418,7 @@ public function test_get_coursemodule_from_id() { $this->assertSame('folder', $cm->modname); $this->assertSame($folder1a->id, $cm->instance); $this->assertSame($folder1a->course, $cm->course); - $this->assertObjectNotHasAttribute('sectionnum', $cm); + $this->assertObjectNotHasProperty('sectionnum', $cm); $this->assertEquals($cm, get_coursemodule_from_id('', $folder1a->cmid)); $this->assertEquals($cm, get_coursemodule_from_id('folder', $folder1a->cmid, $course1->id)); @@ -483,7 +483,7 @@ public function test_get_coursemodule_from_instance() { $this->assertSame('folder', $cm->modname); $this->assertSame($folder1a->id, $cm->instance); $this->assertSame($folder1a->course, $cm->course); - $this->assertObjectNotHasAttribute('sectionnum', $cm); + $this->assertObjectNotHasProperty('sectionnum', $cm); $this->assertEquals($cm, get_coursemodule_from_instance('folder', $folder1a->id, $course1->id)); $this->assertEquals($cm, get_coursemodule_from_instance('folder', $folder1a->id, 0)); @@ -553,17 +553,17 @@ public function test_get_coursemodules_in_course() { $this->assertSame('folder', $cm->modname); $this->assertSame($folder1a->id, $cm->instance); $this->assertSame($folder1a->course, $cm->course); - $this->assertObjectNotHasAttribute('sectionnum', $cm); - $this->assertObjectNotHasAttribute('revision', $cm); - $this->assertObjectNotHasAttribute('display', $cm); + $this->assertObjectNotHasProperty('sectionnum', $cm); + $this->assertObjectNotHasProperty('revision', $cm); + $this->assertObjectNotHasProperty('display', $cm); $cm = $modules[$folder1b->cmid]; $this->assertSame('folder', $cm->modname); $this->assertSame($folder1b->id, $cm->instance); $this->assertSame($folder1b->course, $cm->course); - $this->assertObjectNotHasAttribute('sectionnum', $cm); - $this->assertObjectNotHasAttribute('revision', $cm); - $this->assertObjectNotHasAttribute('display', $cm); + $this->assertObjectNotHasProperty('sectionnum', $cm); + $this->assertObjectNotHasProperty('revision', $cm); + $this->assertObjectNotHasProperty('display', $cm); $modules = get_coursemodules_in_course('folder', $course1->id, 'revision, display'); $this->assertCount(2, $modules); @@ -572,9 +572,9 @@ public function test_get_coursemodules_in_course() { $this->assertSame('folder', $cm->modname); $this->assertSame($folder1a->id, $cm->instance); $this->assertSame($folder1a->course, $cm->course); - $this->assertObjectNotHasAttribute('sectionnum', $cm); - $this->assertObjectHasAttribute('revision', $cm); - $this->assertObjectHasAttribute('display', $cm); + $this->assertObjectNotHasProperty('sectionnum', $cm); + $this->assertObjectHasProperty('revision', $cm); + $this->assertObjectHasProperty('display', $cm); $modules = get_coursemodules_in_course('label', $course1->id); $this->assertCount(0, $modules); @@ -842,11 +842,11 @@ public function test_get_users_listing(): void { $this->assertEquals('user_a@example.com', $results[$userids[0]]->email); $this->assertEquals(1, $results[$userids[0]]->confirmed); $this->assertEquals('a_first', $results[$userids[0]]->firstname); - $this->assertObjectHasAttribute('firstnamephonetic', $results[$userids[0]]); + $this->assertObjectHasProperty('firstnamephonetic', $results[$userids[0]]); // Should not have the custom field or department because no context specified. - $this->assertObjectNotHasAttribute('department', $results[$userids[0]]); - $this->assertObjectNotHasAttribute('profile_field_specialid', $results[$userids[0]]); + $this->assertObjectNotHasProperty('department', $results[$userids[0]]); + $this->assertObjectNotHasProperty('profile_field_specialid', $results[$userids[0]]); // Check sorting. $results = get_users_listing('username', 'DESC'); @@ -867,8 +867,8 @@ public function test_get_users_listing(): void { // specify a context AND have permissions. $results = get_users_listing('lastaccess', 'asc', 0, 0, '', '', '', '', null, \context_system::instance()); - $this->assertObjectNotHasAttribute('department', $results[$userids[0]]); - $this->assertObjectNotHasAttribute('profile_field_specialid', $results[$userids[0]]); + $this->assertObjectNotHasProperty('department', $results[$userids[0]]); + $this->assertObjectNotHasProperty('profile_field_specialid', $results[$userids[0]]); $this->setAdminUser(); $results = get_users_listing('lastaccess', 'asc', 0, 0, '', '', '', '', null, \context_system::instance()); diff --git a/lib/tests/date_test.php b/lib/tests/date_test.php index 74060fbe07e73..ed825e84d4eb5 100644 --- a/lib/tests/date_test.php +++ b/lib/tests/date_test.php @@ -614,4 +614,56 @@ public function test_get_user_timezone_object() { $this->assertSame($zone, $tz->getName()); } } + + /** + * Data provider for the values for test_core_strftime(). + * + * @return array + */ + public static function get_strftime_provider(): array { + return [ + 'string_c' => [ + "1708405742", + "%c", + "20 February 2024 at 1:09 pm", + ], + 'numeric_c' => [ + 1708405742, + "%c", + "20 February 2024 at 1:09 pm", + ], + 'string_strftimedatetime' => [ + "1708405742", + get_string("strftimedatetime", 'langconfig'), + "20 February 2024, 01:09 PM", + ], + 'numeric_strftimedatetime' => [ + 1708405742, + get_string("strftimedatetime", 'langconfig'), + "20 February 2024, 01:09 PM", + ], + 'string_strftimedatetimeshortaccurate' => [ + "1708405742", + get_string("strftimedatetimeshortaccurate", 'langconfig'), + "20/02/24, 13:09:02", + ], + 'numeric_strftimedatetimeshortaccurate' => [ + 1708405742, + get_string("strftimedatetimeshortaccurate", 'langconfig'), + "20/02/24, 13:09:02", + ], + ]; + } + + /** + * Test \core_date::strftime function. + * + * @dataProvider get_strftime_provider + * @param mixed $input Input passed to strftime + * @param string $format The date format to pass to strftime, falls back to '%c' if null + * @param string $expected The output generated by strftime + */ + public function test_strftime(mixed $input, string $format, string $expected): void { + $this->assertEqualsIgnoringWhitespace($expected, core_date::strftime($format, $input)); + } } diff --git a/lib/tests/filetypes_test.php b/lib/tests/filetypes_test.php index 478de231f163c..7f3bf48ec2ce6 100644 --- a/lib/tests/filetypes_test.php +++ b/lib/tests/filetypes_test.php @@ -214,36 +214,36 @@ public function test_cleanup() { $this->resetAfterTest(); // The custom filetypes setting is empty to start with. - $this->assertObjectNotHasAttribute('customfiletypes', $CFG); + $this->assertObjectNotHasProperty('customfiletypes', $CFG); // Add a custom filetype, then delete it. core_filetypes::add_type('frog', 'application/x-frog', 'document'); - $this->assertObjectHasAttribute('customfiletypes', $CFG); + $this->assertObjectHasProperty('customfiletypes', $CFG); core_filetypes::delete_type('frog'); - $this->assertObjectNotHasAttribute('customfiletypes', $CFG); + $this->assertObjectNotHasProperty('customfiletypes', $CFG); // Change a standard filetype, then change it back. core_filetypes::update_type('asm', 'asm', 'text/plain', 'document'); - $this->assertObjectHasAttribute('customfiletypes', $CFG); + $this->assertObjectHasProperty('customfiletypes', $CFG); core_filetypes::update_type('asm', 'asm', 'text/plain', 'sourcecode'); - $this->assertObjectNotHasAttribute('customfiletypes', $CFG); + $this->assertObjectNotHasProperty('customfiletypes', $CFG); // Delete a standard filetype, then add it back (the same). core_filetypes::delete_type('asm'); - $this->assertObjectHasAttribute('customfiletypes', $CFG); + $this->assertObjectHasProperty('customfiletypes', $CFG); core_filetypes::add_type('asm', 'text/plain', 'sourcecode'); - $this->assertObjectNotHasAttribute('customfiletypes', $CFG); + $this->assertObjectNotHasProperty('customfiletypes', $CFG); // Revert a changed type. core_filetypes::update_type('asm', 'asm', 'text/plain', 'document'); - $this->assertObjectHasAttribute('customfiletypes', $CFG); + $this->assertObjectHasProperty('customfiletypes', $CFG); core_filetypes::revert_type_to_default('asm'); - $this->assertObjectNotHasAttribute('customfiletypes', $CFG); + $this->assertObjectNotHasProperty('customfiletypes', $CFG); // Revert a deleted type. core_filetypes::delete_type('asm'); - $this->assertObjectHasAttribute('customfiletypes', $CFG); + $this->assertObjectHasProperty('customfiletypes', $CFG); core_filetypes::revert_type_to_default('asm'); - $this->assertObjectNotHasAttribute('customfiletypes', $CFG); + $this->assertObjectNotHasProperty('customfiletypes', $CFG); } } diff --git a/lib/tests/grouplib_test.php b/lib/tests/grouplib_test.php index ea02fe88a858e..378d94944696b 100644 --- a/lib/tests/grouplib_test.php +++ b/lib/tests/grouplib_test.php @@ -656,9 +656,9 @@ public function test_groups_get_course_data() { // Get the data. $data = groups_get_course_data($course->id); $this->assertInstanceOf('stdClass', $data); - $this->assertObjectHasAttribute('groups', $data); - $this->assertObjectHasAttribute('groupings', $data); - $this->assertObjectHasAttribute('mappings', $data); + $this->assertObjectHasProperty('groups', $data); + $this->assertObjectHasProperty('groupings', $data); + $this->assertObjectHasProperty('mappings', $data); // Test we have the expected items returns. $this->assertCount(4, $data->groups); diff --git a/lib/tests/lock/timing_wrapper_lock_factory_test.php b/lib/tests/lock/timing_wrapper_lock_factory_test.php index a05815d4cd70e..f1918bff302ec 100644 --- a/lib/tests/lock/timing_wrapper_lock_factory_test.php +++ b/lib/tests/lock/timing_wrapper_lock_factory_test.php @@ -50,7 +50,7 @@ public function test_lock_timing(): void { $duration = microtime(true) - $before; // Confirm that perf info is now logged and appears plausible. - $this->assertObjectHasAttribute('locks', $PERF); + $this->assertObjectHasProperty('locks', $PERF); $this->assertEquals('phpunit', $PERF->locks[0]->type); $this->assertEquals('frog', $PERF->locks[0]->resource); $this->assertTrue($PERF->locks[0]->wait <= $duration); diff --git a/lib/tests/moodlelib_test.php b/lib/tests/moodlelib_test.php index 1b277bb9e8234..a04c9e1faa887 100644 --- a/lib/tests/moodlelib_test.php +++ b/lib/tests/moodlelib_test.php @@ -2145,11 +2145,7 @@ public function test_lang_string_var_export() { public function test_get_string_limitation() { // This is one of the limitations to the lang_string class. It can't be // used as a key. - if (PHP_VERSION_ID >= 80000) { - $this->expectException(\TypeError::class); - } else { - $this->expectWarning(); - } + $this->expectException(\TypeError::class); $array = array(get_string('yes', null, null, true) => 'yes'); } @@ -3159,8 +3155,8 @@ public function test_complete_user_login() { $this->assertTimeCurrent($USER->currentlogin); $this->assertSame(sesskey(), $USER->sesskey); $this->assertTimeCurrent($USER->preference['_lastloaded']); - $this->assertObjectNotHasAttribute('password', $USER); - $this->assertObjectNotHasAttribute('description', $USER); + $this->assertObjectNotHasProperty('password', $USER); + $this->assertObjectNotHasProperty('description', $USER); } /** diff --git a/lib/tests/session_manager_test.php b/lib/tests/session_manager_test.php index 34239875caa44..9f025c7774d13 100644 --- a/lib/tests/session_manager_test.php +++ b/lib/tests/session_manager_test.php @@ -99,15 +99,15 @@ public function test_set_user() { $this->assertEquals(0, $USER->id); $user = $this->getDataGenerator()->create_user(); - $this->assertObjectHasAttribute('description', $user); - $this->assertObjectHasAttribute('password', $user); + $this->assertObjectHasProperty('description', $user); + $this->assertObjectHasProperty('password', $user); \core\session\manager::set_user($user); $this->assertEquals($user->id, $USER->id); - $this->assertObjectNotHasAttribute('description', $user); - $this->assertObjectNotHasAttribute('password', $user); - $this->assertObjectHasAttribute('sesskey', $user); + $this->assertObjectNotHasProperty('description', $user); + $this->assertObjectNotHasProperty('password', $user); + $this->assertObjectHasProperty('sesskey', $user); $this->assertSame($user, $GLOBALS['USER']); $this->assertSame($GLOBALS['USER'], $_SESSION['USER']); $this->assertSame($GLOBALS['USER'], $USER); @@ -124,8 +124,8 @@ public function test_login_user() { @\core\session\manager::login_user($user); // Ignore header error messages. $this->assertEquals($user->id, $USER->id); - $this->assertObjectNotHasAttribute('description', $user); - $this->assertObjectNotHasAttribute('password', $user); + $this->assertObjectNotHasProperty('description', $user); + $this->assertObjectNotHasProperty('password', $user); $this->assertSame($user, $GLOBALS['USER']); $this->assertSame($GLOBALS['USER'], $_SESSION['USER']); $this->assertSame($GLOBALS['USER'], $USER); @@ -558,7 +558,7 @@ public function test_loginas() { $_SESSION['extra'] = true; // Try admin loginas this user in system context. - $this->assertObjectNotHasAttribute('realuser', $USER); + $this->assertObjectNotHasProperty('realuser', $USER); \core\session\manager::loginas($user->id, \context_system::instance()); $this->assertSame($user->id, $USER->id); diff --git a/lib/tests/session_redis_cluster_test.php b/lib/tests/session_redis_cluster_test.php new file mode 100644 index 0000000000000..515e51ef12941 --- /dev/null +++ b/lib/tests/session_redis_cluster_test.php @@ -0,0 +1,107 @@ +. + +namespace core; + +use core\session\redis as redis_session; +use RedisClusterException; + +/** + * Unit tests for Redis cluster in the core/session/redis.php. + * + * NOTE: in order to execute this test you need to set up + * Redis cluster server and add configuration a constant + * to config.php or phpunit.xml configuration file: + * + * define('TEST_SESSION_REDIS_HOSTCLUSTER', '127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002'); + * define('TEST_SESSION_REDIS_AUTHCLUSTER', 'foobared'); + * + * define('TEST_SESSION_REDIS_ENCRYPTCLUSTER', ['verify_peer' => false, 'verify_peer_name' => false]); + * OR + * define('TEST_SESSION_REDIS_ENCRYPTCLUSTER', ['cafile' => '/cafile/dir/ca.crt']); + * + * @package core + * @copyright 2024 Meirza + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \core\session\redis + */ +class session_redis_cluster_test extends \advanced_testcase { + + /** + * Set up the test environment. + */ + public function setUp(): void { + global $CFG; + + if (!\cache_helper::is_cluster_available()) { + $this->markTestSkipped('Could not test core_session with cluster, class RedisCluster is not available.'); + } else if (!defined('TEST_SESSION_REDIS_HOSTCLUSTER')) { + $this->markTestSkipped('Could not test session_redis_cluster_test with cluster, missing configuration. ' . + "Example: define('TEST_SESSION_REDIS_HOSTCLUSTER', " . + "'localhost:7000,localhost:7001,localhost:7002');"); + } + $this->resetAfterTest(); + $CFG->session_redis_host = TEST_SESSION_REDIS_HOSTCLUSTER; + if (defined('TEST_SESSION_REDIS_ENCRYPTCLUSTER') && TEST_SESSION_REDIS_ENCRYPTCLUSTER) { + $CFG->session_redis_encrypt = TEST_SESSION_REDIS_ENCRYPTCLUSTER; + } + if (defined('TEST_SESSION_REDIS_AUTHCLUSTER') && TEST_SESSION_REDIS_AUTHCLUSTER) { + $CFG->session_redis_auth = TEST_SESSION_REDIS_AUTHCLUSTER; + } + } + + /** + * Tests compression for session read and write operations. + * + * It covers the behavior of session read and write operations under different compression configurations. + * + * @covers ::read + * @covers ::write + */ + public function test_read_and_write(): void { + $rediscluster = new redis_session(); + $rediscluster->init(); + $this->assertTrue($rediscluster->write('sess1', 'DATA')); + $this->assertSame('DATA', $rediscluster->read('sess1')); + $this->assertTrue($rediscluster->close()); + } + + /** + * Tests the behavior when connection attempts to Redis cluster are exceeded. + * + * It sets up the environment to simulate multiple failed connection attempts and + * checks if the expected exception message is received. + * + * @covers ::init + */ + public function test_exception_when_connection_attempts_exceeded(): void { + global $CFG; + + $CFG->session_redis_host = '127.0.0.1:1111111,127.0.0.1:1111112,127.0.0.1:1111113'; + $actual = ''; + + $rediscluster = new redis_session(); + try { + $rediscluster->init(); + } catch (RedisClusterException $e) { + $actual = $e->getMessage(); + } + + $expected = "Failed to connect (try 5 out of 5) to Redis at"; + $this->assertDebuggingCalledCount(5); + $this->assertStringContainsString($expected, $actual); + } +} diff --git a/lib/tests/session_redis_test.php b/lib/tests/session_redis_test.php index 4d5af406a85e6..1628f34e8423c 100644 --- a/lib/tests/session_redis_test.php +++ b/lib/tests/session_redis_test.php @@ -348,7 +348,7 @@ public function test_exception_when_connection_attempts_exceeded() { if ($this->encrypted) { $host = "tls://$host"; } - $expected = "Failed to connect (try 5 out of 5) to redis at $host:111111"; + $expected = "Failed to connect (try 5 out of 5) to Redis at $host:111111"; $this->assertDebuggingCalledCount(5); $this->assertStringContainsString($expected, $actual); } @@ -368,6 +368,6 @@ public function test_session_redis_encrypt() { $sess = new \core\session\redis(); $prop = new \ReflectionProperty(\core\session\redis::class, 'host'); - $this->assertEquals('tls://' . TEST_SESSION_REDIS_HOST, $prop->getValue($sess)); + $this->assertEquals('tls://' . TEST_SESSION_REDIS_HOST, $prop->getValue($sess)[0]); } } diff --git a/lib/tests/sessionlib_test.php b/lib/tests/sessionlib_test.php index f4e40ce08ae23..aa801ee37d992 100644 --- a/lib/tests/sessionlib_test.php +++ b/lib/tests/sessionlib_test.php @@ -76,7 +76,7 @@ public function test_cron_setup_user() { $this->assertSame($user1->id, $USER->id); $this->assertSame($PAGE->context, \context_course::instance($SITE->id)); $this->assertNotSame($adminsession, $SESSION); - $this->assertObjectNotHasAttribute('test1', $SESSION); + $this->assertObjectNotHasProperty('test1', $SESSION); $this->assertEmpty((array)$SESSION); $usersession1 = $SESSION; $SESSION->test2 = true; @@ -261,7 +261,7 @@ public function test_sesskey() { $user = $this->getDataGenerator()->create_user(); \core\session\manager::init_empty_session(); - $this->assertObjectNotHasAttribute('sesskey', $USER); + $this->assertObjectNotHasProperty('sesskey', $USER); $sesskey = sesskey(); $this->assertNotEmpty($sesskey); diff --git a/lib/tests/upgradelib_test.php b/lib/tests/upgradelib_test.php index 4b56c8507c187..4da34eb8e9ccd 100644 --- a/lib/tests/upgradelib_test.php +++ b/lib/tests/upgradelib_test.php @@ -915,8 +915,8 @@ public function test_upgrade_core_licenses() { foreach ($licenses as $license) { $this->assertContains($license->shortname, $expectedshortnames); - $this->assertObjectHasAttribute('custom', $license); - $this->assertObjectHasAttribute('sortorder', $license); + $this->assertObjectHasProperty('custom', $license); + $this->assertObjectHasProperty('sortorder', $license); } // A core license which was deleted prior to upgrade should not be reinstalled. $actualshortnames = $DB->get_records_menu('license', null, '', 'id, shortname'); @@ -1568,7 +1568,7 @@ public function test_moodle_upgrade_check_outageless() { public function test_moodle_start_upgrade_outageless() { global $CFG; $this->resetAfterTest(); - $this->assertObjectNotHasAttribute('upgraderunning', $CFG); + $this->assertObjectNotHasProperty('upgraderunning', $CFG); // Confirm that starting normally sets the upgraderunning flag. upgrade_started(); @@ -1592,7 +1592,7 @@ public function test_moodle_start_upgrade_outageless() { public function test_moodle_set_upgrade_timeout_outageless() { global $CFG; $this->resetAfterTest(); - $this->assertObjectNotHasAttribute('upgraderunning', $CFG); + $this->assertObjectNotHasProperty('upgraderunning', $CFG); // Confirm running normally sets the timeout. upgrade_set_timeout(120); diff --git a/lib/tests/user_test.php b/lib/tests/user_test.php index 0a82f09f18627..29b6de77eeac1 100644 --- a/lib/tests/user_test.php +++ b/lib/tests/user_test.php @@ -178,12 +178,12 @@ public function test_search() { $this->assertEquals('House', $result[0]->lastname); $this->assertEquals('house@x.x', $result[0]->email); $this->assertEquals(0, $result[0]->deleted); - $this->assertObjectHasAttribute('firstnamephonetic', $result[0]); - $this->assertObjectHasAttribute('lastnamephonetic', $result[0]); - $this->assertObjectHasAttribute('middlename', $result[0]); - $this->assertObjectHasAttribute('alternatename', $result[0]); - $this->assertObjectHasAttribute('imagealt', $result[0]); - $this->assertObjectHasAttribute('username', $result[0]); + $this->assertObjectHasProperty('firstnamephonetic', $result[0]); + $this->assertObjectHasProperty('lastnamephonetic', $result[0]); + $this->assertObjectHasProperty('middlename', $result[0]); + $this->assertObjectHasProperty('alternatename', $result[0]); + $this->assertObjectHasProperty('imagealt', $result[0]); + $this->assertObjectHasProperty('username', $result[0]); // Now search by lastname, both names, and partials, case-insensitive. $this->assertEquals($result, \core_user::search('House')); diff --git a/lib/upgrade.txt b/lib/upgrade.txt index d94d71319819e..163bde8bb810b 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -99,6 +99,44 @@ information provided here is intended especially for developers. 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. +* The triggerSelector method in the `core/comboboxsearch/search_combobox` JS module is deprecated. It was not used. +* PHPUnit has been upgraded to 9.6 (see MDL-81266 for details). + The main goal of the update is to allow developers to know in advance, + via deprecations, which stuff is going to stop working (because of being removed) + with PHPUnit 10 (see MDL-80969 for details). Other than that, the changes are minimal. + This is the list of noticeable changes: + - Deprecation: MDL-81281. A number of attribute-related assertions have been deprecated, will + be removed with PHPUnit 10. Alternatives for *some* of them are available: + - assertClassHasAttribute() + - assertClassNotHasAttribute() + - assertClassHasStaticAttribute() + - assertClassNotHasStaticAttribute() + - assertObjectHasAttribute() => assertObjectHasProperty() + - assertObjectNotHasAttribute() => assertObjectNotHasProperty() + - Deprecation: MDL-81266. A number of deprecation/notice/warning/error expectations have + been deprecated, will be removed with PHPUnit 10. No alternative exists. A working + replacement is available in the linked issue, hopefully there aren't many cases. + - expectDeprecation() + - expectDeprecationMessage() + - expectDeprecationMessageMatches() + - expectError() + - expectErrorMessage() + - expectErrorMessageMatches() + - expectNotice() + - expectNoticeMessage() + - expectNoticeMessageMatches() + - expectWarning() + - expectWarningMessage() + - expectWarningMessageMatches() + - Deprecation: MDL-81308. The ->withConsecutive() functionality on PHPUnit mocks + has been *silently* deprecated, will be removed with PHPUnit 10. Note that this + won't affect PHPUnit 9.6 runs and an alternative path will be + proposed in the linked issue, part of the PHPUnit 10 epic. + - Deprecation: PHPUnit\Framework\TestCase::getMockClass() has been deprecated, will + be removed with PHPUnit 10. No clear alternative exists and won't be investigated, + because there aren't cases in core. + - Deprecation: Cannot use the "Test" suffix on abstract test case classes. Proceed to + rename them to end with "TestCase" instead. === 4.3 === diff --git a/lib/xapi/tests/local/statement_test.php b/lib/xapi/tests/local/statement_test.php index c9ff7be7aefaa..b60e02cf8a431 100644 --- a/lib/xapi/tests/local/statement_test.php +++ b/lib/xapi/tests/local/statement_test.php @@ -171,10 +171,10 @@ public function test_create(bool $useagent, array $extras, array $extravalues) { $alldefined = array_merge($extras, $extravalues); foreach ($allextras as $extra) { if (in_array($extra, $alldefined)) { - $this->assertObjectHasAttribute($extra, $data); + $this->assertObjectHasProperty($extra, $data); $this->assertNotEmpty($data->$extra); } else { - $this->assertObjectNotHasAttribute($extra, $data); + $this->assertObjectNotHasProperty($extra, $data); } } } @@ -305,10 +305,10 @@ public function test_create_from_data(bool $useagent, array $extras, array $extr $alldefined = array_merge($extras, $extravalues); foreach ($allextras as $extra) { if (in_array($extra, $alldefined)) { - $this->assertObjectHasAttribute($extra, $data); + $this->assertObjectHasProperty($extra, $data); $this->assertNotEmpty($data->object); } else { - $this->assertObjectNotHasAttribute($extra, $data); + $this->assertObjectNotHasProperty($extra, $data); } } } @@ -340,7 +340,7 @@ public function test_add_attachment() { // Check resulting json. $statementdata = json_decode(json_encode($statement)); - $this->assertObjectHasAttribute('attachments', $statementdata); + $this->assertObjectHasProperty('attachments', $statementdata); $this->assertNotEmpty($statementdata->attachments); $this->assertCount(1, $statementdata->attachments); } @@ -382,7 +382,7 @@ public function test_add_attachment_from_data() { $this->assertEquals($itemdata->length, $attachmentdata->length); $statementdata = json_decode(json_encode($statement)); - $this->assertObjectHasAttribute('attachments', $statementdata); + $this->assertObjectHasProperty('attachments', $statementdata); $this->assertNotEmpty($statementdata->attachments); $this->assertCount(1, $statementdata->attachments); diff --git a/message/output/popup/tests/externallib_test.php b/message/output/popup/tests/externallib_test.php index 1b27e02305742..a5e3988d7a2b4 100644 --- a/message/output/popup/tests/externallib_test.php +++ b/message/output/popup/tests/externallib_test.php @@ -101,7 +101,7 @@ public function test_get_popup_notifications_as_recipient() { $found = 0; foreach ($result['notifications'] as $notification) { if (!empty($notification->customdata)) { - $this->assertObjectHasAttribute('datakey', json_decode($notification->customdata)); + $this->assertObjectHasProperty('datakey', json_decode($notification->customdata)); $found++; } } diff --git a/message/tests/api_test.php b/message/tests/api_test.php index 1d6496e898b25..c555a4a121880 100644 --- a/message/tests/api_test.php +++ b/message/tests/api_test.php @@ -1212,36 +1212,36 @@ public function test_get_conversations_no_restrictions() { // Verify format of the return structure. foreach ($conversations as $conv) { - $this->assertObjectHasAttribute('id', $conv); - $this->assertObjectHasAttribute('name', $conv); - $this->assertObjectHasAttribute('subname', $conv); - $this->assertObjectHasAttribute('imageurl', $conv); - $this->assertObjectHasAttribute('type', $conv); - $this->assertObjectHasAttribute('isfavourite', $conv); - $this->assertObjectHasAttribute('membercount', $conv); - $this->assertObjectHasAttribute('isread', $conv); - $this->assertObjectHasAttribute('unreadcount', $conv); - $this->assertObjectHasAttribute('members', $conv); + $this->assertObjectHasProperty('id', $conv); + $this->assertObjectHasProperty('name', $conv); + $this->assertObjectHasProperty('subname', $conv); + $this->assertObjectHasProperty('imageurl', $conv); + $this->assertObjectHasProperty('type', $conv); + $this->assertObjectHasProperty('isfavourite', $conv); + $this->assertObjectHasProperty('membercount', $conv); + $this->assertObjectHasProperty('isread', $conv); + $this->assertObjectHasProperty('unreadcount', $conv); + $this->assertObjectHasProperty('members', $conv); foreach ($conv->members as $member) { - $this->assertObjectHasAttribute('id', $member); - $this->assertObjectHasAttribute('fullname', $member); - $this->assertObjectHasAttribute('profileimageurl', $member); - $this->assertObjectHasAttribute('profileimageurlsmall', $member); - $this->assertObjectHasAttribute('isonline', $member); - $this->assertObjectHasAttribute('showonlinestatus', $member); - $this->assertObjectHasAttribute('isblocked', $member); - $this->assertObjectHasAttribute('iscontact', $member); - $this->assertObjectHasAttribute('isdeleted', $member); - $this->assertObjectHasAttribute('canmessage', $member); - $this->assertObjectHasAttribute('requirescontact', $member); - $this->assertObjectHasAttribute('contactrequests', $member); + $this->assertObjectHasProperty('id', $member); + $this->assertObjectHasProperty('fullname', $member); + $this->assertObjectHasProperty('profileimageurl', $member); + $this->assertObjectHasProperty('profileimageurlsmall', $member); + $this->assertObjectHasProperty('isonline', $member); + $this->assertObjectHasProperty('showonlinestatus', $member); + $this->assertObjectHasProperty('isblocked', $member); + $this->assertObjectHasProperty('iscontact', $member); + $this->assertObjectHasProperty('isdeleted', $member); + $this->assertObjectHasProperty('canmessage', $member); + $this->assertObjectHasProperty('requirescontact', $member); + $this->assertObjectHasProperty('contactrequests', $member); } - $this->assertObjectHasAttribute('messages', $conv); + $this->assertObjectHasProperty('messages', $conv); foreach ($conv->messages as $message) { - $this->assertObjectHasAttribute('id', $message); - $this->assertObjectHasAttribute('useridfrom', $message); - $this->assertObjectHasAttribute('text', $message); - $this->assertObjectHasAttribute('timecreated', $message); + $this->assertObjectHasProperty('id', $message); + $this->assertObjectHasProperty('useridfrom', $message); + $this->assertObjectHasProperty('text', $message); + $this->assertObjectHasProperty('timecreated', $message); } } } @@ -4232,8 +4232,8 @@ public function test_create_contact_request() { $sink->close(); // Test customdata. $customdata = json_decode($messages[0]->customdata); - $this->assertObjectHasAttribute('notificationiconurl', $customdata); - $this->assertObjectHasAttribute('actionbuttons', $customdata); + $this->assertObjectHasProperty('notificationiconurl', $customdata); + $this->assertObjectHasProperty('actionbuttons', $customdata); $this->assertCount(2, (array) $customdata->actionbuttons); $this->assertEquals($user1->id, $request->userid); @@ -4308,12 +4308,12 @@ public function test_get_contact_requests() { $this->assertEquals($user2->id, $request->id); $this->assertEquals(fullname($user2), $request->fullname); - $this->assertObjectHasAttribute('profileimageurl', $request); - $this->assertObjectHasAttribute('profileimageurlsmall', $request); - $this->assertObjectHasAttribute('isonline', $request); - $this->assertObjectHasAttribute('showonlinestatus', $request); - $this->assertObjectHasAttribute('isblocked', $request); - $this->assertObjectHasAttribute('iscontact', $request); + $this->assertObjectHasProperty('profileimageurl', $request); + $this->assertObjectHasProperty('profileimageurlsmall', $request); + $this->assertObjectHasProperty('isonline', $request); + $this->assertObjectHasProperty('showonlinestatus', $request); + $this->assertObjectHasProperty('isblocked', $request); + $this->assertObjectHasProperty('iscontact', $request); } /** @@ -4980,7 +4980,7 @@ public function test_get_conversation_members() { $this->assertEquals(true, $member1->showonlinestatus); $this->assertEquals(false, $member1->iscontact); $this->assertEquals(false, $member1->isblocked); - $this->assertObjectHasAttribute('contactrequests', $member1); + $this->assertObjectHasProperty('contactrequests', $member1); $this->assertEmpty($member1->contactrequests); $this->assertEquals($user2->id, $member2->id); @@ -4989,7 +4989,7 @@ public function test_get_conversation_members() { $this->assertEquals(true, $member2->showonlinestatus); $this->assertEquals(true, $member2->iscontact); $this->assertEquals(false, $member2->isblocked); - $this->assertObjectHasAttribute('contactrequests', $member2); + $this->assertObjectHasProperty('contactrequests', $member2); $this->assertEmpty($member2->contactrequests); $this->assertEquals($user3->id, $member3->id); @@ -4998,7 +4998,7 @@ public function test_get_conversation_members() { $this->assertEquals(true, $member3->showonlinestatus); $this->assertEquals(false, $member3->iscontact); $this->assertEquals(true, $member3->isblocked); - $this->assertObjectHasAttribute('contactrequests', $member3); + $this->assertObjectHasProperty('contactrequests', $member3); $this->assertEmpty($member3->contactrequests); } @@ -5149,18 +5149,18 @@ public function test_send_message_to_conversation_individual_conversation() { $messages = $messagessink->get_messages(); // Test customdata. $customdata = json_decode($messages[0]->customdata); - $this->assertObjectHasAttribute('notificationiconurl', $customdata); - $this->assertObjectHasAttribute('actionbuttons', $customdata); + $this->assertObjectHasProperty('notificationiconurl', $customdata); + $this->assertObjectHasProperty('actionbuttons', $customdata); $this->assertCount(1, (array) $customdata->actionbuttons); - $this->assertObjectHasAttribute('placeholders', $customdata); + $this->assertObjectHasProperty('placeholders', $customdata); $this->assertCount(1, (array) $customdata->placeholders); // Verify the message returned. $this->assertInstanceOf(\stdClass::class, $message1); - $this->assertObjectHasAttribute('id', $message1); + $this->assertObjectHasProperty('id', $message1); $this->assertEquals($user1->id, $message1->useridfrom); $this->assertEquals('this is a message', $message1->text); - $this->assertObjectHasAttribute('timecreated', $message1); + $this->assertObjectHasProperty('timecreated', $message1); // Verify events. Note: the event is a message read event because of an if (PHPUNIT) conditional within message_send(), // however, we can still determine the number and ids of any recipients this way. @@ -5199,17 +5199,17 @@ public function test_send_message_to_conversation_group_conversation() { $messages = $messagessink->get_messages(); // Verify the message returned. $this->assertInstanceOf(\stdClass::class, $message1); - $this->assertObjectHasAttribute('id', $message1); + $this->assertObjectHasProperty('id', $message1); $this->assertEquals($user1->id, $message1->useridfrom); $this->assertEquals('message to the group', $message1->text); - $this->assertObjectHasAttribute('timecreated', $message1); + $this->assertObjectHasProperty('timecreated', $message1); // Test customdata. $customdata = json_decode($messages[0]->customdata); - $this->assertObjectHasAttribute('actionbuttons', $customdata); + $this->assertObjectHasProperty('actionbuttons', $customdata); $this->assertCount(1, (array) $customdata->actionbuttons); - $this->assertObjectHasAttribute('placeholders', $customdata); + $this->assertObjectHasProperty('placeholders', $customdata); $this->assertCount(1, (array) $customdata->placeholders); - $this->assertObjectNotHasAttribute('notificationiconurl', $customdata); // No group image means no image. + $this->assertObjectNotHasProperty('notificationiconurl', $customdata); // No group image means no image. // Verify events. Note: the event is a message read event because of an if (PHPUNIT) conditional within message_send(), // however, we can still determine the number and ids of any recipients this way. @@ -5268,14 +5268,14 @@ public function test_send_message_to_conversation_linked_group_conversation() { $messages = $messagessink->get_messages(); // Verify the message returned. $this->assertInstanceOf(\stdClass::class, $message1); - $this->assertObjectHasAttribute('id', $message1); + $this->assertObjectHasProperty('id', $message1); $this->assertEquals($user1->id, $message1->useridfrom); $this->assertEquals('message to the group', $message1->text); - $this->assertObjectHasAttribute('timecreated', $message1); + $this->assertObjectHasProperty('timecreated', $message1); // Test customdata. $customdata = json_decode($messages[0]->customdata); - $this->assertObjectHasAttribute('notificationiconurl', $customdata); - $this->assertObjectHasAttribute('notificationsendericonurl', $customdata); + $this->assertObjectHasProperty('notificationiconurl', $customdata); + $this->assertObjectHasProperty('notificationsendericonurl', $customdata); $this->assertEquals($groupimageurl, $customdata->notificationiconurl); $this->assertEquals($group->name, $customdata->conversationname); $userpicture = new \user_picture($user1); diff --git a/message/tests/externallib_test.php b/message/tests/externallib_test.php index e1a54b8135182..02538447d3afc 100644 --- a/message/tests/externallib_test.php +++ b/message/tests/externallib_test.php @@ -1484,7 +1484,7 @@ public function test_get_messages() { $messages = external_api::clean_returnvalue(core_message_external::get_messages_returns(), $messages); $this->assertCount(1, $messages['messages']); // Check we receive custom data as a unserialisable json. - $this->assertObjectHasAttribute('datakey', json_decode($messages['messages'][0]['customdata'])); + $this->assertObjectHasProperty('datakey', json_decode($messages['messages'][0]['customdata'])); $this->assertEquals('mod_feedback', $messages['messages'][0]['component']); $this->assertEquals('submission', $messages['messages'][0]['eventtype']); $feedbackicon = clean_param($PAGE->get_renderer('core')->image_url('monologo', 'mod_feedback')->out(), PARAM_URL); @@ -4527,7 +4527,7 @@ public function test_get_conversation_members() { $this->assertEquals(true, $member1->showonlinestatus); $this->assertEquals(false, $member1->iscontact); $this->assertEquals(false, $member1->isblocked); - $this->assertObjectHasAttribute('contactrequests', $member1); + $this->assertObjectHasProperty('contactrequests', $member1); $this->assertEmpty($member1->contactrequests); $this->assertEquals($user2->id, $member2->id); @@ -4536,7 +4536,7 @@ public function test_get_conversation_members() { $this->assertEquals(true, $member2->showonlinestatus); $this->assertEquals(true, $member2->iscontact); $this->assertEquals(false, $member2->isblocked); - $this->assertObjectHasAttribute('contactrequests', $member2); + $this->assertObjectHasProperty('contactrequests', $member2); $this->assertEmpty($member2->contactrequests); $this->assertEquals($user3->id, $member3->id); @@ -4545,7 +4545,7 @@ public function test_get_conversation_members() { $this->assertEquals(true, $member3->showonlinestatus); $this->assertEquals(false, $member3->iscontact); $this->assertEquals(true, $member3->isblocked); - $this->assertObjectHasAttribute('contactrequests', $member3); + $this->assertObjectHasProperty('contactrequests', $member3); $this->assertEmpty($member3->contactrequests); } @@ -4712,12 +4712,12 @@ public function test_send_messages_to_conversation_individual() { external_api::clean_returnvalue(core_message_external::send_messages_to_conversation_returns(), $writtenmessages); $this->assertCount(2, $writtenmessages); - $this->assertObjectHasAttribute('id', $writtenmessages[0]); + $this->assertObjectHasProperty('id', $writtenmessages[0]); $this->assertEquals($user1->id, $writtenmessages[0]->useridfrom); $this->assertEquals('

    a message from user 1

    ', $writtenmessages[0]->text); $this->assertNotEmpty($writtenmessages[0]->timecreated); - $this->assertObjectHasAttribute('id', $writtenmessages[1]); + $this->assertObjectHasProperty('id', $writtenmessages[1]); $this->assertEquals($user1->id, $writtenmessages[1]->useridfrom); $this->assertEquals('

    another message from user 1

    ', $writtenmessages[1]->text); $this->assertNotEmpty($writtenmessages[1]->timecreated); @@ -4764,12 +4764,12 @@ public function test_send_messages_to_conversation_group() { external_api::clean_returnvalue(core_message_external::send_messages_to_conversation_returns(), $writtenmessages); $this->assertCount(2, $writtenmessages); - $this->assertObjectHasAttribute('id', $writtenmessages[0]); + $this->assertObjectHasProperty('id', $writtenmessages[0]); $this->assertEquals($user1->id, $writtenmessages[0]->useridfrom); $this->assertEquals('

    a message from user 1 to group conv

    ', $writtenmessages[0]->text); $this->assertNotEmpty($writtenmessages[0]->timecreated); - $this->assertObjectHasAttribute('id', $writtenmessages[1]); + $this->assertObjectHasProperty('id', $writtenmessages[1]); $this->assertEquals($user1->id, $writtenmessages[1]->useridfrom); $this->assertEquals('

    another message from user 1 to group conv

    ', $writtenmessages[1]->text); $this->assertNotEmpty($writtenmessages[1]->timecreated); diff --git a/mod/bigbluebuttonbn/classes/local/config.php b/mod/bigbluebuttonbn/classes/local/config.php index 9a27d3fc56fbe..9f47b5fdcc73e 100644 --- a/mod/bigbluebuttonbn/classes/local/config.php +++ b/mod/bigbluebuttonbn/classes/local/config.php @@ -36,8 +36,6 @@ class config { /** @var string Default bigbluebutton server shared secret */ public const DEFAULT_SHARED_SECRET = '0b21fcaf34673a8c3ec8ed877d76ae34'; - /** @var string Default bigbluebutton data processing agreement url */ - public const DEFAULT_DPA_URL = 'https://blindsidenetworks.com/dpa-moodle-free-tier'; /** @var string the default bigbluebutton checksum algorithm */ public const DEFAULT_CHECKSUM_ALGORITHM = 'SHA256'; @@ -67,8 +65,8 @@ protected static function get_moodle_version_major(): string { */ protected static function defaultvalues(): array { return [ - 'server_url' => self::DEFAULT_SERVER_URL, - 'shared_secret' => self::DEFAULT_SHARED_SECRET, + 'server_url' => '', + 'shared_secret' => '', 'voicebridge_editable' => false, 'importrecordings_enabled' => false, 'importrecordings_from_deleted_enabled' => false, @@ -177,6 +175,27 @@ public static function importrecordings_enabled(): bool { return (boolean) self::get('importrecordings_enabled'); } + /** + * Check if bbb server credentials are invalid. + * + * @return bool + */ + public static function server_credentials_invalid(): bool { + // Test server credentials across all versions of the plugin are flagged. + $parsedurl = parse_url(self::get('server_url')); + $defaultserverurl = parse_url(self::DEFAULT_SERVER_URL); + if (!isset($parsedurl['host'])) { + return false; + } + if (strpos($parsedurl['host'], $defaultserverurl['host']) === 0) { + return true; + } + if (strpos($parsedurl['host'], 'test-install.blindsidenetworks.com') === 0) { + return true; + } + return false; + } + /** * Wraps current settings in an array. * diff --git a/mod/bigbluebuttonbn/classes/output/view_page.php b/mod/bigbluebuttonbn/classes/output/view_page.php index 90848202b7593..d2662d03ade25 100644 --- a/mod/bigbluebuttonbn/classes/output/view_page.php +++ b/mod/bigbluebuttonbn/classes/output/view_page.php @@ -66,14 +66,6 @@ public function export_for_template(renderer_base $output): stdClass { 'joinurl' => $this->instance->get_join_url(), ]; - if ($this->show_default_server_warning()) { - $templatedata->serverwarning = (new notification( - get_string('view_warning_default_server', 'mod_bigbluebuttonbn'), - notification::NOTIFY_WARNING, - false - ))->export_for_template($output); - } - $viewwarningmessage = config::get('general_warning_message'); if ($this->show_view_warning() && !empty($viewwarningmessage)) { $templatedata->sitenotification = (object) [ @@ -127,23 +119,6 @@ public function export_for_template(renderer_base $output): stdClass { return $templatedata; } - /** - * Whether to show the default server warning. - * - * @return bool - */ - protected function show_default_server_warning(): bool { - if (!$this->instance->is_admin()) { - return false; - } - - if (config::DEFAULT_SERVER_URL != config::get('server_url')) { - return false; - } - - return true; - } - /** * Whether to show the view warning. * diff --git a/mod/bigbluebuttonbn/classes/settings.php b/mod/bigbluebuttonbn/classes/settings.php index 79f9a86ed0dab..e874d9b1bbc04 100644 --- a/mod/bigbluebuttonbn/classes/settings.php +++ b/mod/bigbluebuttonbn/classes/settings.php @@ -152,7 +152,7 @@ protected function add_conditional_element(string $name, admin_setting $item, ad * @throws \coding_exception */ protected function add_general_settings(): admin_settingpage { - global $CFG; + global $CFG, $OUTPUT; $settingsgeneral = new admin_settingpage( $this->section, get_string('config_general', 'bigbluebuttonbn'), @@ -167,12 +167,12 @@ protected function add_general_settings(): admin_settingpage { ); $settingsgeneral->add($item); - if (empty($CFG->bigbluebuttonbn_default_dpa_accepted)) { - $settingsgeneral->add(new admin_setting_configcheckbox( - 'bigbluebuttonbn_default_dpa_accepted', - get_string('acceptdpa', 'mod_bigbluebuttonbn'), - get_string('enablingbigbluebuttondpainfo', 'mod_bigbluebuttonbn', config::DEFAULT_DPA_URL), - 0 + if (config::server_credentials_invalid()) { + // A notification should appear when default credentials are used. + $settingsgeneral->add(new admin_setting_heading( + 'bigbluebuttonbn_notification', + '', + $OUTPUT->notification(get_string('credentials_warning', 'mod_bigbluebuttonbn'), 'error') )); } @@ -180,7 +180,7 @@ protected function add_general_settings(): admin_settingpage { 'bigbluebuttonbn_server_url', get_string('config_server_url', 'bigbluebuttonbn'), get_string('config_server_url_description', 'bigbluebuttonbn'), - config::DEFAULT_SERVER_URL, + '', PARAM_RAW ); $item->set_updatedcallback( @@ -199,7 +199,7 @@ function() { 'bigbluebuttonbn_shared_secret', get_string('config_shared_secret', 'bigbluebuttonbn'), get_string('config_shared_secret_description', 'bigbluebuttonbn'), - config::DEFAULT_SHARED_SECRET + '' ); $this->add_conditional_element( 'shared_secret', @@ -220,16 +220,6 @@ function() { $settingsgeneral ); - $item = new \admin_setting_description( - 'bigbluebuttonbn_dpa_info', - '', - get_string('config_dpa_note', 'bigbluebuttonbn', config::DEFAULT_DPA_URL), - ); - $this->add_conditional_element( - 'dpa_info', - $item, - $settingsgeneral - ); $item = new admin_setting_configtext( 'bigbluebuttonbn_poll_interval', get_string('config_poll_interval', 'bigbluebuttonbn'), diff --git a/mod/bigbluebuttonbn/classes/task/send_bigbluebutton_module_disabled_notification.php b/mod/bigbluebuttonbn/classes/task/send_bigbluebutton_module_disabled_notification.php index 0da79bad316d7..cb5fffcdcbdf7 100644 --- a/mod/bigbluebuttonbn/classes/task/send_bigbluebutton_module_disabled_notification.php +++ b/mod/bigbluebuttonbn/classes/task/send_bigbluebutton_module_disabled_notification.php @@ -23,7 +23,7 @@ use mod_bigbluebuttonbn\local\config; /** - * Ad-hoc task to send a notification related to the disabling of the BigBlueButton activity module. + * Deprecated Ad-hoc task to send a notification related to the disabling of the BigBlueButton activity module. * * The ad-hoc tasks sends a notification to the administrator informing that the BigBlueButton activity module has * been disabled and they are required to confirm their acceptance of the data processing agreement prior to @@ -34,25 +34,12 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class send_bigbluebutton_module_disabled_notification extends adhoc_task { - /** * Execute the task. */ - public function execute(): void { - $message = new message(); - $message->component = 'moodle'; - $message->name = 'notices'; - $message->userfrom = \core_user::get_noreply_user(); - $message->userto = get_admin(); - $message->notification = 1; - $message->contexturl = (new \moodle_url('/admin/modules.php'))->out(false); - $message->contexturlname = get_string('modsettings', 'admin'); - $message->subject = get_string('bigbluebuttondisablednotification_subject', 'mod_bigbluebuttonbn'); - $message->fullmessageformat = FORMAT_HTML; - $message->fullmessagehtml = get_string('bigbluebuttondisablednotification', 'mod_bigbluebuttonbn', - config::DEFAULT_DPA_URL); - $message->smallmessage = strip_tags($message->fullmessagehtml); - - message_send($message); + public function execute() { + // Log the debug message. + $message = "Attempted to run deprecated send_bigbluebutton_module_disabled_notification task."; + debugging($message, DEBUG_DEVELOPER); } } diff --git a/mod/bigbluebuttonbn/classes/test/testcase_helper_trait.php b/mod/bigbluebuttonbn/classes/test/testcase_helper_trait.php index df44bc746696c..b1a0778d91bfe 100644 --- a/mod/bigbluebuttonbn/classes/test/testcase_helper_trait.php +++ b/mod/bigbluebuttonbn/classes/test/testcase_helper_trait.php @@ -26,6 +26,7 @@ use context_module; use mod_bigbluebuttonbn\instance; +use mod_bigbluebuttonbn\local\config; use mod_bigbluebuttonbn\local\proxy\recording_proxy; use mod_bigbluebuttonbn\meeting; use stdClass; @@ -158,6 +159,8 @@ protected function initialise_mock_server(): void { } try { $this->getDataGenerator()->get_plugin_generator('mod_bigbluebuttonbn')->reset_mock(); + // Mock server expects a value. By default this field is empty. + set_config('bigbluebuttonbn_shared_secret', config::DEFAULT_SHARED_SECRET); } catch (\moodle_exception $e) { $this->markTestSkipped( 'Cannot connect to the mock server for this test. Make sure that TEST_MOD_BIGBLUEBUTTONBN_MOCK_SERVER points diff --git a/mod/bigbluebuttonbn/lang/en/bigbluebuttonbn.php b/mod/bigbluebuttonbn/lang/en/bigbluebuttonbn.php index e47c59ce9d86c..6423bcbdff7d2 100644 --- a/mod/bigbluebuttonbn/lang/en/bigbluebuttonbn.php +++ b/mod/bigbluebuttonbn/lang/en/bigbluebuttonbn.php @@ -26,7 +26,7 @@ defined('MOODLE_INTERNAL') || die(); $string['activityoverview'] = 'You have upcoming BigBlueButton sessions'; -$string['acceptdpa'] = 'I understand and accept the data processing agreement'; +$string['credentials_warning'] = 'The use of default server credentials will soon expire (see note above to obtain new credentials).'; $string['bbbduetimeoverstartingtime'] = 'The close time must be later than the open time.'; $string['bbbdurationwarning'] = 'The maximum duration for this session is %duration% minutes.'; $string['bbbrecordwarning'] = 'This session may be recorded.'; @@ -45,12 +45,7 @@ $string['bigbluebuttonbn:importrecordings'] = 'Import recordings'; $string['bigbluebuttonbn:viewallrecordingformats'] = 'View all recording formats'; $string['bigbluebuttonbn'] = 'BigBlueButton'; -$string['bigbluebuttondisablednotification_subject'] = 'BigBlueButton activity module disabled.'; -$string['bigbluebuttondisablednotification'] = 'The BigBlueButton activity module has been disabled and any existing BigBlueButton course activities are currently not accessible. Prior to re-enabling this plugin, please ensure that you have read and accepted the data processing agreement with Blindside Networks Inc.'; $string['cannotperformaction'] = 'Cannot perform action {$a} on this recording'; -$string['enablingbigbluebutton'] = 'Enabling BigBlueButton activity'; -$string['enablingbigbluebuttondpainfo'] = 'In order to meet your data protection obligations, prior to enabling this plugin, you may need to ensure that you have read and accepted the Blindside Networks data processing agreement. Please consult with your own privacy professionals for advice.'; -$string['dpainfonotsigned'] = 'Before enabling this plugin, you must confirm that you have read and accepted the Blindside Networks data processing agreement.'; $string['indicator:cognitivedepth'] = 'BigBlueButton cognitive'; $string['indicator:cognitivedepth_help'] = 'This indicator is based on the cognitive depth reached by the student in a BigBlueButton activity.'; $string['indicator:socialbreadth'] = 'BigBlueButton social'; @@ -79,6 +74,10 @@ $string['search:activity'] = 'BigBlueButton - activity information'; $string['search:tags'] = 'BigBlueButton - tags information'; $string['settings'] = 'BigBlueButton settings'; + +$string['settings_credential_warning_no_capability'] = 'The use of default server credentials will soon expire. To use BigBlueButton your site will require new server credentials. Please contact your site administrator for help with this.'; +$string['settings_credential_warning'] = 'Default BigBlueButton plugin credentials will soon expire. See BigBlueButton plugin settings (opens in a new window) for more information.'; + $string['privacy:metadata:bigbluebuttonbn'] = 'BigBlueButton session configuration'; $string['privacy:metadata:bigbluebuttonbn:participants'] = 'A list of rules that define the role users will have in the BigBlueButton session. A user ID may be stored as permissions can be granted per role or per user.'; $string['privacy:metadata:bigbluebuttonbn_logs'] = 'Stores events triggered when using the plugin.'; @@ -127,25 +126,18 @@ $string['minute'] = 'minute'; $string['minutes'] = 'minutes'; -$string['config_dpa_note'] = 'Note: In order to meet your data protection obligations, before using a service provider for this plugin, you must ensure that you have read and accepted the service provider\'s data processing agreement. For the default free BigBlueButton service, this is the Blindside Networks data processing agreement. Please consult with your own privacy professionals for advice.'; $string['config_guestaccess_enabled'] = 'External guest access'; $string['config_guestaccess_enabled_description'] = 'Allow users without an account on your site to access the room.'; $string['config_general'] = 'General settings'; -$string['config_general_description'] = 'These settings are always used.'; +$string['config_general_description'] = 'To set up BigBlueButton, you can either use your own BigBlueButton server and credentials, or obtain credentials through the Blindside Networks Registration Portal (opens in a new window).'; $string['config_profile_picture_enabled'] = 'Show profile pictures'; $string['config_profile_picture_enabled_description'] = 'Should profile pictures of participants be shown in BigBlueButton sessions?'; $string['config_server_url'] = 'BigBlueButton server URL'; -$string['config_server_url_description'] = 'The default credentials are for a free BigBlueButton service for Moodle (opens in new window) provided by Blindside Networks with restrictions as follows: -
      -
    1. The maximum length for each session is 60 minutes
    2. -
    3. The maximum number of concurrent users per session is 25
    4. -
    5. Recordings expire after seven (7) days and are not downloadable
    6. -
    7. Student webcams are only visible to the moderator.
    8. -
    '; +$string['config_server_url_description'] = 'The server URL of your BigBlueButton server '; $string['config_shared_secret'] = 'BigBlueButton shared secret'; -$string['config_shared_secret_description'] = 'The security secret of your BigBlueButton server. The default secret is for a free BigBlueButton service provided by Blindside Networks.'; +$string['config_shared_secret_description'] = 'The security secret of your BigBlueButton server.'; $string['config_checksum_algorithm'] = 'BigBlueButton server checksum algorithm'; $string['config_checksum_algorithm_description'] = 'SHA1 is compatible with older servers. SHA256 and SHA512 are more secure. SHA512 is FIPS 140-2 compliant.'; @@ -629,13 +621,6 @@ $string['view_error_meeting_not_running'] = 'Something went wrong; the session is not running.'; $string['view_error_current_state_not_found'] = 'Current state was not found. The recording may have been deleted or the BigBlueButton server is not compatible with the action performed.'; $string['view_error_action_not_completed'] = 'Action could not be completed'; -$string['view_warning_default_server'] = 'This site is using a free BigBlueButton service for Moodle (opens in new window) provided by Blindside Networks with restrictions as follows: -
      -
    1. The maximum length for each session is 60 minutes
    2. -
    3. The maximum number of concurrent users per session is 25
    4. -
    5. Recordings expire after seven (7) days and are not downloadable
    6. -
    7. Student webcams are only visible to the moderator.
    8. -
    '; $string['view_room'] = 'View room'; $string['index_error_noinstances'] = 'There are no instances of BigBlueButton rooms'; @@ -667,3 +652,18 @@ $string['completionview_desc'] = 'View the room'; $string['completionattendancegroup_help'] = 'Attending the meeting for (n) minutes is required for completion.'; $string['completionengagementgroup_help'] = 'Active participation during the session is required for completion.'; +// Deprecated since Moodle 4.4. +$string['acceptdpa'] = 'I understand and accept the data processing agreement'; +$string['bigbluebuttondisablednotification_subject'] = 'BigBlueButton activity module disabled.'; +$string['bigbluebuttondisablednotification'] = 'The BigBlueButton activity module has been disabled and any existing BigBlueButton course activities are currently not accessible. Prior to re-enabling this plugin, please ensure that you have read and accepted the data processing agreement with Blindside Networks Inc.'; +$string['enablingbigbluebutton'] = 'Enabling BigBlueButton activity'; +$string['enablingbigbluebuttondpainfo'] = 'In order to meet your data protection obligations, prior to enabling this plugin, you may need to ensure that you have read and accepted the Blindside Networks data processing agreement. Please consult with your own privacy professionals for advice.'; +$string['dpainfonotsigned'] = 'Before enabling this plugin, you must confirm that you have read and accepted the Blindside Networks data processing agreement.'; +$string['config_dpa_note'] = 'Note: In order to meet your data protection obligations, before using a service provider for this plugin, you must ensure that you have read and accepted the service provider\'s data processing agreement. For the default free BigBlueButton service, this is the Blindside Networks data processing agreement. Please consult with your own privacy professionals for advice.'; +$string['view_warning_default_server'] = 'This site is using a free BigBlueButton service for Moodle (opens in new window) provided by Blindside Networks with restrictions as follows: +
      +
    1. The maximum length for each session is 60 minutes
    2. +
    3. The maximum number of concurrent users per session is 25
    4. +
    5. Recordings expire after seven (7) days and are not downloadable
    6. +
    7. Student webcams are only visible to the moderator.
    8. +
    '; diff --git a/mod/bigbluebuttonbn/lang/en/deprecated.txt b/mod/bigbluebuttonbn/lang/en/deprecated.txt index d9ce987c4e95a..936094c917101 100644 --- a/mod/bigbluebuttonbn/lang/en/deprecated.txt +++ b/mod/bigbluebuttonbn/lang/en/deprecated.txt @@ -2,3 +2,11 @@ completionview,mod_bigbluebuttonbn completionview_desc,mod_bigbluebuttonbn completionattendancegroup_help,mod_bigbluebuttonbn completionengagementgroup_help,mod_bigbluebuttonbn +acceptdpa,mod_bigbluebuttonbn +bigbluebuttondisablednotification_subject,mod_bigbluebuttonbn +bigbluebuttondisablednotification,mod_bigbluebuttonbn +enablingbigbluebutton,mod_bigbluebuttonbn +enablingbigbluebuttondpainfo,mod_bigbluebuttonbn +dpainfonotsigned,mod_bigbluebuttonbn +config_dpa_note,mod_bigbluebuttonbn +view_warning_default_server,mod_bigbluebuttonbn diff --git a/mod/bigbluebuttonbn/lib.php b/mod/bigbluebuttonbn/lib.php index b9ad514bca63c..e7c649ba2714b 100644 --- a/mod/bigbluebuttonbn/lib.php +++ b/mod/bigbluebuttonbn/lib.php @@ -719,28 +719,6 @@ function bigbluebuttonbn_print_recent_activity(object $course, bool $viewfullnam return true; } -/** - * Callback method executed prior to enabling the activity module. - * - * @return bool Whether to proceed and enable the plugin or not. - */ -function bigbluebuttonbn_pre_enable_plugin_actions(): bool { - global $PAGE; - - // If the default server configuration is used and the administrator has not accepted the default data processing - // agreement, do not enable the plugin. Instead, display a dynamic form where the administrator can confirm that he - // accepts the DPA prior to enabling the plugin. - if (config::get('server_url') === config::DEFAULT_SERVER_URL && !config::get('default_dpa_accepted')) { - $url = new moodle_url('/admin/category.php', ['category' => 'modbigbluebuttonbnfolder']); - \core\notification::add( - get_string('dpainfonotsigned', 'mod_bigbluebuttonbn', $url->out(false)), - \core\notification::ERROR - ); - return false; - } - // Otherwise, continue and enable the plugin. - return true; -} /** * Creates a number of BigblueButtonBN activities. diff --git a/mod/bigbluebuttonbn/tests/behat/add_instance.feature b/mod/bigbluebuttonbn/tests/behat/add_instance.feature index 602f593725c72..2e84cca6a3cdc 100644 --- a/mod/bigbluebuttonbn/tests/behat/add_instance.feature +++ b/mod/bigbluebuttonbn/tests/behat/add_instance.feature @@ -6,7 +6,7 @@ Feature: bigbluebuttonbn instance Background: Make sure that a course is created Given a BigBlueButton mock server is configured - And I accept dpa and enable bigbluebuttonbn plugin + And I enable "bigbluebuttonbn" "mod" plugin And the following "courses" exist: | fullname | shortname | category | | Test course | Test course | 0 | diff --git a/mod/bigbluebuttonbn/tests/behat/add_instance_in_empty_state.feature b/mod/bigbluebuttonbn/tests/behat/add_instance_in_empty_state.feature new file mode 100644 index 0000000000000..04b69feab1e42 --- /dev/null +++ b/mod/bigbluebuttonbn/tests/behat/add_instance_in_empty_state.feature @@ -0,0 +1,48 @@ +@mod @mod_bigbluebuttonbn @javascript +Feature: I can create a bigbluebuttonbn instance with default server + In case the BigBlueButton server has not been configured + As a user + I want to see a notification message + + Background: Make sure that a course is created + Given I enable "bigbluebuttonbn" "mod" plugin + And the following "courses" exist: + | fullname | shortname | category | + | Test course | Test course | 0 | + And the following "users" exist: + | username | firstname | lastname | email | + | user1 | User1G1 | 1 | user1@example.com | + | teacher1 | TeacherG1 | 1 | teacher1@example.com | + And the following "course enrolments" exist: + | user | course | role | + | user1 | Test course | student | + | teacher1 | Test course | editingteacher | + And the following "activities" exist: + | activity | course | name | type | + | bigbluebuttonbn | Test course | BBB Instance name | 0 | + | bigbluebuttonbn | Test course | BBB Instance name 2 | 1 | + | bigbluebuttonbn | Test course | BBB Instance name 3 | 2 | + And I am on the "Test course" "course" page logged in as "admin" + + Scenario Outline: Add an activity using default server for the three types of instance types + When I change window size to "large" + And I add a bigbluebuttonbn activity to course "Test course" section "1" + And I select "" from the "Instance type" singleselect + Then I should see "Restrict access" + + Examples: + | type | + | Room with recordings | + | Room only | + | Recordings only | + + Scenario Outline: Users should see a notification message when accessing activities if the default server is used + When I am on the "BBB Instance name" Activity page logged in as + Then "Join session" "link" should exist + And I "" + + Examples: + | user | shouldseemessage | messageexist | + | user1 | The use of default server credentials will soon expire.| should not see | + | teacher1 | The use of default server credentials will soon expire.| should see | + | admin | Default BigBlueButton plugin credentials will soon expire.| should see | diff --git a/mod/bigbluebuttonbn/tests/behat/behat_mod_bigbluebuttonbn.php b/mod/bigbluebuttonbn/tests/behat/behat_mod_bigbluebuttonbn.php index ffe5bd6951363..6f1190de3949e 100644 --- a/mod/bigbluebuttonbn/tests/behat/behat_mod_bigbluebuttonbn.php +++ b/mod/bigbluebuttonbn/tests/behat/behat_mod_bigbluebuttonbn.php @@ -28,6 +28,7 @@ use Behat\Behat\Hook\Scope\BeforeScenarioScope; use Behat\Gherkin\Node\TableNode; use mod_bigbluebuttonbn\instance; +use mod_bigbluebuttonbn\local\config; use mod_bigbluebuttonbn\test\subplugins_test_helper_trait; use Moodle\BehatExtension\Exception\SkippedException; require_once(__DIR__ . '../../../classes/test/subplugins_test_helper_trait.php'); @@ -57,16 +58,9 @@ public function before_scenario(BeforeScenarioScope $scope) { if (defined('TEST_MOD_BIGBLUEBUTTONBN_MOCK_SERVER')) { $this->send_mock_request('backoffice/reset'); } - } - - /** - * Accept dpa and enable bigbluebuttonbn plugin. - * - * @When /^I accept dpa and enable bigbluebuttonbn plugin$/ - */ - public function i_accept_dpa_and_enable_bigbluebuttonbn_plugin(): void { - set_config('bigbluebuttonbn_default_dpa_accepted', true); - $this->execute('behat_general::i_enable_plugin', ['bigbluebuttonbn', 'mod']); + // Fields are empty by default which causes tests to fail. + set_config('bigbluebuttonbn_server_url', config::DEFAULT_SERVER_URL); + set_config('bigbluebuttonbn_shared_secret', config::DEFAULT_SHARED_SECRET); } /** diff --git a/mod/bigbluebuttonbn/tests/behat/completion.feature b/mod/bigbluebuttonbn/tests/behat/completion.feature index fe208aa3573bd..26df1b2a604f2 100644 --- a/mod/bigbluebuttonbn/tests/behat/completion.feature +++ b/mod/bigbluebuttonbn/tests/behat/completion.feature @@ -2,7 +2,7 @@ Feature: As a user I can complete a BigblueButtonBN activity by usual or custom criteria Background: - Given I accept dpa and enable bigbluebuttonbn plugin + Given I enable "bigbluebuttonbn" "mod" plugin And the following "courses" exist: | fullname | shortname | category | enablecompletion | | Test course | C1 | 0 | 1 | diff --git a/mod/bigbluebuttonbn/tests/behat/edit_instance.feature b/mod/bigbluebuttonbn/tests/behat/edit_instance.feature index d303818dc06bc..58a6c9f134405 100644 --- a/mod/bigbluebuttonbn/tests/behat/edit_instance.feature +++ b/mod/bigbluebuttonbn/tests/behat/edit_instance.feature @@ -4,7 +4,7 @@ Feature: I can edit a bigbluebutton instance Background: Make sure that a course is created Given a BigBlueButton mock server is configured - And I accept dpa and enable bigbluebuttonbn plugin + And I enable "bigbluebuttonbn" "mod" plugin And the following config values are set as admin: | bigbluebuttonbn_voicebridge_editable | 1 | And the following "courses" exist: diff --git a/mod/bigbluebuttonbn/tests/behat/end_meeting.feature b/mod/bigbluebuttonbn/tests/behat/end_meeting.feature index 8090997a61b30..7a6d91a5db308 100644 --- a/mod/bigbluebuttonbn/tests/behat/end_meeting.feature +++ b/mod/bigbluebuttonbn/tests/behat/end_meeting.feature @@ -6,7 +6,7 @@ Feature: Test the ability to end a meeting Background: Given a BigBlueButton mock server is configured - And I accept dpa and enable bigbluebuttonbn plugin + And I enable "bigbluebuttonbn" "mod" plugin Scenario Outline: Only a BigBlueButton moderator can end a session Given the following course exists: diff --git a/mod/bigbluebuttonbn/tests/behat/group_mode.feature b/mod/bigbluebuttonbn/tests/behat/group_mode.feature index 837b42f8767ee..fb4b21c2a1d32 100644 --- a/mod/bigbluebuttonbn/tests/behat/group_mode.feature +++ b/mod/bigbluebuttonbn/tests/behat/group_mode.feature @@ -5,7 +5,7 @@ Feature: Test the module in group mode. # groupmode 1 = separate groups, we force the group # groupmode 2 = visible group Given a BigBlueButton mock server is configured - And I accept dpa and enable bigbluebuttonbn plugin + And I enable "bigbluebuttonbn" "mod" plugin And the following "courses" exist: | fullname | shortname | category | groupmode | groupmodeforce | | Test Course 1 | C1 | 0 | 1 | 1 | diff --git a/mod/bigbluebuttonbn/tests/behat/guest_access.feature b/mod/bigbluebuttonbn/tests/behat/guest_access.feature index b371cff41eba2..b26b486954a64 100644 --- a/mod/bigbluebuttonbn/tests/behat/guest_access.feature +++ b/mod/bigbluebuttonbn/tests/behat/guest_access.feature @@ -3,7 +3,7 @@ Feature: Guest access allows external users to connect to a meeting Background: Given a BigBlueButton mock server is configured - And I accept dpa and enable bigbluebuttonbn plugin + And I enable "bigbluebuttonbn" "mod" plugin And the following "courses" exist: | fullname | shortname | category | | Test Course 1 | C1 | 0 | diff --git a/mod/bigbluebuttonbn/tests/behat/join_meeting.feature b/mod/bigbluebuttonbn/tests/behat/join_meeting.feature index 6eb1deeff326e..5b180e8af220d 100644 --- a/mod/bigbluebuttonbn/tests/behat/join_meeting.feature +++ b/mod/bigbluebuttonbn/tests/behat/join_meeting.feature @@ -4,7 +4,7 @@ Feature: Test the ability to run the full meeting lifecycle (start to end) Background: Given a BigBlueButton mock server is configured - And I accept dpa and enable bigbluebuttonbn plugin + And I enable "bigbluebuttonbn" "mod" plugin And the following config values are set as admin: | bigbluebuttonbn_userlimit_editable | 1 | And the following course exists: diff --git a/mod/bigbluebuttonbn/tests/behat/join_meeting_as_guest.feature b/mod/bigbluebuttonbn/tests/behat/join_meeting_as_guest.feature index 4a2c4edae472f..b1bdc4a085741 100644 --- a/mod/bigbluebuttonbn/tests/behat/join_meeting_as_guest.feature +++ b/mod/bigbluebuttonbn/tests/behat/join_meeting_as_guest.feature @@ -3,7 +3,7 @@ Feature: Test the ability to run the full meeting lifecycle (start to end) for g Background: Given a BigBlueButton mock server is configured - And I accept dpa and enable bigbluebuttonbn plugin + And I enable "bigbluebuttonbn" "mod" plugin And the following config values are set as admin: | bigbluebuttonbn_guestaccess_enabled | 1 | And the following course exists: diff --git a/mod/bigbluebuttonbn/tests/behat/lock_settings.feature b/mod/bigbluebuttonbn/tests/behat/lock_settings.feature index 9f6a6f1b6d893..b037e6f58517c 100644 --- a/mod/bigbluebuttonbn/tests/behat/lock_settings.feature +++ b/mod/bigbluebuttonbn/tests/behat/lock_settings.feature @@ -6,7 +6,7 @@ Feature: Test that the meeting has the right lock setting. Background: Given a BigBlueButton mock server is configured - And I accept dpa and enable bigbluebuttonbn plugin + And I enable "bigbluebuttonbn" "mod" plugin @javascript Scenario Outline: Teacher should be able to set the right lock feature in a given meeting diff --git a/mod/bigbluebuttonbn/tests/behat/meeting_roles.feature b/mod/bigbluebuttonbn/tests/behat/meeting_roles.feature index 37d0a7509a7b1..22ef21354c60b 100644 --- a/mod/bigbluebuttonbn/tests/behat/meeting_roles.feature +++ b/mod/bigbluebuttonbn/tests/behat/meeting_roles.feature @@ -6,7 +6,7 @@ Feature: Test that meeting roles are sent to the server Background: Given a BigBlueButton mock server is configured - And I accept dpa and enable bigbluebuttonbn plugin + And I enable "bigbluebuttonbn" "mod" plugin @javascript Scenario Outline: Users should receive the appropriate role when joining the meeting diff --git a/mod/bigbluebuttonbn/tests/behat/recordings.feature b/mod/bigbluebuttonbn/tests/behat/recordings.feature index 91ede6f5c1a0d..d516919a8bc1d 100644 --- a/mod/bigbluebuttonbn/tests/behat/recordings.feature +++ b/mod/bigbluebuttonbn/tests/behat/recordings.feature @@ -4,7 +4,7 @@ Feature: The recording can be managed through the room page Background: Make sure that import recording is enabled and course, activities and recording exists Given a BigBlueButton mock server is configured - And I accept dpa and enable bigbluebuttonbn plugin + And I enable "bigbluebuttonbn" "mod" plugin And the following "courses" exist: | fullname | shortname | category | | Test Course 1 | C1 | 0 | diff --git a/mod/bigbluebuttonbn/tests/behat/recordings_import.feature b/mod/bigbluebuttonbn/tests/behat/recordings_import.feature index e5f442bce3f09..851c9d68d7300 100644 --- a/mod/bigbluebuttonbn/tests/behat/recordings_import.feature +++ b/mod/bigbluebuttonbn/tests/behat/recordings_import.feature @@ -7,7 +7,7 @@ Feature: Manage and list recordings And the following config values are set as admin: | bigbluebuttonbn_importrecordings_enabled | 1 | | bigbluebuttonbn_importrecordings_from_deleted_enabled | 1 | - And I accept dpa and enable bigbluebuttonbn plugin + And I enable "bigbluebuttonbn" "mod" plugin And the following "courses" exist: | fullname | shortname | category | | Test Course 1 | C1 | 0 | diff --git a/mod/bigbluebuttonbn/tests/behat/roles.feature b/mod/bigbluebuttonbn/tests/behat/roles.feature index 9e83ca6915b5a..a6eace3731db9 100644 --- a/mod/bigbluebuttonbn/tests/behat/roles.feature +++ b/mod/bigbluebuttonbn/tests/behat/roles.feature @@ -5,7 +5,7 @@ Feature: Set role as Bigbluebuttonbn moderator I need to see the list of roles Background: - Given I accept dpa and enable bigbluebuttonbn plugin + Given I enable "bigbluebuttonbn" "mod" plugin And the following "course" exist: | fullname | shortname | | Course 1 | C1 | diff --git a/mod/bigbluebuttonbn/tests/behat/room.feature b/mod/bigbluebuttonbn/tests/behat/room.feature index 484a417b43b21..4b6f8435a06b2 100644 --- a/mod/bigbluebuttonbn/tests/behat/room.feature +++ b/mod/bigbluebuttonbn/tests/behat/room.feature @@ -3,7 +3,7 @@ Feature: The recording can be managed through the room page and as a user I can Background: Make sure that import recording is enabled and course, activities and recording exists Given a BigBlueButton mock server is configured - And I accept dpa and enable bigbluebuttonbn plugin + And I enable "bigbluebuttonbn" "mod" plugin And the following "courses" exist: | fullname | shortname | category | | Test Course 1 | C1 | 0 | diff --git a/mod/bigbluebuttonbn/tests/behat/start_meeting.feature b/mod/bigbluebuttonbn/tests/behat/start_meeting.feature index 3b1d7cd4171f6..f8f2d783e3c0e 100644 --- a/mod/bigbluebuttonbn/tests/behat/start_meeting.feature +++ b/mod/bigbluebuttonbn/tests/behat/start_meeting.feature @@ -6,7 +6,7 @@ Feature: Test the ability to start a meeting Background: Given a BigBlueButton mock server is configured - And I accept dpa and enable bigbluebuttonbn plugin + And I enable "bigbluebuttonbn" "mod" plugin Scenario Outline: Users should be able to join a session depending on the Wait for moderator to join setting Given the following course exists: diff --git a/mod/bigbluebuttonbn/tests/behat/subplugins.feature b/mod/bigbluebuttonbn/tests/behat/subplugins.feature index d098b395a19d8..227904dc17a2b 100644 --- a/mod/bigbluebuttonbn/tests/behat/subplugins.feature +++ b/mod/bigbluebuttonbn/tests/behat/subplugins.feature @@ -5,7 +5,7 @@ Feature: BigBlueButtonBN Subplugins test I can see the additional settings coming from the subplugins in the edit form Background: Make sure that the BigBlueButtonBN plugin is enabled - Given I accept dpa and enable bigbluebuttonbn plugin + Given I enable "bigbluebuttonbn" "mod" plugin And the following "courses" exist: | fullname | shortname | category | enablecompletion | | Test course | Test course | 0 | 1 | diff --git a/mod/bigbluebuttonbn/tests/external/can_join_test.php b/mod/bigbluebuttonbn/tests/external/can_join_test.php index 8fe1d7080eeb3..597bb8e44cb2d 100644 --- a/mod/bigbluebuttonbn/tests/external/can_join_test.php +++ b/mod/bigbluebuttonbn/tests/external/can_join_test.php @@ -61,6 +61,7 @@ protected function can_join(...$params) { * Test execute API CALL with no instance */ public function test_execute_no_instance() { + $this->resetAfterTest(); $canjoin = $this->can_join(1234, 5678); $this->assertIsArray($canjoin); diff --git a/mod/bigbluebuttonbn/tests/external/completion_validate_test.php b/mod/bigbluebuttonbn/tests/external/completion_validate_test.php index df6524095a05c..f3dbf16a2b135 100644 --- a/mod/bigbluebuttonbn/tests/external/completion_validate_test.php +++ b/mod/bigbluebuttonbn/tests/external/completion_validate_test.php @@ -63,6 +63,7 @@ protected function completion_validate(...$params) { * Test execute API CALL with no instance */ public function test_execute_no_instance() { + $this->resetAfterTest(); $result = $this->completion_validate(1234); $this->assertIsArray($result); diff --git a/mod/bigbluebuttonbn/tests/external/end_meeting_test.php b/mod/bigbluebuttonbn/tests/external/end_meeting_test.php index d3fd82e482208..c079cc89df6a8 100644 --- a/mod/bigbluebuttonbn/tests/external/end_meeting_test.php +++ b/mod/bigbluebuttonbn/tests/external/end_meeting_test.php @@ -63,6 +63,7 @@ protected function end_meeting(...$params) { * Test execute API CALL with no instance */ public function test_execute_no_instance() { + $this->resetAfterTest(); $this->expectException(moodle_exception::class); $endmeeting = $this->end_meeting(1234, 5678); } diff --git a/mod/bigbluebuttonbn/tests/external/get_bigbluebuttons_by_courses_test.php b/mod/bigbluebuttonbn/tests/external/get_bigbluebuttons_by_courses_test.php index cd0db47400870..a963ba499bdf3 100644 --- a/mod/bigbluebuttonbn/tests/external/get_bigbluebuttons_by_courses_test.php +++ b/mod/bigbluebuttonbn/tests/external/get_bigbluebuttons_by_courses_test.php @@ -63,6 +63,7 @@ protected function get_bigbluebuttons_by_courses(...$params) { * Test execute API CALL with no instance */ public function test_execute_no_instance() { + $this->resetAfterTest(); $bbbactivities = $this->get_bigbluebuttons_by_courses([1234, 5678]); $this->assertIsArray($bbbactivities); diff --git a/mod/bigbluebuttonbn/tests/external/get_join_url_test.php b/mod/bigbluebuttonbn/tests/external/get_join_url_test.php index 9036916d61e5d..e245b37d18cc9 100644 --- a/mod/bigbluebuttonbn/tests/external/get_join_url_test.php +++ b/mod/bigbluebuttonbn/tests/external/get_join_url_test.php @@ -63,6 +63,7 @@ protected function get_join_url(...$params) { * Test execute API CALL with no instance */ public function test_execute_no_instance() { + $this->resetAfterTest(); $this->expectExceptionMessageMatches('/No such instance.*/'); $joinurl = $this->get_join_url(1234, 5678); diff --git a/mod/bigbluebuttonbn/tests/external/get_recordings_test.php b/mod/bigbluebuttonbn/tests/external/get_recordings_test.php index 96b85bc481615..ca106e9e1d3ce 100644 --- a/mod/bigbluebuttonbn/tests/external/get_recordings_test.php +++ b/mod/bigbluebuttonbn/tests/external/get_recordings_test.php @@ -63,6 +63,7 @@ protected function get_recordings(...$params) { * Test execute API CALL with no instance */ public function test_execute_wrong_instance() { + $this->resetAfterTest(); $getrecordings = $this->get_recordings(1234); $this->assertIsArray($getrecordings); diff --git a/mod/bigbluebuttonbn/tests/external/view_bigbluebuttonbn_test.php b/mod/bigbluebuttonbn/tests/external/view_bigbluebuttonbn_test.php index 31e0ca4574bd9..1b9f698360011 100644 --- a/mod/bigbluebuttonbn/tests/external/view_bigbluebuttonbn_test.php +++ b/mod/bigbluebuttonbn/tests/external/view_bigbluebuttonbn_test.php @@ -63,6 +63,7 @@ protected function view_bigbluebuttonbn(...$params) { * Test execute API CALL with no instance */ public function test_execute_no_instance() { + $this->resetAfterTest(); $bbbactivities = $this->view_bigbluebuttonbn(1234); $this->assertIsArray($bbbactivities); diff --git a/mod/bigbluebuttonbn/tests/generator/lib.php b/mod/bigbluebuttonbn/tests/generator/lib.php index 5328ba9ca40da..c6970e6909d21 100644 --- a/mod/bigbluebuttonbn/tests/generator/lib.php +++ b/mod/bigbluebuttonbn/tests/generator/lib.php @@ -50,7 +50,6 @@ class mod_bigbluebuttonbn_generator extends \testing_module_generator { */ public function create_instance($record = null, array $options = null) { // Prior to creating the instance, make sure that the BigBlueButton module is enabled. - set_config('bigbluebuttonbn_default_dpa_accepted', true); $modules = \core_plugin_manager::instance()->get_plugins_of_type('mod'); if (!$modules['bigbluebuttonbn']->is_enabled()) { mod::enable_plugin('bigbluebuttonbn', true); diff --git a/mod/bigbluebuttonbn/tests/lib_test.php b/mod/bigbluebuttonbn/tests/lib_test.php index ae24f29bba881..f91121d54c2e7 100644 --- a/mod/bigbluebuttonbn/tests/lib_test.php +++ b/mod/bigbluebuttonbn/tests/lib_test.php @@ -698,71 +698,4 @@ public function test_mod_bigbluebuttonbn_core_calendar_is_event_visible() { $this->assertFalse(mod_bigbluebuttonbn_core_calendar_is_event_visible($event)); } - /** - * Check the bigbluebuttonbn_pre_enable_plugin_actions function. - * - * @covers ::bigbluebuttonbn_pre_enable_plugin_actions - * @dataProvider bigbluebuttonbn_pre_enable_plugin_actions_provider - * @param bool $initialstate - * @param bool $expected - * @param int $notificationcount - */ - public function test_bigbluebuttonbn_pre_enable_plugin_actions( - ?bool $initialstate, - bool $expected, - int $notificationcount - ): void { - $this->resetAfterTest(true); - - set_config('bigbluebuttonbn_default_dpa_accepted', $initialstate); - - $this->assertEquals($expected, bigbluebuttonbn_pre_enable_plugin_actions()); - $this->assertCount($notificationcount, \core\notification::fetch()); - } - - /** - * Check the bigbluebuttonbn_pre_enable_plugin_actions function. - * - * @covers ::bigbluebuttonbn_pre_enable_plugin_actions - * @dataProvider bigbluebuttonbn_pre_enable_plugin_actions_provider - * @param bool $initialstate - * @param bool $expected - * @param int $notificationcount - */ - public function test_enable_plugin( - ?bool $initialstate, - bool $expected, - int $notificationcount - ): void { - $this->resetAfterTest(true); - - set_config('bigbluebuttonbn_default_dpa_accepted', $initialstate); - $this->assertEquals($expected, \core\plugininfo\mod::enable_plugin('bigbluebuttonbn', 1)); - $this->assertCount($notificationcount, \core\notification::fetch()); - } - - /** - * Data provider for bigbluebuttonbn_pre_enable_plugin_actions tests. - * - * @return array - */ - public function bigbluebuttonbn_pre_enable_plugin_actions_provider(): array { - return [ - 'Initially unset' => [ - null, - false, - 1, - ], - 'Set to false' => [ - false, - false, - 1, - ], - 'Initially set' => [ - true, - true, - 0, - ], - ]; - } } diff --git a/mod/bigbluebuttonbn/tests/local/extension_test.php b/mod/bigbluebuttonbn/tests/local/extension_test.php index 02561cb7bd563..a342441468af3 100644 --- a/mod/bigbluebuttonbn/tests/local/extension_test.php +++ b/mod/bigbluebuttonbn/tests/local/extension_test.php @@ -397,7 +397,6 @@ public function classes_implementing_class(): array { */ private function enable_plugins(bool $bbbenabled) { // First make sure that either BBB is enabled or not. - set_config('bigbluebuttonbn_default_dpa_accepted', $bbbenabled); \core\plugininfo\mod::enable_plugin('bigbluebuttonbn', $bbbenabled ? 1 : 0); $plugin = extension::BBB_EXTENSION_PLUGIN_NAME . '_simple'; if ($bbbenabled) { diff --git a/mod/bigbluebuttonbn/view.php b/mod/bigbluebuttonbn/view.php index faa5bc8de433f..74175cddd1c3c 100644 --- a/mod/bigbluebuttonbn/view.php +++ b/mod/bigbluebuttonbn/view.php @@ -26,6 +26,7 @@ */ use mod_bigbluebuttonbn\instance; +use mod_bigbluebuttonbn\local\config; use mod_bigbluebuttonbn\local\exceptions\server_not_available_exception; use mod_bigbluebuttonbn\local\proxy\bigbluebutton_proxy; use mod_bigbluebuttonbn\logger; @@ -87,6 +88,17 @@ echo $OUTPUT->header(); +// Valid credentials have not been setup, then we output a message to teachers and admin. +if (config::server_credentials_invalid()) { + if (has_capability('moodle/site:config', context_system::instance())) { + $settingslink = new moodle_url('/admin/settings.php', ['section' => 'modsettingbigbluebuttonbn']); + echo $OUTPUT->notification(get_string('settings_credential_warning', 'bigbluebuttonbn', + ['settingslink' => $settingslink->out()]), 'notifywarning'); + } else if (has_capability('moodle/course:manageactivities', context_course::instance($course->id))) { + echo $OUTPUT->notification(get_string('settings_credential_warning_no_capability', 'bigbluebuttonbn'), 'notifywarning'); + } +} + // Validate if the user is in a role allowed to join. if (!$instance->can_join() && $instance->get_type() != instance::TYPE_RECORDING_ONLY) { if (isguestuser()) { diff --git a/mod/feedback/tests/external/external_test.php b/mod/feedback/tests/external/external_test.php index 1ea702e6e5004..018a887709bd5 100644 --- a/mod/feedback/tests/external/external_test.php +++ b/mod/feedback/tests/external/external_test.php @@ -759,7 +759,7 @@ public function test_process_page() { $customdata = json_decode($messages[0]->customdata); $this->assertEquals($this->feedback->id, $customdata->instance); $this->assertEquals($this->feedback->cmid, $customdata->cmid); - $this->assertObjectHasAttribute('notificationiconurl', $customdata); + $this->assertObjectHasProperty('notificationiconurl', $customdata); } /** diff --git a/mod/forum/tests/mail_test.php b/mod/forum/tests/mail_test.php index d1bfe8af0c30b..e3e2ec820c9bc 100644 --- a/mod/forum/tests/mail_test.php +++ b/mod/forum/tests/mail_test.php @@ -1606,8 +1606,8 @@ public function test_notification_customdata() { $this->assertEquals($forum->cmid, $customdata->cmid); $this->assertEquals($post->id, $customdata->postid); $this->assertEquals($discussion->id, $customdata->discussionid); - $this->assertObjectHasAttribute('notificationiconurl', $customdata); - $this->assertObjectHasAttribute('actionbuttons', $customdata); + $this->assertObjectHasProperty('notificationiconurl', $customdata); + $this->assertObjectHasProperty('actionbuttons', $customdata); $this->assertCount(1, (array) $customdata->actionbuttons); } } diff --git a/mod/imscp/mod_form.php b/mod/imscp/mod_form.php index d93baf90add2e..a8a0eabb3945f 100644 --- a/mod/imscp/mod_form.php +++ b/mod/imscp/mod_form.php @@ -59,7 +59,9 @@ public function definition() { // IMS-CP file upload. $mform->addElement('header', 'content', get_string('contentheader', 'imscp')); $mform->setExpanded('content', true); - $mform->addElement('filepicker', 'package', get_string('packagefile', 'imscp')); + + $mform->addElement('filepicker', 'package', get_string('packagefile', 'imscp'), null, + ['accepted_types' => ['application/zip', '.imscc']]); $options = array('-1' => get_string('all'), '0' => get_string('no'), '1' => '1', '2' => '2', '5' => '5', '10' => '10', '20' => '20'); @@ -78,27 +80,15 @@ public function definition() { * @param array $files */ public function validation($data, $files) { - global $USER; - if ($errors = parent::validation($data, $files)) { return $errors; } - $usercontext = context_user::instance($USER->id); - $fs = get_file_storage(); - - if (!$files = $fs->get_area_files($usercontext->id, 'user', 'draft', $data['package'], 'id', false)) { + if (!$this->get_draft_files('package')) { if (!$this->current->instance) { $errors['package'] = get_string('required'); return $errors; } - } else { - $file = reset($files); - if ($file->get_mimetype() != 'application/zip') { - $errors['package'] = get_string('invalidfiletype', 'error', '', $file); - // Better delete current file, it is not usable anyway. - $fs->delete_area_files($usercontext->id, 'user', 'draft', $data['package']); - } } return $errors; diff --git a/mod/lti/tests/locallib_test.php b/mod/lti/tests/locallib_test.php index 48a9f58fa220e..0f94476e7a353 100644 --- a/mod/lti/tests/locallib_test.php +++ b/mod/lti/tests/locallib_test.php @@ -302,21 +302,21 @@ public function test_lti_prepare_type_for_save_forcessl() { // Try when the forcessl config property is not set. lti_prepare_type_for_save($type, $config); - $this->assertObjectHasAttribute('lti_forcessl', $config); + $this->assertObjectHasProperty('lti_forcessl', $config); $this->assertEquals(0, $config->lti_forcessl); $this->assertEquals(0, $type->forcessl); // Try when forcessl config property is set. $config->lti_forcessl = 1; lti_prepare_type_for_save($type, $config); - $this->assertObjectHasAttribute('lti_forcessl', $config); + $this->assertObjectHasProperty('lti_forcessl', $config); $this->assertEquals(1, $config->lti_forcessl); $this->assertEquals(1, $type->forcessl); // Try when forcessl config property is set to 0. $config->lti_forcessl = 0; lti_prepare_type_for_save($type, $config); - $this->assertObjectHasAttribute('lti_forcessl', $config); + $this->assertObjectHasProperty('lti_forcessl', $config); $this->assertEquals(0, $config->lti_forcessl); $this->assertEquals(0, $type->forcessl); } diff --git a/mod/quiz/classes/cache/overrides.php b/mod/quiz/classes/cache/overrides.php index 5a69313978b24..346d38d8ed7f9 100644 --- a/mod/quiz/classes/cache/overrides.php +++ b/mod/quiz/classes/cache/overrides.php @@ -64,6 +64,11 @@ public static function get_instance_for_cache(cache_definition $definition): ove public function load_for_cache($key) { global $DB; + // Ignore getting data if this is a cache invalidation - {@see \cache_helper::purge_by_event()}. + if ($key == 'lastinvalidation') { + return null; + } + [$quizid, $ug, $ugid] = explode('_', $key); $quizid = (int) $quizid; diff --git a/mod/quiz/classes/external/delete_overrides.php b/mod/quiz/classes/external/delete_overrides.php new file mode 100644 index 0000000000000..17ba19f991009 --- /dev/null +++ b/mod/quiz/classes/external/delete_overrides.php @@ -0,0 +1,77 @@ +. + +namespace mod_quiz\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_multiple_structure; +use core_external\external_single_structure; +use core_external\external_value; +use mod_quiz\quiz_settings; + +/** + * Webservice for deleting quiz overrides. + * + * @package mod_quiz + * @copyright 2024 Matthew Hilton + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class delete_overrides extends external_api { + /** + * Defines parameters + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + // This must be nested in a single structure, because the ids structure does not play nicely at the top level. + 'data' => new external_single_structure([ + 'quizid' => new external_value(PARAM_INT, "ID of quiz to delete overrides in"), + 'ids' => new external_multiple_structure(new external_value(PARAM_INT, 'ID of override to delete')), + ]), + ]); + } + + /** + * Executes webservice function, deleting given overrides. + * + * @param array $params array of override parameters + * @return array with ids key, which contains the ids of the overrides successfully deleted. + */ + public static function execute($params): array { + $params = self::validate_parameters(self::execute_parameters(), ['data' => $params])['data']; + + $quizsettings = quiz_settings::create($params['quizid']); + $manager = $quizsettings->get_override_manager(); + self::validate_context($manager->context); + $manager->require_manage_capability(); + $manager->delete_overrides_by_id($params['ids']); + + return ['ids' => $params['ids']]; + } + + /** + * Defines return type + * + * @return external_single_structure + */ + public static function execute_returns(): external_single_structure { + return new external_single_structure([ + 'ids' => new external_multiple_structure(new external_value(PARAM_INT, 'ID of deleted override')), + ]); + } +} diff --git a/mod/quiz/classes/external/get_overrides.php b/mod/quiz/classes/external/get_overrides.php new file mode 100644 index 0000000000000..373106f928bad --- /dev/null +++ b/mod/quiz/classes/external/get_overrides.php @@ -0,0 +1,82 @@ +. + +namespace mod_quiz\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_multiple_structure; +use core_external\external_single_structure; +use core_external\external_value; +use mod_quiz\quiz_settings; + +/** + * Webservice for searching overrides. + * + * @package mod_quiz + * @copyright 2024 Matthew Hilton + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class get_overrides extends external_api { + /** + * Defines parameters for getting quiz overrides. + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'quizid' => new external_value(PARAM_INT, 'ID of quiz to get overrides for'), + ]); + } + + /** + * Executes webservice function, returning quiz overrides. + * + * @param int $quizid + * @return array with overrides key which contains the overrides for the given quiz. + */ + public static function execute($quizid): array { + $params = self::validate_parameters(self::execute_parameters(), ['quizid' => $quizid]); + $manager = quiz_settings::create($params['quizid'])->get_override_manager(); + self::validate_context($manager->context); + $manager->require_read_capability(); + $overrides = $manager->get_all_overrides(); + return ['overrides' => $overrides]; + } + + /** + * Defines return type + * + * @return external_single_structure + */ + public static function execute_returns(): external_single_structure { + $overridedatastructure = new external_single_structure([ + 'id' => new external_value(PARAM_INT, 'Override ID'), + 'quiz' => new external_value(PARAM_INT, 'Quiz ID'), + 'userid' => new external_value(PARAM_INT, 'User ID', VALUE_DEFAULT, null), + 'groupid' => new external_value(PARAM_INT, 'Group ID', VALUE_DEFAULT, null), + 'timeopen' => new external_value(PARAM_INT, 'Override time open value', VALUE_DEFAULT, null), + 'timeclose' => new external_value(PARAM_INT, 'Override time close value', VALUE_DEFAULT, null), + 'timelimit' => new external_value(PARAM_INT, 'Override time limit value', VALUE_DEFAULT, null), + 'attempts' => new external_value(PARAM_INT, 'Override attempts value', VALUE_DEFAULT, null), + 'password' => new external_value(PARAM_TEXT, 'Override password', VALUE_DEFAULT, null), + ]); + + return new external_single_structure([ + 'overrides' => new external_multiple_structure($overridedatastructure), + ]); + } +} diff --git a/mod/quiz/classes/external/save_overrides.php b/mod/quiz/classes/external/save_overrides.php new file mode 100644 index 0000000000000..40c5029d7ba42 --- /dev/null +++ b/mod/quiz/classes/external/save_overrides.php @@ -0,0 +1,90 @@ +. + +namespace mod_quiz\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_multiple_structure; +use core_external\external_single_structure; +use core_external\external_value; +use mod_quiz\quiz_settings; + +/** + * Webservice for upserting quiz overrides. + * + * @package mod_quiz + * @copyright 2024 Matthew Hilton + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class save_overrides extends external_api { + /** + * Defines parameters + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + $overridestructure = new external_single_structure([ + 'id' => new external_value(PARAM_INT, 'ID of existing override (if updating)', VALUE_DEFAULT, null), + 'groupid' => new external_value(PARAM_INT, 'ID of group', VALUE_DEFAULT, null), + 'userid' => new external_value(PARAM_INT, 'ID of user', VALUE_DEFAULT, null), + 'timeopen' => new external_value(PARAM_INT, 'Quiz override opening timestamp', VALUE_DEFAULT, null), + 'timeclose' => new external_value(PARAM_INT, 'Quiz override closing timestamp', VALUE_OPTIONAL, null), + 'timelimit' => new external_value(PARAM_INT, 'Quiz override time limit', VALUE_DEFAULT, null), + 'attempts' => new external_value(PARAM_INT, 'Quiz override attempt count', VALUE_DEFAULT, null), + 'password' => new external_value(PARAM_TEXT, 'Quiz override password', VALUE_DEFAULT, null), + ]); + + return new external_function_parameters([ + // This must be nested in a single structure, because the overrides structure does not play nicely at the top level. + 'data' => new external_single_structure([ + 'quizid' => new external_value(PARAM_INT, 'ID of quiz to save overrides to'), + 'overrides' => new external_multiple_structure($overridestructure), + ]), + ]); + } + + /** + * Executes webservice function, saving the requested overrides. + * + * @param array $data array with quizid key and overrides key containing list of overrides to save. + * @return array with ids key which contains ids of created/updated overrides. + */ + public static function execute($data): array { + $params = self::validate_parameters(self::execute_parameters(), ['data' => $data])['data']; + + $quizsettings = quiz_settings::create($params['quizid']); + $manager = $quizsettings->get_override_manager(); + self::validate_context($manager->context); + $manager->require_manage_capability(); + + // Iterate over and save all overrides. + $ids = array_map(fn($override) => $manager->save_override($override), $params['overrides']); + + return ['ids' => $ids]; + } + + /** + * Defines return type + * + * @return external_single_structure + */ + public static function execute_returns(): external_single_structure { + return new external_single_structure([ + 'ids' => new external_multiple_structure(new external_value(PARAM_INT, 'ID of created/updated override')), + ]); + } +} diff --git a/mod/quiz/classes/form/edit_override_form.php b/mod/quiz/classes/form/edit_override_form.php index c227e5e0f40bd..4f5c7c6786074 100644 --- a/mod/quiz/classes/form/edit_override_form.php +++ b/mod/quiz/classes/form/edit_override_form.php @@ -56,6 +56,9 @@ class edit_override_form extends moodleform { /** @var int userid, if provided. */ protected $userid; + /** @var int overrideid, if provided. */ + protected int $overrideid; + /** * Constructor. * @@ -76,6 +79,7 @@ public function __construct(moodle_url $submiturl, $this->groupmode = $groupmode; $this->groupid = empty($override->groupid) ? 0 : $override->groupid; $this->userid = empty($override->userid) ? 0 : $override->userid; + $this->overrideid = $override->id ?? 0; parent::__construct($submiturl); } @@ -261,43 +265,29 @@ public static function display_user_name(stdClass $user, array $extrauserfields) return $username; } + /** + * Validate the data from the form. + * + * @param array $data form data + * @param array $files form files + * @return array An array of error messages, where the key is is the mform element name and the value is the error. + */ public function validation($data, $files): array { $errors = parent::validation($data, $files); + $data['id'] = $this->overrideid; + $data['quiz'] = $this->quiz->id; - $mform =& $this->_form; - $quiz = $this->quiz; + $manager = new \mod_quiz\local\override_manager($this->quiz, $this->context); + $errors = array_merge($errors, $manager->validate_data($data)); - if ($mform->elementExists('userid')) { - if (empty($data['userid'])) { - $errors['userid'] = get_string('required'); - } - } - - if ($mform->elementExists('groupid')) { - if (empty($data['groupid'])) { - $errors['groupid'] = get_string('required'); - } - } - - // Ensure that the dates make sense. - if (!empty($data['timeopen']) && !empty($data['timeclose'])) { - if ($data['timeclose'] < $data['timeopen'] ) { - $errors['timeclose'] = get_string('closebeforeopen', 'quiz'); - } - } - - // Ensure that at least one quiz setting was changed. - $changed = false; - $keys = ['timeopen', 'timeclose', 'timelimit', 'attempts', 'password']; - foreach ($keys as $key) { - if ($data[$key] != $quiz->{$key}) { - $changed = true; - break; + // Any 'general' errors we merge with the group/user selector element. + if (!empty($errors['general'])) { + if ($this->groupmode) { + $errors['groupid'] = $errors['groupid'] ?? "" . $errors['general']; + } else { + $errors['userid'] = $errors['userid'] ?? "" . $errors['general']; } } - if (!$changed) { - $errors['timeopen'] = get_string('nooverridedata', 'quiz'); - } return $errors; } diff --git a/mod/quiz/classes/local/override_cache.php b/mod/quiz/classes/local/override_cache.php new file mode 100644 index 0000000000000..af1e3850c4ea3 --- /dev/null +++ b/mod/quiz/classes/local/override_cache.php @@ -0,0 +1,126 @@ +. + +namespace mod_quiz\local; + +/** + * Cache manager for quiz overrides + * + * Override cache data is set via its data source, {@see \mod_quiz\cache\overrides} + * @package mod_quiz + * @copyright 2024 Matthew Hilton + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class override_cache { + /** @var string invalidation event used to purge data when reset_userdata is called, {@see \cache_helper::purge_by_event()} **/ + public const INVALIDATION_USERDATARESET = 'userdatareset'; + + /** + * Create override_cache object and link to quiz + * + * @param int $quizid The quiz to link this cache to + */ + public function __construct( + /** @var int $quizid ID of quiz cache is being operated on **/ + protected readonly int $quizid + ) { + } + + /** + * Returns the override cache + * + * @return \cache + */ + protected function get_cache(): \cache { + return \cache::make('mod_quiz', 'overrides'); + } + + /** + * Returns group cache key + * + * @param int $groupid + * @return string the group cache key + */ + protected function get_group_cache_key(int $groupid): string { + return "{$this->quizid}_g_{$groupid}"; + } + + /** + * Returns user cache key + * + * @param int $userid + * @return string the user cache key + */ + protected function get_user_cache_key(int $userid): string { + return "{$this->quizid}_u_{$userid}"; + } + + /** + * Returns the override value in the cache for the given group + * + * @param int $groupid group to get cached override data for + * @return ?\stdClass override value in the cache for the given group, or null if there is none. + */ + public function get_cached_group_override(int $groupid): ?\stdClass { + $raw = $this->get_cache()->get($this->get_group_cache_key($groupid)); + return empty($raw) || !is_object($raw) ? null : (object) $raw; + } + + /** + * Returns the override value in the cache for the given user + * + * @param int $userid user to get cached override data for + * @return ?\stdClass the override value in the cache for the given user, or null if there is none. + */ + public function get_cached_user_override(int $userid): ?\stdClass { + $raw = $this->get_cache()->get($this->get_user_cache_key($userid)); + return empty($raw) || !is_object($raw) ? null : (object) $raw; + } + + /** + * Deletes the cached override data for a given group + * + * @param int $groupid group to delete data for + */ + public function clear_for_group(int $groupid): void { + $this->get_cache()->delete($this->get_group_cache_key($groupid)); + } + + /** + * Deletes the cached override data for the given user + * + * @param int $userid user to delete data for + */ + public function clear_for_user(int $userid): void { + $this->get_cache()->delete($this->get_user_cache_key($userid)); + } + + /** + * Clears the cache for the given user and/or group. + * + * @param ?int $userid user to delete data for, or null. + * @param ?int $groupid group to delete data for, or null. + */ + public function clear_for(?int $userid = null, ?int $groupid = null): void { + if (!empty($userid)) { + $this->clear_for_user($userid); + } + + if (!empty($groupid)) { + $this->clear_for_group($groupid); + } + } +} diff --git a/mod/quiz/classes/local/override_manager.php b/mod/quiz/classes/local/override_manager.php new file mode 100644 index 0000000000000..a79c739b1a56f --- /dev/null +++ b/mod/quiz/classes/local/override_manager.php @@ -0,0 +1,603 @@ +. + +namespace mod_quiz\local; + +use mod_quiz\event\group_override_created; +use mod_quiz\event\group_override_deleted; +use mod_quiz\event\group_override_updated; +use mod_quiz\event\user_override_created; +use mod_quiz\event\user_override_deleted; +use mod_quiz\event\user_override_updated; + +/** + * Manager class for quiz overrides + * + * @package mod_quiz + * @copyright 2024 Matthew Hilton + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class override_manager { + /** @var array quiz setting keys that can be overwritten **/ + private const OVERRIDEABLE_QUIZ_SETTINGS = ['timeopen', 'timeclose', 'timelimit', 'attempts', 'password']; + + /** + * Create override manager + * + * @param \stdClass $quiz The quiz to link the manager to. + * @param \context_module $context Context being operated in + */ + public function __construct( + /** @var \stdClass The quiz linked to this manager instance **/ + protected readonly \stdClass $quiz, + /** @var \context_module The context being operated in **/ + public readonly \context_module $context + ) { + global $CFG; + // Required for quiz_* methods. + require_once($CFG->dirroot . '/mod/quiz/locallib.php'); + + // Sanity check that the context matches the quiz. + if (empty($quiz->cmid) || $quiz->cmid != $context->instanceid) { + throw new \coding_exception("Given context does not match the quiz object"); + } + } + + /** + * Returns all overrides for the linked quiz. + * + * @return array of quiz_override records + */ + public function get_all_overrides(): array { + global $DB; + return $DB->get_records('quiz_overrides', ['quiz' => $this->quiz->id]); + } + + /** + * Validates the data, usually from a moodleform or a webservice call. + * If it contains an 'id' property, additional validation is performed against the existing record. + * + * @param array $formdata data from moodleform or webservice call. + * @return array array where the keys are error elements, and the values are lists of errors for each element. + */ + public function validate_data(array $formdata): array { + global $DB; + + // Because this can be called directly (e.g. via edit_override_form) + // and not just through save_override, we must ensure the data + // is parsed in the same way. + $formdata = $this->parse_formdata($formdata); + + $formdata = (object) $formdata; + + $errors = []; + + // Ensure at least one of the overrideable settings is set. + $keysthatareset = array_map(function ($key) use ($formdata) { + return isset($formdata->$key) && !is_null($formdata->$key); + }, self::OVERRIDEABLE_QUIZ_SETTINGS); + + if (!in_array(true, $keysthatareset)) { + $errors['general'][] = new \lang_string('nooverridedata', 'quiz'); + } + + // Ensure quiz is a valid quiz. + if (empty($formdata->quiz) || empty(get_coursemodule_from_instance('quiz', $formdata->quiz))) { + $errors['quiz'][] = new \lang_string('overrideinvalidquiz', 'quiz'); + } + + // Ensure either userid or groupid is set. + if (empty($formdata->userid) && empty($formdata->groupid)) { + $errors['general'][] = new \lang_string('overridemustsetuserorgroup', 'quiz'); + } + + // Ensure not both userid and groupid are set. + if (!empty($formdata->userid) && !empty($formdata->groupid)) { + $errors['general'][] = new \lang_string('overridecannotsetbothgroupanduser', 'quiz'); + } + + // If group is set, ensure it is a real group. + if (!empty($formdata->groupid) && empty(groups_get_group($formdata->groupid))) { + $errors['groupid'][] = new \lang_string('overrideinvalidgroup', 'quiz'); + } + + // If user is set, ensure it is a valid user. + if (!empty($formdata->userid) && !\core_user::is_real_user($formdata->userid, true)) { + $errors['userid'][] = new \lang_string('overrideinvaliduser', 'quiz'); + } + + // Ensure timeclose is later than timeopen, if both are set. + if (!empty($formdata->timeclose) && !empty($formdata->timeopen) && $formdata->timeclose <= $formdata->timeopen) { + $errors['timeclose'][] = new \lang_string('closebeforeopen', 'quiz'); + } + + // Ensure attempts is a integer greater than or equal to 0 (0 is unlimited attempts). + if (isset($formdata->attempts) && ((int) $formdata->attempts < 0)) { + $errors['attempts'][] = new \lang_string('overrideinvalidattempts', 'quiz'); + } + + // Ensure timelimit is greather than zero. + if (!empty($formdata->timelimit) && $formdata->timelimit <= 0) { + $errors['timelimit'][] = new \lang_string('overrideinvalidtimelimit', 'quiz'); + } + + // Ensure other records do not exist with the same group or user. + if (!empty($formdata->quiz) && (!empty($formdata->userid) || !empty($formdata->groupid))) { + $existingrecordparams = ['quiz' => $formdata->quiz, 'groupid' => $formdata->groupid ?? null, + 'userid' => $formdata->userid ?? null, ]; + $records = $DB->get_records('quiz_overrides', $existingrecordparams, '', 'id'); + + // Ignore self if updating. + if (!empty($formdata->id)) { + unset($records[$formdata->id]); + } + + // If count is not zero, it means existing records exist already for this user/group. + if (!empty($records)) { + $errors['general'][] = new \lang_string('overridemultiplerecordsexist', 'quiz'); + } + } + + // If is existing record, validate it against the existing record. + if (!empty($formdata->id)) { + $existingrecorderrors = self::validate_against_existing_record($formdata->id, $formdata); + $errors = array_merge($errors, $existingrecorderrors); + } + + // Implode each value (array of error strings) into a single error string. + foreach ($errors as $key => $value) { + $errors[$key] = implode(",", $value); + } + + return $errors; + } + + /** + * Returns the existing quiz override record with the given ID or null if does not exist. + * + * @param int $id existing quiz override id + * @return ?\stdClass record, if exists + */ + private static function get_existing(int $id): ?\stdClass { + global $DB; + return $DB->get_record('quiz_overrides', ['id' => $id]) ?: null; + } + + /** + * Validates the formdata against an existing record. + * + * @param int $existingid id of existing quiz override record + * @param \stdClass $formdata formdata, usually from moodleform or webservice call. + * @return array array where the keys are error elements, and the values are lists of errors for each element. + */ + private static function validate_against_existing_record(int $existingid, \stdClass $formdata): array { + $existingrecord = self::get_existing($existingid); + $errors = []; + + // Existing record must exist. + if (empty($existingrecord)) { + $errors['general'][] = new \lang_string('overrideinvalidexistingid', 'quiz'); + } + + // Group value must match existing record if it is set in the formdata. + if (!empty($existingrecord) && !empty($formdata->groupid) && $existingrecord->groupid != $formdata->groupid) { + $errors['groupid'][] = new \lang_string('overridecannotchange', 'quiz'); + } + + // User value must match existing record if it is set in the formdata. + if (!empty($existingrecord) && !empty($formdata->userid) && $existingrecord->userid != $formdata->userid) { + $errors['userid'][] = new \lang_string('overridecannotchange', 'quiz'); + } + + return $errors; + } + + /** + * Parses the formdata by finding only the OVERRIDEABLE_QUIZ_SETTINGS, + * clearing any values that match the existing quiz, and re-adds the user or group id. + * + * @param array $formdata data usually from moodleform or webservice call. + * @return array array containing parsed formdata, with keys as the properties and values as the values. + * Any values set the same as the existing quiz are set to null. + */ + public function parse_formdata(array $formdata): array { + // Get the data from the form that we want to update. + $settings = array_intersect_key($formdata, array_flip(self::OVERRIDEABLE_QUIZ_SETTINGS)); + + // Remove values that are the same as currently in the quiz. + $settings = $this->clear_unused_values($settings); + + // Add the user / group back as applicable. + $userorgroupdata = array_intersect_key($formdata, array_flip(['userid', 'groupid', 'quiz', 'id'])); + + return array_merge($settings, $userorgroupdata); + } + + /** + * Saves the given override. If an id is given, it updates, otherwise it creates a new one. + * Note, capabilities are not checked, {@see require_manage_capability()} + * + * @param array $formdata data usually from moodleform or webservice call. + * @return int updated/inserted record id + */ + public function save_override(array $formdata): int { + global $DB; + + // Extract only the necessary data. + $datatoset = $this->parse_formdata($formdata); + $datatoset['quiz'] = $this->quiz->id; + + // Validate the data is OK. + $errors = $this->validate_data($datatoset); + if (!empty($errors)) { + $errorstr = implode(',', $errors); + throw new \invalid_parameter_exception($errorstr); + } + + // Insert or update. + $id = $datatoset['id'] ?? 0; + if (!empty($id)) { + $DB->update_record('quiz_overrides', $datatoset); + } else { + $id = $DB->insert_record('quiz_overrides', $datatoset); + } + + $userid = $datatoset['userid'] ?? null; + $groupid = $datatoset['groupid'] ?? null; + + // Clear the cache. + $cache = new override_cache($this->quiz->id); + $cache->clear_for($userid, $groupid); + + // Trigger moodle events. + if (empty($formdata['id'])) { + $this->fire_created_event($id, $userid, $groupid); + } else { + $this->fire_updated_event($id, $userid, $groupid); + } + + // Update open events. + quiz_update_open_attempts(['quizid' => $this->quiz->id]); + + // Update calendar events. + $isgroup = !empty($datatoset['groupid']); + if ($isgroup) { + // If is group, must update the entire quiz calendar events. + quiz_update_events($this->quiz); + } else { + // If is just a user, can update only their calendar event. + quiz_update_events($this->quiz, (object) $datatoset); + } + + return $id; + } + + /** + * Deletes all the overrides for the linked quiz + * + * @param bool $shouldlog If true, will log a override_deleted event + */ + public function delete_all_overrides(bool $shouldlog = true): void { + global $DB; + $overrides = $DB->get_records('quiz_overrides', ['quiz' => $this->quiz->id], '', 'id,userid,groupid'); + $this->delete_overrides($overrides, $shouldlog); + } + + /** + * Deletes overrides given just their ID. + * Note, the given IDs must exist otherwise an exception will be thrown. + * Also note, capabilities are not checked, {@see require_manage_capability()} + * + * @param array $ids IDs of overrides to delete + * @param bool $shouldlog If true, will log a override_deleted event + */ + public function delete_overrides_by_id(array $ids, bool $shouldlog = true): void { + global $DB; + [$sql, $params] = self::get_override_in_sql($this->quiz->id, $ids); + $records = $DB->get_records_select('quiz_overrides', $sql, $params, '', 'id,userid,groupid'); + + // Ensure all the given ids exist, so the user is aware if they give a dodgy id. + $missingids = array_diff($ids, array_keys($records)); + + if (!empty($missingids)) { + throw new \invalid_parameter_exception(get_string('overridemissingdelete', 'quiz', implode(',', $missingids))); + } + + $this->delete_overrides($records, $shouldlog); + } + + + /** + * Builds sql and parameters to find overrides in quiz with the given ids + * + * @param int $quizid id of quiz + * @param array $ids array of quiz override ids + * @return array sql and params + */ + private static function get_override_in_sql(int $quizid, array $ids): array { + global $DB; + + [$insql, $inparams] = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED); + $params = array_merge($inparams, ['quizid' => $quizid]); + $sql = 'id ' . $insql . ' AND quiz = :quizid'; + return [$sql, $params]; + } + + /** + * Deletes the given overrides in the quiz linked to the override manager. + * Note - capabilities are not checked, {@see require_manage_capability()} + * + * @param array $overrides override to delete. Must specify an id, quizid, and either a userid or groupid. + * @param bool $shouldlog If true, will log a override_deleted event + */ + public function delete_overrides(array $overrides, bool $shouldlog = true): void { + global $DB; + + foreach ($overrides as $override) { + if (empty($override->id)) { + throw new \coding_exception("All overrides must specify an ID"); + } + + // Sanity check that user xor group is specified. + // User or group is required to clear the cache. + self::ensure_userid_xor_groupid_set($override->userid ?? null, $override->groupid ?? null); + } + + if (empty($overrides)) { + // Exit early, since delete select requires at least 1 record. + return; + } + + // Match id and quiz. + [$sql, $params] = self::get_override_in_sql($this->quiz->id, array_column($overrides, 'id')); + $DB->delete_records_select('quiz_overrides', $sql, $params); + + $cache = new override_cache($this->quiz->id); + + // Perform other cleanup. + foreach ($overrides as $override) { + $userid = $override->userid ?? null; + $groupid = $override->groupid ?? null; + + $cache->clear_for($userid, $groupid); + $this->delete_override_events($userid, $groupid); + + if ($shouldlog) { + $this->fire_deleted_event($override->id, $userid, $groupid); + } + } + } + + /** + * Ensures either userid or groupid is set, but not both. + * If neither or both are set, a coding exception is thrown. + * + * @param ?int $userid user for the record, or null + * @param ?int $groupid group for the record, or null + */ + private static function ensure_userid_xor_groupid_set(?int $userid = null, ?int $groupid = null): void { + $groupset = !empty($groupid); + $userset = !empty($userid); + + // If either set, but not both (xor). + $xorset = $groupset ^ $userset; + + if (!$xorset) { + throw new \coding_exception("Either userid or groupid must be specified, but not both."); + } + } + + /** + * Deletes the events associated with the override. + * + * @param ?int $userid or null if groupid is specified + * @param ?int $groupid or null if the userid is specified + */ + private function delete_override_events(?int $userid = null, ?int $groupid = null): void { + global $DB; + + // Sanity check. + self::ensure_userid_xor_groupid_set($userid, $groupid); + + $eventssearchparams = ['modulename' => 'quiz', 'instance' => $this->quiz->id]; + + if (!empty($userid)) { + $eventssearchparams['userid'] = $userid; + } + + if (!empty($groupid)) { + $eventssearchparams['groupid'] = $groupid; + } + + $events = $DB->get_records('event', $eventssearchparams); + foreach ($events as $event) { + $eventold = \calendar_event::load($event); + $eventold->delete(); + } + } + + /** + * Requires the user has the override management capability + */ + public function require_manage_capability(): void { + require_capability('mod/quiz:manageoverrides', $this->context); + } + + /** + * Requires the user has the override viewing capability + */ + public function require_read_capability(): void { + // If user can manage, they can also view. + // It would not make sense to be able to create and edit overrides without being able to view them. + if (!has_any_capability(['mod/quiz:viewoverrides', 'mod/quiz:manageoverrides'], $this->context)) { + throw new \required_capability_exception($this->context, 'mod/quiz:viewoverrides', 'nopermissions', ''); + } + } + + /** + * Builds common event data + * + * @param int $id override id + * @return array of data to add as parameters to an event. + */ + private function get_base_event_params(int $id): array { + return [ + 'context' => $this->context, + 'other' => [ + 'quizid' => $this->quiz->id, + ], + 'objectid' => $id, + ]; + } + + /** + * Log that a given override was deleted + * + * @param int $id of quiz override that was just deleted + * @param ?int $userid user attached to override record, or null + * @param ?int $groupid group attached to override record, or null + */ + private function fire_deleted_event(int $id, ?int $userid = null, ?int $groupid = null): void { + // Sanity check. + self::ensure_userid_xor_groupid_set($userid, $groupid); + + $params = $this->get_base_event_params($id); + $params['objectid'] = $id; + + if (!empty($userid)) { + $params['relateduserid'] = $userid; + user_override_deleted::create($params)->trigger(); + } + + if (!empty($groupid)) { + $params['other']['groupid'] = $groupid; + group_override_deleted::create($params)->trigger(); + } + } + + + /** + * Log that a given override was created + * + * @param int $id of quiz override that was just created + * @param ?int $userid user attached to override record, or null + * @param ?int $groupid group attached to override record, or null + */ + private function fire_created_event(int $id, ?int $userid = null, ?int $groupid = null): void { + // Sanity check. + self::ensure_userid_xor_groupid_set($userid, $groupid); + + $params = $this->get_base_event_params($id); + + if (!empty($userid)) { + $params['relateduserid'] = $userid; + user_override_created::create($params)->trigger(); + } + + if (!empty($groupid)) { + $params['other']['groupid'] = $groupid; + group_override_created::create($params)->trigger(); + } + } + + /** + * Log that a given override was updated + * + * @param int $id of quiz override that was just updated + * @param ?int $userid user attached to override record, or null + * @param ?int $groupid group attached to override record, or null + */ + private function fire_updated_event(int $id, ?int $userid = null, ?int $groupid = null): void { + // Sanity check. + self::ensure_userid_xor_groupid_set($userid, $groupid); + + $params = $this->get_base_event_params($id); + + if (!empty($userid)) { + $params['relateduserid'] = $userid; + user_override_updated::create($params)->trigger(); + } + + if (!empty($groupid)) { + $params['other']['groupid'] = $groupid; + group_override_updated::create($params)->trigger(); + } + } + + /** + * Clears any overrideable settings in the formdata, where the value matches what is already in the quiz + * If they match, the data is set to null. + * + * @param array $formdata data usually from moodleform or webservice call. + * @return array formdata with same values cleared + */ + private function clear_unused_values(array $formdata): array { + foreach (self::OVERRIDEABLE_QUIZ_SETTINGS as $key) { + // If the formdata is the same as the current quiz object data, clear it. + if (isset($formdata[$key]) && $formdata[$key] == $this->quiz->$key) { + $formdata[$key] = null; + } + + // Ensure these keys always are set (even if null). + $formdata[$key] = $formdata[$key] ?? null; + + // If the formdata is empty, set it to null. + // This avoids putting 0, false, or '' into the DB since the override logic expects null. + // Attempts is the exception, it can have a integer value of '0', so we use is_numeric instead. + if ($key != 'attempts' && empty($formdata[$key])) { + $formdata[$key] = null; + } + + if ($key == 'attempts' && !is_numeric($formdata[$key])) { + $formdata[$key] = null; + } + } + + return $formdata; + } + + /** + * Deletes orphaned group overrides in a given course. + * Note - permissions are not checked and events are not logged for performance reasons. + * + * @param int $courseid ID of course to delete orphaned group overrides in + * @return array array of quizzes that had orphaned group overrides. + */ + public static function delete_orphaned_group_overrides_in_course(int $courseid): array { + global $DB; + + // It would be nice if we got the groupid that was deleted. + // Instead, we just update all quizzes with orphaned group overrides. + $sql = "SELECT o.id, o.quiz, o.groupid + FROM {quiz_overrides} o + JOIN {quiz} quiz ON quiz.id = o.quiz + LEFT JOIN {groups} grp ON grp.id = o.groupid + WHERE quiz.course = :courseid + AND o.groupid IS NOT NULL + AND grp.id IS NULL"; + $params = ['courseid' => $courseid]; + $records = $DB->get_records_sql($sql, $params); + + $DB->delete_records_list('quiz_overrides', 'id', array_keys($records)); + + // Purge cache for each record. + foreach ($records as $record) { + $cache = new override_cache($record->quiz); + $cache->clear_for_group($record->groupid); + } + return array_unique(array_column($records, 'quiz')); + } +} diff --git a/mod/quiz/classes/privacy/provider.php b/mod/quiz/classes/privacy/provider.php index 6d910511a48b5..8d77a18f684d8 100644 --- a/mod/quiz/classes/privacy/provider.php +++ b/mod/quiz/classes/privacy/provider.php @@ -365,8 +365,8 @@ public static function delete_data_for_all_users_in_context(\context $context) { [$quizobj] ); - // Delete all overrides - do not log. - quiz_delete_all_overrides($quiz, false); + // Delete all overrides. + $quizobj->get_override_manager()->delete_all_overrides(shouldlog: false); // This will delete all question attempts, quiz attempts, and quiz grades for this quiz. quiz_delete_all_attempts($quiz); @@ -411,9 +411,11 @@ public static function delete_data_for_user(approved_contextlist $contextlist) { 'userid' => $user->id, ]); - foreach ($overrides as $override) { - quiz_delete_override($quiz, $override->id, false); - } + $manager = $quizobj->get_override_manager(); + $manager->delete_overrides( + overrides: $overrides, + shouldlog: false, + ); // This will delete all question attempts, quiz attempts, and quiz grades for this quiz. quiz_delete_user_attempts($quizobj, $user); @@ -461,9 +463,11 @@ public static function delete_data_for_users(approved_userlist $userlist) { 'userid' => $userid, ]); - foreach ($overrides as $override) { - quiz_delete_override($quiz, $override->id, false); - } + $manager = $quizobj->get_override_manager(); + $manager->delete_overrides( + overrides: $overrides, + shouldlog: false, + ); // This will delete all question attempts, quiz attempts, and quiz grades for this user in the given quiz. quiz_delete_user_attempts($quizobj, (object)['id' => $userid]); diff --git a/mod/quiz/classes/quiz_settings.php b/mod/quiz/classes/quiz_settings.php index 48ab0d6575a22..2b1c925bb5a8c 100644 --- a/mod/quiz/classes/quiz_settings.php +++ b/mod/quiz/classes/quiz_settings.php @@ -622,4 +622,16 @@ public function get_all_question_types_used($includepotential = false) { return $questiontypes; } + + /** + * Returns an override manager instance with context and quiz loaded. + * + * @return \mod_quiz\local\override_manager + */ + public function get_override_manager(): \mod_quiz\local\override_manager { + return new \mod_quiz\local\override_manager( + quiz: $this->quiz, + context: $this->context + ); + } } diff --git a/mod/quiz/db/caches.php b/mod/quiz/db/caches.php index 0b0e541c79c07..e4d49b9c54494 100644 --- a/mod/quiz/db/caches.php +++ b/mod/quiz/db/caches.php @@ -31,5 +31,8 @@ 'mode' => cache_store::MODE_APPLICATION, 'simplekeys' => true, 'datasource' => '\mod_quiz\cache\overrides', + 'invalidationevents' => [ + \mod_quiz\local\override_cache::INVALIDATION_USERDATARESET, + ], ], ]; diff --git a/mod/quiz/db/services.php b/mod/quiz/db/services.php index 2d6545bce49a4..bf366fdb3de09 100644 --- a/mod/quiz/db/services.php +++ b/mod/quiz/db/services.php @@ -231,4 +231,28 @@ 'capabilities' => 'mod/quiz:manage', 'ajax' => true, ], + + 'mod_quiz_save_overrides' => [ + 'classname' => 'mod_quiz\external\save_overrides', + 'description' => 'Update or insert quiz overrides', + 'type' => 'write', + 'capabilities' => 'mod/quiz:manageoverrides', + 'ajax' => true, + ], + + 'mod_quiz_delete_overrides' => [ + 'classname' => 'mod_quiz\external\delete_overrides', + 'description' => 'Delete quiz overrides', + 'type' => 'write', + 'capabilities' => 'mod/quiz:manageoverrides', + 'ajax' => true, + ], + + 'mod_quiz_get_overrides' => [ + 'classname' => 'mod_quiz\external\get_overrides', + 'description' => 'Get quiz overrides', + 'type' => 'read', + 'capabilities' => 'mod/quiz:manageoverrides', + 'ajax' => true, + ], ]; diff --git a/mod/quiz/deprecatedlib.php b/mod/quiz/deprecatedlib.php index 45f2fb4101f26..97de982885f1d 100644 --- a/mod/quiz/deprecatedlib.php +++ b/mod/quiz/deprecatedlib.php @@ -311,3 +311,40 @@ function quiz_calculate_best_attempt($quiz, $attempts) { return $maxattempt; } } + +/** + * Deletes a quiz override from the database and clears any corresponding calendar events + * + * @deprecated since Moodle 4.4 + * @todo MDL-80944 Final deprecation in Moodle 4.8 + * @param stdClass $quiz The quiz object. + * @param int $overrideid The id of the override being deleted + * @param bool $log Whether to trigger logs. + * @return bool true on success + */ +#[\core\attribute\deprecated('override_manager::delete_override_by_id', since: '4.4')] +function quiz_delete_override($quiz, $overrideid, $log = true) { + \core\deprecation::emit_deprecation_if_present(__FUNCTION__); + $quizsettings = quiz_settings::create($quiz->id); + $quizsettings->get_override_manager()->delete_overrides_by_id( + ids: [$overrideid], + shouldlog: $log, + ); + + return true; +} + +/** + * Deletes all quiz overrides from the database and clears any corresponding calendar events + * + * @deprecated since Moodle 4.4 + * @todo MDL-80944 Final deprecation in Moodle 4.8 + * @param stdClass $quiz The quiz object. + * @param bool $log Whether to trigger logs. + */ +#[\core\attribute\deprecated('override_manager::delete_all_overrides', since: '4.4')] +function quiz_delete_all_overrides($quiz, $log = true) { + \core\deprecation::emit_deprecation_if_present(__FUNCTION__); + $quizsettings = quiz_settings::create($quiz->id); + $quizsettings->get_override_manager()->delete_all_overrides(shouldlog: $log); +} diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index 579fd1c46ce27..ee17c971e763f 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -176,7 +176,7 @@ $string['categoryupdated'] = 'The category was successfully updated'; $string['close'] = 'Close window'; $string['closed'] = 'Closed'; -$string['closebeforeopen'] = 'Could not update the quiz. You have specified a close date before the open date.'; +$string['closebeforeopen'] = 'The close date cannot be before or equal to the open date.'; $string['closepreview'] = 'Close preview'; $string['closereview'] = 'Close review'; $string['comment'] = 'Comment'; @@ -650,10 +650,21 @@ $string['overduehandlingautoabandon'] = 'Attempts must be submitted before time expires, or they are not counted'; $string['overduemustbesubmittedby'] = 'This attempt is now overdue. It should already have been submitted. If you would like this quiz to be graded, you must submit it by {$a}. If you do not submit it by then, no marks from this attempt will be counted.'; $string['override'] = 'Override'; +$string['overridecannotchange'] = 'The user or group cannot be changed after an override is created'; +$string['overridecannotsetbothgroupanduser'] = 'Both group and user cannot be set at the same time'; $string['overridedeletegroupsure'] = 'Are you sure you want to delete the override for group {$a}?'; $string['overridedeleteusersure'] = 'Are you sure you want to delete the override for user {$a}?'; $string['overridegroup'] = 'Override group'; $string['overridegroupeventname'] = '{$a->quiz} - {$a->group}'; +$string['overrideinvalidattempts'] = 'Attempts value must be greater than zero'; +$string['overrideinvalidexistingid'] = 'Existing override does not exist'; +$string['overrideinvalidgroup'] = 'Group given does not exist'; +$string['overrideinvalidquiz'] = 'Quiz id set does not exist'; +$string['overrideinvalidtimelimit'] = 'Time limit must be greater than zero'; +$string['overrideinvaliduser'] = 'User given does not exist'; +$string['overridemissingdelete'] = 'Override id(s) {$a} could not be deleted because they do not exist or are not a part of the given quiz'; +$string['overridemultiplerecordsexist'] = 'Multiple overrides cannot be made for the same user/group'; +$string['overridemustsetuserorgroup'] = 'A user or group must be set'; $string['overrides'] = 'Overrides'; $string['overridesforquiz'] = 'Settings overrides: {$a}'; $string['overridesnoneforgroups'] = 'No group settings overrides have been created for this quiz.'; diff --git a/mod/quiz/lib.php b/mod/quiz/lib.php index cbdafc4f08db6..36a613b7aa439 100644 --- a/mod/quiz/lib.php +++ b/mod/quiz/lib.php @@ -38,6 +38,7 @@ use mod_quiz\question\qubaids_for_quiz; use mod_quiz\question\qubaids_for_users_attempts; use core_question\statistics\questions\all_calculated_for_qubaid_condition; +use mod_quiz\local\override_cache; use mod_quiz\quiz_attempt; use mod_quiz\quiz_settings; @@ -190,7 +191,11 @@ function quiz_delete_instance($id) { $quiz = $DB->get_record('quiz', ['id' => $id], '*', MUST_EXIST); quiz_delete_all_attempts($quiz); - quiz_delete_all_overrides($quiz); + + // Delete all overrides, and for performance do not log or check permissions. + $quizobj = quiz_settings::create($quiz->id); + $quizobj->get_override_manager()->delete_all_overrides(shouldlog: false); + quiz_delete_references($quiz->id); // We need to do the following deletes before we try and delete randoms, otherwise they would still be 'in use'. @@ -214,86 +219,6 @@ function quiz_delete_instance($id) { return true; } -/** - * Deletes a quiz override from the database and clears any corresponding calendar events - * - * @param stdClass $quiz The quiz object. - * @param int $overrideid The id of the override being deleted - * @param bool $log Whether to trigger logs. - * @return bool true on success - */ -function quiz_delete_override($quiz, $overrideid, $log = true) { - global $DB; - - if (!isset($quiz->cmid)) { - $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course); - $quiz->cmid = $cm->id; - } - - $override = $DB->get_record('quiz_overrides', ['id' => $overrideid], '*', MUST_EXIST); - - // Delete the events. - if (isset($override->groupid)) { - // Create the search array for a group override. - $eventsearcharray = ['modulename' => 'quiz', - 'instance' => $quiz->id, 'groupid' => (int)$override->groupid]; - $cachekey = "{$quiz->id}_g_{$override->groupid}"; - } else { - // Create the search array for a user override. - $eventsearcharray = ['modulename' => 'quiz', - 'instance' => $quiz->id, 'userid' => (int)$override->userid]; - $cachekey = "{$quiz->id}_u_{$override->userid}"; - } - $events = $DB->get_records('event', $eventsearcharray); - foreach ($events as $event) { - $eventold = calendar_event::load($event); - $eventold->delete(); - } - - $DB->delete_records('quiz_overrides', ['id' => $overrideid]); - cache::make('mod_quiz', 'overrides')->delete($cachekey); - - if ($log) { - // Set the common parameters for one of the events we will be triggering. - $params = [ - 'objectid' => $override->id, - 'context' => context_module::instance($quiz->cmid), - 'other' => [ - 'quizid' => $override->quiz - ] - ]; - // Determine which override deleted event to fire. - if (!empty($override->userid)) { - $params['relateduserid'] = $override->userid; - $event = \mod_quiz\event\user_override_deleted::create($params); - } else { - $params['other']['groupid'] = $override->groupid; - $event = \mod_quiz\event\group_override_deleted::create($params); - } - - // Trigger the override deleted event. - $event->add_record_snapshot('quiz_overrides', $override); - $event->trigger(); - } - - return true; -} - -/** - * Deletes all quiz overrides from the database and clears any corresponding calendar events - * - * @param stdClass $quiz The quiz object. - * @param bool $log Whether to trigger logs. - */ -function quiz_delete_all_overrides($quiz, $log = true) { - global $DB; - - $overrides = $DB->get_records('quiz_overrides', ['quiz' => $quiz->id], 'id'); - foreach ($overrides as $override) { - quiz_delete_override($quiz, $override->id, $log); - } -} - /** * Updates a quiz object with override information for a user. * @@ -1620,7 +1545,7 @@ function quiz_reset_userdata($data) { } if ($purgeoverrides) { - cache::make('mod_quiz', 'overrides')->purge(); + \cache_helper::purge_by_event(\mod_quiz\local\override_cache::INVALIDATION_USERDATARESET); } return $status; @@ -2158,8 +2083,8 @@ function quiz_get_coursemodule_info($coursemodule) { function mod_quiz_cm_info_dynamic(cm_info $cm) { global $USER; - $cache = cache::make('mod_quiz', 'overrides'); - $override = $cache->get("{$cm->instance}_u_{$USER->id}"); + $cache = new override_cache($cm->instance); + $override = $cache->get_cached_user_override($USER->id); if (!$override) { $override = (object) [ @@ -2174,7 +2099,7 @@ function mod_quiz_cm_info_dynamic(cm_info $cm) { $closes = []; $groupings = groups_get_user_groups($cm->course, $USER->id); foreach ($groupings[0] as $groupid) { - $groupoverride = $cache->get("{$cm->instance}_g_{$groupid}"); + $groupoverride = $cache->get_cached_group_override($groupid); if (isset($groupoverride->timeopen)) { $opens[] = $groupoverride->timeopen; } diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index eb2b00b7c7186..7459560f2d387 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -40,6 +40,7 @@ use mod_quiz\event\attempt_submitted; use mod_quiz\grade_calculator; use mod_quiz\hook\attempt_state_changed; +use mod_quiz\local\override_manager; use mod_quiz\question\bank\qbank_helper; use mod_quiz\question\display_options; use mod_quiz\quiz_attempt; @@ -1592,28 +1593,11 @@ function quiz_send_notify_manual_graded_message(quiz_attempt $attemptobj, object * @return void */ function quiz_process_group_deleted_in_course($courseid) { - global $DB; + $affectedquizzes = override_manager::delete_orphaned_group_overrides_in_course($courseid); - // It would be nice if we got the groupid that was deleted. - // Instead, we just update all quizzes with orphaned group overrides. - $sql = "SELECT o.id, o.quiz, o.groupid - FROM {quiz_overrides} o - JOIN {quiz} quiz ON quiz.id = o.quiz - LEFT JOIN {groups} grp ON grp.id = o.groupid - WHERE quiz.course = :courseid - AND o.groupid IS NOT NULL - AND grp.id IS NULL"; - $params = ['courseid' => $courseid]; - $records = $DB->get_records_sql($sql, $params); - if (!$records) { - return; // Nothing to do. - } - $DB->delete_records_list('quiz_overrides', 'id', array_keys($records)); - $cache = cache::make('mod_quiz', 'overrides'); - foreach ($records as $record) { - $cache->delete("{$record->quiz}_g_{$record->groupid}"); - } - quiz_update_open_attempts(['quizid' => array_unique(array_column($records, 'quiz'))]); + if (!empty($affectedquizzes)) { + quiz_update_open_attempts(['quizid' => $affectedquizzes]); + } } /** diff --git a/mod/quiz/overridedelete.php b/mod/quiz/overridedelete.php index 2e70db0b07387..aa42d2c7a89c2 100644 --- a/mod/quiz/overridedelete.php +++ b/mod/quiz/overridedelete.php @@ -38,11 +38,12 @@ $cm = $quizobj->get_cm(); $course = $quizobj->get_course(); $context = $quizobj->get_context(); +$manager = $quizobj->get_override_manager(); require_login($course, false, $cm); // Check the user has the required capabilities to modify an override. -require_capability('mod/quiz:manageoverrides', $context); +$manager->require_manage_capability(); if ($override->groupid) { if (!groups_group_visible($override->groupid, $course, $cm)) { @@ -65,11 +66,7 @@ // If confirm is set (PARAM_BOOL) then we have confirmation of intention to delete. if ($confirm) { require_sesskey(); - - // Set the course module id before calling quiz_delete_override(). - $quiz->cmid = $cm->id; - quiz_delete_override($quiz, $override->id); - + $manager->delete_overrides(overrides: [$override]); redirect($cancelurl); } diff --git a/mod/quiz/overrideedit.php b/mod/quiz/overrideedit.php index bf2cb378c7e05..878e263770562 100644 --- a/mod/quiz/overrideedit.php +++ b/mod/quiz/overrideedit.php @@ -46,6 +46,7 @@ $cm = $quizobj->get_cm(); $course = $quizobj->get_course(); $context = $quizobj->get_context(); +$manager = $quizobj->get_override_manager(); $url = new moodle_url('/mod/quiz/overrideedit.php'); if ($action) { @@ -65,7 +66,7 @@ require_login($course, false, $cm); // Add or edit an override. -require_capability('mod/quiz:manageoverrides', $context); +$manager->require_manage_capability(); if ($overrideid) { // Editing an override. @@ -121,98 +122,13 @@ redirect($url); } else if ($fromform = $mform->get_data()) { - // Process the data. - $fromform->quiz = $quiz->id; - - // Replace unchanged values with null. - foreach ($keys as $key) { - if ($fromform->{$key} == $quiz->{$key}) { - $fromform->{$key} = null; - } - } - - // See if we are replacing an existing override. - $userorgroupchanged = false; - if (empty($override->id)) { - $userorgroupchanged = true; - } else if (!empty($fromform->userid)) { - $userorgroupchanged = $fromform->userid !== $override->userid; - } else { - $userorgroupchanged = $fromform->groupid !== $override->groupid; + // Only include id when editing (i.e. action is empty). + if (empty($action) && !empty($overrideid)) { + $fromform->id = $overrideid; } - if ($userorgroupchanged) { - $conditions = [ - 'quiz' => $quiz->id, - 'userid' => empty($fromform->userid) ? null : $fromform->userid, - 'groupid' => empty($fromform->groupid) ? null : $fromform->groupid]; - if ($oldoverride = $DB->get_record('quiz_overrides', $conditions)) { - // There is an old override, so we merge any new settings on top of - // the older override. - foreach ($keys as $key) { - if (is_null($fromform->{$key})) { - $fromform->{$key} = $oldoverride->{$key}; - } - } - // Set the course module id before calling quiz_delete_override(). - $quiz->cmid = $cm->id; - quiz_delete_override($quiz, $oldoverride->id); - } - } - - // Set the common parameters for one of the events we may be triggering. - $params = [ - 'context' => $context, - 'other' => [ - 'quizid' => $quiz->id - ] - ]; - if (!empty($override->id)) { - $fromform->id = $override->id; - $DB->update_record('quiz_overrides', $fromform); - $cachekey = $groupmode ? "{$fromform->quiz}_g_{$fromform->groupid}" : "{$fromform->quiz}_u_{$fromform->userid}"; - cache::make('mod_quiz', 'overrides')->delete($cachekey); - - // Determine which override updated event to fire. - $params['objectid'] = $override->id; - if (!$groupmode) { - $params['relateduserid'] = $fromform->userid; - $event = \mod_quiz\event\user_override_updated::create($params); - } else { - $params['other']['groupid'] = $fromform->groupid; - $event = \mod_quiz\event\group_override_updated::create($params); - } - - // Trigger the override updated event. - $event->trigger(); - } else { - unset($fromform->id); - $fromform->id = $DB->insert_record('quiz_overrides', $fromform); - $cachekey = $groupmode ? "{$fromform->quiz}_g_{$fromform->groupid}" : "{$fromform->quiz}_u_{$fromform->userid}"; - cache::make('mod_quiz', 'overrides')->delete($cachekey); - - // Determine which override created event to fire. - $params['objectid'] = $fromform->id; - if (!$groupmode) { - $params['relateduserid'] = $fromform->userid; - $event = \mod_quiz\event\user_override_created::create($params); - } else { - $params['other']['groupid'] = $fromform->groupid; - $event = \mod_quiz\event\group_override_created::create($params); - } - - // Trigger the override created event. - $event->trigger(); - } - - quiz_update_open_attempts(['quizid' => $quiz->id]); - if ($groupmode) { - // Priorities may have shifted, so we need to update all of the calendar events for group overrides. - quiz_update_events($quiz); - } else { - // User override. We only need to update the calendar event for this user override. - quiz_update_events($quiz, $fromform); - } + // Process the data. + $id = $manager->save_override((array) $fromform); if (!empty($fromform->submitbutton)) { redirect($overridelisturl); @@ -221,7 +137,7 @@ // The user pressed the 'again' button, so redirect back to this page. $url->remove_params('cmid'); $url->param('action', 'duplicate'); - $url->param('id', $fromform->id); + $url->param('id', $id); redirect($url); } diff --git a/mod/quiz/tests/event/events_test.php b/mod/quiz/tests/event/events_test.php index 983f6af336144..fe2895d7b61d6 100644 --- a/mod/quiz/tests/event/events_test.php +++ b/mod/quiz/tests/event/events_test.php @@ -630,6 +630,7 @@ public function test_user_override_deleted() { $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); + $quizsettings = quiz_settings::create($quiz->id); // Create an override. $override = new \stdClass(); @@ -639,7 +640,7 @@ public function test_user_override_deleted() { // Trigger and capture the event. $sink = $this->redirectEvents(); - quiz_delete_override($quiz, $override->id); + $quizsettings->get_override_manager()->delete_overrides(overrides: [$override]); $events = $sink->get_events(); $event = reset($events); @@ -660,6 +661,7 @@ public function test_group_override_deleted() { $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); + $quizsettings = quiz_settings::create($quiz->id); // Create an override. $override = new \stdClass(); @@ -669,7 +671,7 @@ public function test_group_override_deleted() { // Trigger and capture the event. $sink = $this->redirectEvents(); - quiz_delete_override($quiz, $override->id); + $quizsettings->get_override_manager()->delete_overrides(overrides: [$override]); $events = $sink->get_events(); $event = reset($events); diff --git a/mod/quiz/tests/external/external_test.php b/mod/quiz/tests/external/external_test.php index 9824e71e160e6..0822c78906f10 100644 --- a/mod/quiz/tests/external/external_test.php +++ b/mod/quiz/tests/external/external_test.php @@ -1553,7 +1553,7 @@ public function test_process_attempt() { $this->assertEquals($quizobj->get_quizid(), $customdata->instance); $this->assertEquals($quizobj->get_cmid(), $customdata->cmid); $this->assertEquals($attempt->id, $customdata->attemptid); - $this->assertObjectHasAttribute('notificationiconurl', $customdata); + $this->assertObjectHasProperty('notificationiconurl', $customdata); } // Start new attempt. diff --git a/mod/quiz/tests/external/override_test.php b/mod/quiz/tests/external/override_test.php new file mode 100644 index 0000000000000..4cd6b332945da --- /dev/null +++ b/mod/quiz/tests/external/override_test.php @@ -0,0 +1,223 @@ +. + +namespace mod_quiz\external; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/../../../../webservice/tests/helpers.php'); + +/** + * Tests for override webservices + * + * @package mod_quiz + * @copyright 2024 Matthew Hilton + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \mod_quiz\external\get_overrides + * @covers \mod_quiz\external\save_overrides + * @covers \mod_quiz\external\delete_overrides + */ +final class override_test extends \externallib_advanced_testcase { + /** + * Creates a quiz for testing. + * + * @return object $quiz + */ + private function create_quiz(): object { + $course = $this->getDataGenerator()->create_course(); + return $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); + } + + /** + * Provides values to test_get_overrides + * + * @return array + */ + public static function get_override_provider(): array { + return [ + 'quiz that exists' => [ + 'quizid' => ':quizid', + ], + 'quiz that does not exist' => [ + 'quizid' => -1, + 'expectedexception' => \dml_missing_record_exception::class, + ], + ]; + } + + /** + * Tests get_overrides + * + * @param int|string $quizid + * @param string $expectedexception + * @dataProvider get_override_provider + */ + public function test_get_overrides(int|string $quizid, string $expectedexception = ''): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $quiz = $this->create_quiz(); + + // Create an override. + $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id]); + + // Replace placeholders. + if ($quizid == ":quizid") { + $quizid = $quiz->id; + } + + if (!empty($expectedexception)) { + $this->expectException($expectedexception); + } + + $result = get_overrides::execute($quizid); + $this->assertNotEmpty($result); + } + + /** + * Provides values to test_save_overrides + * + * @return array + */ + public static function save_overrides_provider(): array { + return [ + 'good insert' => [ + 'data' => [ + 'timeopen' => 999, + ], + ], + 'bad insert' => [ + 'data' => [ + 'id' => ':existingid', + 'timeopen' => -1, + ], + 'expectedexception' => \invalid_parameter_exception::class, + ], + 'good update' => [ + 'data' => [ + 'timeopen' => 999, + ], + ], + 'bad update' => [ + 'data' => [ + 'id' => ':existingid', + 'timeopen' => -1, + ], + 'expectedexception' => \invalid_parameter_exception::class, + ], + ]; + } + + /** + * Tests save_overrides + * + * @dataProvider save_overrides_provider + * @param array $data + * @param string $expectedexception + */ + public function test_save_overrides(array $data, string $expectedexception = ''): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $quiz = $this->create_quiz(); + $user = $this->getDataGenerator()->create_user(); + + if (!empty($data['id'])) { + $data['id'] = $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'userid' => $user->id]); + } + + // Make a new user to insert a new override for. + $user = $this->getDataGenerator()->create_user(); + $data = array_merge($data, ['userid' => $user->id]); + + $payload = [ + 'quizid' => $quiz->id, + 'overrides' => [ + $data, + ], + ]; + + if (!empty($expectedexception)) { + $this->expectException($expectedexception); + } + + $result = save_overrides::execute($payload); + + // If has reached here, but not thrown exception and was expected to, fail the test. + if ($expectedexception) { + $this->fail("Expected exception " . $expectedexception . " was not thrown"); + } + + $this->assertNotEmpty($result['ids']); + $this->assertCount(1, $result['ids']); + } + + /** + * Provides values to test_delete_overrides + * + * @return array + */ + public static function delete_overrides_provider(): array { + return [ + 'delete existing override' => [ + 'id' => ':existingid', + ], + 'delete override that does not exist' => [ + 'id' => -1, + 'expectedexception' => \invalid_parameter_exception::class, + ], + ]; + } + + /** + * Tests delete_overrides + * + * @dataProvider delete_overrides_provider + * @param int|string $id + * @param string $expectedexception + */ + public function test_delete_overrides(int|string $id, string $expectedexception = ''): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $quiz = $this->create_quiz(); + $user = $this->getDataGenerator()->create_user(); + + if ($id == ':existingid') { + $id = $DB->insert_record('quiz_overrides', ['quiz' => $quiz->id, 'userid' => $user->id]); + } + + if (!empty($expectedexception)) { + $this->expectException($expectedexception); + } + + $result = delete_overrides::execute(['quizid' => $quiz->id, 'ids' => [$id]]); + + // If has reached here, but not thrown exception and was expected to, fail the test. + if ($expectedexception) { + $this->fail("Expected exception " . $expectedexception . " was not thrown"); + } + + $this->assertNotEmpty($result['ids']); + $this->assertContains($id, $result['ids']); + } +} diff --git a/mod/quiz/tests/local/override_cache_test.php b/mod/quiz/tests/local/override_cache_test.php new file mode 100644 index 0000000000000..d77f77319c850 --- /dev/null +++ b/mod/quiz/tests/local/override_cache_test.php @@ -0,0 +1,80 @@ +. + +namespace mod_quiz\local; + +/** + * Cache manager tests for quiz overrides + * + * @package mod_quiz + * @copyright 2024 Matthew Hilton + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \mod_quiz\local\override_cache + */ +final class override_cache_test extends \advanced_testcase { + /** + * Tests CRUD functions of the override_cache + */ + public function test_crud(): void { + // Cache is normally protected, but for testing we reflect it and put test data into it. + $overridecache = new override_cache(0); + $reflection = new \ReflectionClass($overridecache); + + $getcache = $reflection->getMethod('get_cache'); + $cache = $getcache->invoke($overridecache); + + $getuserkey = $reflection->getMethod('get_user_cache_key'); + + $getgroupkey = $reflection->getMethod('get_group_cache_key'); + + $dummydata = (object)[ + 'userid' => 1234, + ]; + + // Set some data. + $cache->set($getuserkey->invoke($overridecache, 123), $dummydata); + $cache->set($getgroupkey->invoke($overridecache, 456), $dummydata); + + // Get the data back. + $this->assertEquals($dummydata, $overridecache->get_cached_user_override(123)); + $this->assertEquals($dummydata, $overridecache->get_cached_group_override(456)); + + // Delete. + $overridecache->clear_for_user(123); + $overridecache->clear_for_group(456); + + $this->assertEmpty($overridecache->get_cached_user_override(123)); + $this->assertEmpty($overridecache->get_cached_group_override(456)); + + // Put some data back. + $cache->set($getuserkey->invoke($overridecache, 123), $dummydata); + $cache->set($getgroupkey->invoke($overridecache, 456), $dummydata); + + // Clear it. + $overridecache->clear_for(123, 456); + $this->assertEmpty($overridecache->get_cached_user_override(123)); + $this->assertEmpty($overridecache->get_cached_group_override(456)); + + // Put some data back. + $cache->set($getuserkey->invoke($overridecache, 123), 'testuser'); + $cache->set($getgroupkey->invoke($overridecache, 456), 'testgroup'); + + // Purge it. + \cache_helper::purge_by_event(override_cache::INVALIDATION_USERDATARESET); + $this->assertEmpty($overridecache->get_cached_user_override(123)); + $this->assertEmpty($overridecache->get_cached_group_override(456)); + } +} diff --git a/mod/quiz/tests/local/override_manager_test.php b/mod/quiz/tests/local/override_manager_test.php new file mode 100644 index 0000000000000..caa7e4e949bb4 --- /dev/null +++ b/mod/quiz/tests/local/override_manager_test.php @@ -0,0 +1,1076 @@ +. + +namespace mod_quiz\local; + +use mod_quiz\event\group_override_created; +use mod_quiz\event\group_override_updated; +use mod_quiz\event\user_override_created; +use mod_quiz\event\user_override_updated; +use mod_quiz\event\user_override_deleted; +use mod_quiz\quiz_settings; + +/** + * Test for override_manager class + * + * @package mod_quiz + * @copyright 2024 Matthew Hilton + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \mod_quiz\local\override_manager + */ +final class override_manager_test extends \advanced_testcase { + /** @var array Default quiz settings **/ + private const TEST_QUIZ_SETTINGS = [ + 'attempts' => 5, + 'timeopen' => 100000000, + 'timeclose' => 10000001, + 'timelimit' => 10, + ]; + + /** + * Create quiz and course for test + * + * @return array containing quiz object and course + */ + private function create_quiz_and_course(): array { + $course = $this->getDataGenerator()->create_course(['groupmode' => SEPARATEGROUPS]); + $quizparams = array_merge(self::TEST_QUIZ_SETTINGS, ['course' => $course->id]); + $quiz = $this->getDataGenerator()->create_module('quiz', $quizparams); + $quizobj = quiz_settings::create($quiz->id); + return [$quizobj, $course]; + } + + /** + * Utility function that replaces the placeholders in the given data. + * + * @param array $data + * @param array $placeholdervalues + * @return array the $data with the placeholders replaced + */ + private function replace_placeholders(array $data, array $placeholdervalues) { + foreach ($data as $key => $value) { + $replacement = $placeholdervalues[$value] ?? null; + + if (!empty($replacement)) { + $data[$key] = $replacement; + } + } + + return $data; + } + + /** + * Utility function that sets up data for tests testing CRUD operations. + * Placeholders such as ':userid' and ':groupid' can be used in the data to replace with the relevant id. + * + * @param array $existingdata Data used to setup a preexisting quiz override record. + * @param array $formdata submitted formdata + * @return \stdClass containing formdata (after placeholders replaced), quizobj, user and group + */ + private function setup_existing_and_testing_data(array $existingdata, array $formdata): \stdClass { + global $DB; + + [$quizobj, $course] = $this->create_quiz_and_course(); + + $user = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + + $user2 = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($user2->id, $course->id); + + $groupid = groups_create_group((object) ['courseid' => $course->id, 'name' => 'test']); + $group2id = groups_create_group((object) ['courseid' => $course->id, 'name' => 'test2']); + + // Replace any userid or groupid placeholders in the form data or existing data. + $placeholdervalues = [ + ':userid' => $user->id, + ':user2id' => $user2->id, + ':groupid' => $groupid, + ':group2id' => $group2id, + ]; + + if (!empty($existingdata)) { + // Raw insert the existing data for the test into the DB. + // We assume it is valid for the test. + $existingdata['quiz'] = $quizobj->get_quizid(); + $existingid = $DB->insert_record('quiz_overrides', $this->replace_placeholders($existingdata, $placeholdervalues)); + $placeholdervalues[':existingid'] = $existingid; + } + + $formdata = $this->replace_placeholders($formdata, $placeholdervalues); + + // Add quiz id to formdata. + $formdata['quiz'] = $quizobj->get_quizid(); + + return (object) [ + 'quizobj' => $quizobj, + 'formdata' => $formdata, + 'user1' => $user, + 'groupid1' => $groupid, + ]; + } + + /** + * Provides values to test_save_and_get_override + * + * @return array + */ + public static function save_and_get_override_provider(): array { + return [ + 'create user override - no existing data' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 1, + 'attempts' => 999, + 'password' => 'test', + ], + 'expectedrecordscreated' => 1, + 'expectedevent' => user_override_created::class, + ], + 'create user override - no calendar events should be created' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => null, + 'timeclose' => null, + 'timelimit' => 1, + 'attempts' => 999, + 'password' => 'test', + ], + 'expectedrecordscreated' => 1, + 'expectedevent' => user_override_created::class, + ], + 'create user override - only timeopen' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => 50, + 'timeclose' => null, + 'timelimit' => null, + 'attempts' => null, + 'password' => 'test', + ], + 'expectedrecordscreated' => 1, + 'expectedevent' => user_override_created::class, + ], + 'create group override - no existing data' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => null, + 'groupid' => ':groupid', + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 1, + 'attempts' => 999, + 'password' => 'test', + ], + 'expectedrecordscreated' => 1, + 'expectedevent' => group_override_created::class, + ], + 'create group override - no calendar events should be created' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => null, + 'groupid' => ':groupid', + 'timeopen' => null, + 'timeclose' => null, + 'timelimit' => 1, + 'attempts' => 999, + 'password' => 'test', + ], + 'expectedrecordscreated' => 1, + 'expectedevent' => group_override_created::class, + ], + 'create group override - only timeopen' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => null, + 'groupid' => ':groupid', + 'timeopen' => 50, + 'timeclose' => null, + 'timelimit' => null, + 'attempts' => null, + 'password' => null, + ], + 'expectedrecordscreated' => 1, + 'expectedevent' => group_override_created::class, + ], + 'update user override - updating existing data' => [ + 'existingdata' => [ + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 2, + 'attempts' => 2, + 'password' => 'test2', + ], + 'formdata' => [ + 'id' => ':existingid', + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => 52, + 'timeclose' => 53, + 'timelimit' => 1, + 'attempts' => 999, + 'password' => 'test', + ], + 'expectedrecordscreated' => 0, + 'expectedevent' => user_override_updated::class, + ], + 'update group override - updating existing data' => [ + 'existingdata' => [ + 'userid' => null, + 'groupid' => ':groupid', + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 2, + 'attempts' => 2, + 'password' => 'test2', + ], + 'formdata' => [ + 'id' => ':existingid', + 'userid' => null, + 'groupid' => ':groupid', + 'timeopen' => 52, + 'timeclose' => 53, + 'timelimit' => 1, + 'attempts' => 999, + 'password' => 'test', + ], + 'expectedrecordscreated' => 0, + 'expectedevent' => group_override_updated::class, + ], + 'attempts is set to unlimited (i.e. 0)' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => null, + 'timeclose' => null, + 'timelimit' => null, + // This checks we are using empty() carefully, since this is valid. + 'attempts' => 0, + 'password' => null, + ], + 'expectedrecordscreated' => 1, + 'expectedevent' => user_override_created::class, + ], + 'some settings submitted are the same as what is in the quiz (valid)' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => ':userid', + 'groupid' => null, + // Make these the same, they should be ignored. + 'timeopen' => self::TEST_QUIZ_SETTINGS['timeopen'], + 'timeclose' => self::TEST_QUIZ_SETTINGS['timeclose'], + 'attempts' => self::TEST_QUIZ_SETTINGS['attempts'], + // However change this, this should still get updated. + 'timelimit' => self::TEST_QUIZ_SETTINGS['timelimit'] + 5, + 'password' => null, + ], + 'expectedrecordscreated' => 1, + 'expectedevent' => user_override_created::class, + ], + ]; + } + + /** + * Tests save_override function + * + * @param array $existingdata If given, an existing override will be created. + * @param array $formdata The data being tested, simulating being submitted + * @param int $expectedrecordscreated The number of records that are expected to be created by upsert + * @param string $expectedeventclass an event class, which is expected to the emitted by upsert + * @dataProvider save_and_get_override_provider + */ + public function test_save_and_get_override( + array $existingdata, + array $formdata, + int $expectedrecordscreated, + string $expectedeventclass + ): void { + global $DB; + + $this->setAdminUser(); + $this->resetAfterTest(); + + $test = $this->setup_existing_and_testing_data($existingdata, $formdata); + $manager = $test->quizobj->get_override_manager(); + + // Get the count before. + $beforecount = $DB->count_records('quiz_overrides'); + + $sink = $this->redirectEvents(); + + // Submit the form data. + $id = $manager->save_override($test->formdata); + + // Get the count after and compare to the expected. + $aftercount = $DB->count_records('quiz_overrides'); + $this->assertEquals($expectedrecordscreated, $aftercount - $beforecount); + + // Read back the created/updated value, and compare it to the formdata. + $readback = $DB->get_record('quiz_overrides', ['id' => $id]); + $this->assertNotEmpty($readback); + + foreach ($test->formdata as $key => $value) { + // If the value is the same as the quiz, we expect it to be null. + if (!empty(self::TEST_QUIZ_SETTINGS[$key]) && $value == self::TEST_QUIZ_SETTINGS[$key]) { + $this->assertNull($readback->{$key}); + } else { + // Else we expect the value to have been set. + $this->assertEquals($value, $readback->{$key}); + } + } + + // Check the get_all_overrides function returns this data as well. + $alloverrideids = array_column($manager->get_all_overrides(), 'id'); + + $this->assertCount($aftercount, $alloverrideids); + $this->assertTrue(in_array($id, $alloverrideids)); + + // Check that the calendar events are created as well. + // This is only if the times were set, and they were set differently to the default. + $expectedcount = 0; + + if (!empty($formdata['timeopen']) && $formdata['timeopen'] != self::TEST_QUIZ_SETTINGS['timeopen']) { + $expectedcount += 1; + } + + if (!empty($formdata['timeclose']) && $formdata['timeclose'] != self::TEST_QUIZ_SETTINGS['timeclose']) { + $expectedcount += 1; + } + + // Find all events. We assume the test event times do not exceed a time of 999. + $events = calendar_get_events(0, 999, [$test->user1->id], [$test->groupid1], false); + $this->assertCount($expectedcount, $events); + + // Check the expected event was also emitted. + if (!empty($expectedeventclass)) { + $events = $sink->get_events(); + $eventclasses = array_map(fn($event) => get_class($event), $events); + $this->assertTrue(in_array($expectedeventclass, $eventclasses)); + } + } + + /** + * Tests that when saving an override, validation is performed and an exception is thrown if this fails. + * Note - this does not test every validation scenario, for that {@see validate_data_provider} + */ + public function test_save_override_validation_throws_exception(): void { + $this->setAdminUser(); + $this->resetAfterTest(); + + [$quizobj] = $this->create_quiz_and_course(); + $manager = $quizobj->get_override_manager(); + + // Submit empty (bad data). + $this->expectException(\invalid_parameter_exception::class); + $this->expectExceptionMessage(get_string('nooverridedata', 'quiz')); + $manager->save_override([]); + } + + /** + * Provides values to test_validate_data + * + * @return array + */ + public static function validate_data_provider(): array { + return [ + 'valid create for user' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 2, + 'attempts' => 2, + 'password' => 'test2', + ], + 'expectedreturn' => [], + ], + 'valid create for group' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => null, + 'groupid' => ':groupid', + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 2, + 'attempts' => 2, + 'password' => 'test2', + ], + 'expectedreturn' => [], + ], + 'valid update for user' => [ + 'existingdata' => [ + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 2, + 'attempts' => 2, + 'password' => 'test2', + ], + 'formdata' => [ + 'id' => ':existingid', + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 2, + 'attempts' => 2, + 'password' => 'test2', + ], + 'expectedreturn' => [], + ], + 'valid update for group' => [ + 'existingdata' => [ + 'userid' => null, + 'groupid' => ':groupid', + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 2, + 'attempts' => 2, + 'password' => 'test2', + ], + 'formdata' => [ + 'id' => ':existingid', + 'userid' => null, + 'groupid' => ':groupid', + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 2, + 'attempts' => 2, + 'password' => 'test2', + ], + 'expectedreturn' => [], + ], + 'update but without user or group specified in update' => [ + 'existingdata' => [ + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 2, + 'attempts' => 2, + 'password' => 'test2', + ], + 'formdata' => [ + 'id' => ':existingid', + 'userid' => null, + 'groupid' => null, + 'timeopen' => 52, + 'timeclose' => 53, + 'timelimit' => 1, + 'attempts' => 999, + 'password' => 'test', + ], + 'expectedreturn' => [ + 'general' => get_string('overridemustsetuserorgroup', 'quiz'), + ], + ], + 'both userid and groupid specified' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => ':userid', + 'groupid' => ':groupid', + 'timeopen' => 50, + 'timeclose' => 100, + 'timelimit' => null, + 'attempts' => null, + 'password' => null, + ], + 'expectedreturn' => [ + 'general' => get_string('overridecannotsetbothgroupanduser', 'quiz'), + ], + ], + 'neither userid nor groupid specified' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => null, + 'groupid' => null, + 'timeopen' => 50, + 'timeclose' => 100, + 'timelimit' => null, + 'attempts' => null, + 'password' => null, + ], + 'expectedreturn' => [ + 'general' => get_string('overridemustsetuserorgroup', 'quiz'), + ], + ], + 'empty data' => [ + 'existingdata' => [], + 'formdata' => [], + 'expectedreturn' => [ + 'general' => get_string('nooverridedata', 'quiz'), + ], + ], + 'all nulls' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => null, + 'groupid' => null, + 'timeopen' => null, + 'timeclose' => null, + 'timelimit' => null, + 'attempts' => null, + 'password' => null, + ], + 'expectedreturn' => [ + 'general' => get_string('nooverridedata', 'quiz'), + ], + ], + 'user given, rest are nulls' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => null, + 'timeclose' => null, + 'timelimit' => null, + 'attempts' => null, + 'password' => null, + ], + 'expectedreturn' => [ + 'general' => get_string('nooverridedata', 'quiz'), + ], + ], + 'all submitted data was the same as the existing quiz' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => self::TEST_QUIZ_SETTINGS['timeopen'], + 'timeclose' => self::TEST_QUIZ_SETTINGS['timeclose'], + 'attempts' => self::TEST_QUIZ_SETTINGS['attempts'], + 'timelimit' => self::TEST_QUIZ_SETTINGS['timelimit'], + 'password' => null, + ], + 'expectedreturn' => [ + 'general' => get_string('nooverridedata', 'quiz'), + ], + ], + 'userid is invalid' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => -1, + 'groupid' => null, + 'timeopen' => null, + 'timeclose' => null, + 'timelimit' => null, + 'attempts' => null, + 'password' => 'mypass', + ], + 'expectedreturn' => [ + 'userid' => get_string('overrideinvaliduser', 'quiz'), + ], + ], + 'groupid is invalid' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => null, + 'groupid' => -1, + 'timeopen' => null, + 'timeclose' => null, + 'timelimit' => null, + 'attempts' => null, + 'password' => 'mypass', + ], + 'expectedreturn' => [ + 'groupid' => get_string('overrideinvalidgroup', 'quiz'), + ], + ], + 'timeclose is before timeopen' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => 50, + 'timeclose' => 10, + 'timelimit' => null, + 'attempts' => null, + 'password' => null, + ], + 'expectedreturn' => [ + 'timeclose' => get_string('closebeforeopen', 'quiz'), + ], + ], + 'timeclose is same as timeopen' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => 50, + 'timeclose' => 50, + 'timelimit' => null, + 'attempts' => null, + 'password' => null, + ], + 'expectedreturn' => [ + 'timeclose' => get_string('closebeforeopen', 'quiz'), + ], + ], + 'timelimit is negative' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => null, + 'timeclose' => null, + 'timelimit' => -1, + 'attempts' => null, + 'password' => null, + ], + 'expectedreturn' => [ + 'timelimit' => get_string('overrideinvalidtimelimit', 'quiz'), + ], + ], + 'attempts is negative' => [ + 'existingdata' => [], + 'formdata' => [ + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => null, + 'timeclose' => null, + 'timelimit' => null, + 'attempts' => -1, + 'password' => null, + ], + 'expectedreturn' => [ + 'attempts' => get_string('overrideinvalidattempts', 'quiz'), + ], + ], + 'existing id given to update is invalid' => [ + 'existingdata' => [], + 'formdata' => [ + 'id' => 999999999999, + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => null, + 'attempts' => null, + 'password' => null, + ], + 'expectedreturn' => [ + 'general' => get_string('overrideinvalidexistingid', 'quiz'), + ], + ], + 'userid changed after creation' => [ + 'existingdata' => [ + 'userid' => ":userid", + 'groupid' => null, + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 2, + 'attempts' => 2, + 'password' => 'test2', + ], + 'formdata' => [ + 'id' => ':existingid', + 'userid' => ":user2id", + 'groupid' => null, + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => null, + 'attempts' => null, + 'password' => null, + ], + 'expectedreturn' => [ + 'userid' => get_string('overridecannotchange', 'quiz'), + ], + ], + 'groupid changed after creation' => [ + 'existingdata' => [ + 'userid' => null, + 'groupid' => ':groupid', + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 2, + 'attempts' => 2, + 'password' => 'test2', + ], + 'formdata' => [ + 'id' => ':existingid', + 'userid' => null, + 'groupid' => ':group2id', + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => null, + 'attempts' => null, + 'password' => null, + ], + 'expectedreturn' => [ + 'groupid' => get_string('overridecannotchange', 'quiz'), + ], + ], + 'create multiple for the same user' => [ + 'existingdata' => [ + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 2, + 'attempts' => 2, + 'password' => 'test2', + ], + 'formdata' => [ + 'userid' => ':userid', + 'groupid' => null, + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 2, + 'attempts' => 2, + 'password' => 'test2', + ], + 'expectedreturn' => [ + 'general' => get_string('overridemultiplerecordsexist', 'quiz'), + ], + ], + 'create multiple for the same group' => [ + 'existingdata' => [ + 'userid' => null, + 'groupid' => ':groupid', + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 2, + 'attempts' => 2, + 'password' => 'test2', + ], + 'formdata' => [ + 'userid' => null, + 'groupid' => ':groupid', + 'timeopen' => 50, + 'timeclose' => 51, + 'timelimit' => 2, + 'attempts' => 2, + 'password' => 'test2', + ], + 'expectedreturn' => [ + 'general' => get_string('overridemultiplerecordsexist', 'quiz'), + ], + ], + ]; + } + + /** + * Tests validate_data function + * + * @param array $existingdata If given, an existing override will be created. + * @param array $formdata The data being tested, simulating being submitted + * @param array $expectedreturn expected keys and associated values expected to be returned from validate_data + * @dataProvider validate_data_provider + */ + public function test_validate_data(array $existingdata, array $formdata, array $expectedreturn): void { + $this->setAdminUser(); + $this->resetAfterTest(); + + // Setup the test. + $test = $this->setup_existing_and_testing_data($existingdata, $formdata); + + // Validate. + $manager = $test->quizobj->get_override_manager(); + $result = $manager->validate_data($test->formdata); + + // Ensure all expected errors appear in the return. + foreach ($expectedreturn as $key => $error) { + // Ensure it is set. + $this->assertContains($key, array_keys($result)); + + // Ensure the message contains the expected error. + $this->assertStringContainsString($error, $result[$key]); + } + + // Ensure there are no extra returned errors than what was expected. + $extra = array_diff_key($result, $expectedreturn); + $this->assertEmpty($extra, 'More validation errors were returned than expected'); + } + + /** + * Provide delete functions to test + * + * @return array + */ + public static function delete_override_provider(): array { + return [ + 'delete by id (no events logged)' => [ + 'function' => fn($manager, $override) => $manager->delete_overrides_by_id([$override->id], false, false), + 'checkeventslogged' => false, + ], + 'delete single (no events logged)' => [ + 'function' => fn($manager, $override) => $manager->delete_overrides([$override], false, false), + 'checkeventslogged' => false, + ], + 'delete all (no events logged)' => [ + 'function' => fn($manager, $override) => $manager->delete_all_overrides(false, false), + 'checkeventslogged' => false, + ], + 'delete by id (events logged)' => [ + 'function' => fn($manager, $override) => $manager->delete_overrides_by_id([$override->id], true, false), + 'checkeventslogged' => true, + ], + 'delete single (events logged)' => [ + 'function' => fn($manager, $override) => $manager->delete_overrides([$override], true, false), + 'checkeventslogged' => true, + ], + 'delete all (events logged)' => [ + 'function' => fn($manager, $override) => $manager->delete_all_overrides(true, false), + 'checkeventslogged' => true, + ], + 'delete all in database (events logged)' => [ + 'function' => fn($manager, $override) => $manager->delete_all_overrides(true, false), + 'checkeventslogged' => true, + ], + ]; + } + + /** + * Tests deleting override functions + * + * @param \Closure $deletefunction delete function to be called. + * @param bool $checkeventslogged if true, will check that events were logged. + * @dataProvider delete_override_provider + */ + public function test_delete_override(\Closure $deletefunction, bool $checkeventslogged): void { + $this->setAdminUser(); + $this->resetAfterTest(); + [$quizobj] = $this->create_quiz_and_course(); + $user = $this->getDataGenerator()->create_user(); + + // Create an override. + $data = [ + 'userid' => $user->id, + 'timeopen' => 500, + ]; + $manager = $quizobj->get_override_manager(); + $id = $manager->save_override($data); + + // Check the calendar event was made. + $this->assertCount(1, calendar_get_events(0, 999, [$user->id], false, false)); + + // Check that the cache was made. + $overridecache = new override_cache($quizobj->get_quizid()); + $this->assertNotEmpty($overridecache->get_cached_user_override($user->id)); + + // Capture events. + $sink = $this->redirectEvents(); + + $override = (object) [ + 'id' => $id, + 'userid' => $user->id, + ]; + + // Delete the override. + $deletefunction($manager, $override); + + // Check the calendar event was deleted. + $this->assertCount(0, calendar_get_events(0, 999, [$user->id], false, false)); + + // Check that the cache was cleared. + $this->assertEmpty($overridecache->get_cached_user_override($user->id)); + + // Check the event was logged. + if ($checkeventslogged) { + $events = $sink->get_events(); + $eventclasses = array_map(fn($e) => get_class($e), $events); + $this->assertTrue(in_array(user_override_deleted::class, $eventclasses)); + } + } + + /** + * Creates a role with the given capabilities and assigns it to the user. + * + * @param int $userid user to assign role to + * @param array $capabilities array of $capname => $permission to add to role + */ + private function give_user_role_with_capabilities(int $userid, array $capabilities): void { + // Setup the role and permissions. + $roleid = $this->getDataGenerator()->create_role(); + foreach ($capabilities as $capname => $permission) { + role_change_permission($roleid, \context_system::instance(), $capname, $permission); + } + + $user = $this->getDataGenerator()->create_user(); + role_assign($roleid, $userid, \context_system::instance()->id); + } + + /** + * Provides values to test_require_read_capability + * + * @return array + */ + public static function require_read_capability_provider(): array { + $readfunc = fn($manager) => $manager->require_read_capability(); + $managefunc = fn($manager) => $manager->require_manage_capability(); + + return [ + 'reading - cannot read' => [ + 'capabilitiestogive' => [], + 'expectedallowed' => false, + 'functionbeingtested' => $readfunc, + ], + 'reading - can read' => [ + 'capabilitiestogive' => ['mod/quiz:viewoverrides' => CAP_ALLOW], + 'expectedallowed' => true, + 'functionbeingtested' => $readfunc, + ], + 'reading - can manage (so can also read)' => [ + 'capabilitiestogive' => ['mod/quiz:manageoverrides' => CAP_ALLOW], + 'expectedallowed' => true, + 'functionbeingtested' => $readfunc, + ], + 'manage - cannot manage' => [ + 'capabilitiestogive' => [], + 'expectedallowed' => false, + 'functionbeingtested' => $managefunc, + ], + 'manage - can only read' => [ + 'capabilitiestogive' => ['mod/quiz:viewoverrides' => CAP_ALLOW], + 'expectedallowed' => false, + 'functionbeingtested' => $managefunc, + ], + 'manage - can manage' => [ + 'capabilitiestogive' => ['mod/quiz:manageoverrides' => CAP_ALLOW], + 'expectedallowed' => true, + 'functionbeingtested' => $managefunc, + ], + ]; + } + + /** + * Tests require_read_capability + * + * @param array $capabilitiestogive array of capability => value to give to test user + * @param bool $expectedallowed if false, will expect required_capability_exception to be thrown + * @param \Closure $functionbeingtested is passed the manager and calls the function being tested (usually require_*_capability) + * @dataProvider require_read_capability_provider + */ + public function test_require_read_capability( + array $capabilitiestogive, + bool $expectedallowed, + \Closure $functionbeingtested + ): void { + $this->resetAfterTest(); + [$quizobj] = $this->create_quiz_and_course(); + $user = $this->getDataGenerator()->create_user(); + $this->give_user_role_with_capabilities($user->id, $capabilitiestogive); + $this->setUser($user); + + if (!$expectedallowed) { + $this->expectException(\required_capability_exception::class); + } + $functionbeingtested($quizobj->get_override_manager()); + } + + /** + * Tests delete_orphaned_group_overrides_in_course + */ + public function test_delete_orphaned_group_overrides_in_course(): void { + global $DB; + + $this->setAdminUser(); + $this->resetAfterTest(); + [$quizobj, $course] = $this->create_quiz_and_course(); + + // Create a two group and one user overrides. + $groupid = groups_create_group((object) ['courseid' => $course->id, 'name' => 'test']); + $groupdata = [ + 'quiz' => $quizobj->get_quizid(), + 'groupid' => $groupid, + 'password' => 'test', + ]; + + $group2id = groups_create_group((object) ['courseid' => $course->id, 'name' => 'test2']); + $group2data = [ + 'quiz' => $quizobj->get_quizid(), + 'groupid' => $group2id, + 'password' => 'test', + ]; + + $userid = $this->getDataGenerator()->create_user()->id; + $userdata = [ + 'quiz' => $quizobj->get_quizid(), + 'userid' => $userid, + 'password' => 'test', + ]; + + $manager = $quizobj->get_override_manager(); + $manager->save_override($groupdata); + $useroverrideid = $manager->save_override($userdata); + $group2overrideid = $manager->save_override($group2data); + + $this->assertCount(3, $manager->get_all_overrides()); + + // Delete the first group (via the DB, so that the callbacks are not run). + $DB->delete_records('groups', ['id' => $groupid]); + + // Confirm the overrides still exist (no callback has been run yet). + $this->assertCount(3, $manager->get_all_overrides()); + + // Run orphaned record callback. + override_manager::delete_orphaned_group_overrides_in_course($course->id); + + // Confirm it has now been deleted (but user and other group override still exists). + $overrides = $manager->get_all_overrides(); + $this->assertCount(2, $overrides); + $this->assertArrayHasKey($useroverrideid, $overrides); + $this->assertArrayHasKey($group2overrideid, $overrides); + } + + /** + * Tests deleting by id but providing an invalid id + */ + public function test_delete_by_id_invalid_id(): void { + $this->setAdminUser(); + $this->resetAfterTest(); + [$quizobj] = $this->create_quiz_and_course(); + + $this->expectException(\invalid_parameter_exception::class); + $this->expectExceptionMessage(get_string('overridemissingdelete', 'quiz', '0,1')); + + // These ids do not exist, so this should throw an error. + $quizobj->get_override_manager()->delete_overrides_by_id([0, 1]); + } + + /** + * Tests that constructing a override manager with mismatching quiz and context throws an exception + */ + public function test_quiz_context_mismatch(): void { + $this->resetAfterTest(); + + // Create one quiz for context, but make the quiz given have an incorrect cmid. + [$quizobj] = $this->create_quiz_and_course(); + $context = \context_module::instance($quizobj->get_cmid()); + + $quiz = (object)[ + 'cmid' => $context->instanceid + 1, + ]; + + $this->expectException(\coding_exception::class); + $this->expectExceptionMessage("Given context does not match the quiz object"); + new override_manager($quiz, $context); + } +} diff --git a/mod/quiz/tests/privacy/provider_test.php b/mod/quiz/tests/privacy/provider_test.php index 742cb6c672634..793d93eed5936 100644 --- a/mod/quiz/tests/privacy/provider_test.php +++ b/mod/quiz/tests/privacy/provider_test.php @@ -535,7 +535,7 @@ public function test_delete_data_for_users() { // Delete the data for user1 and user3 in course1 and check it is removed. $quiz1context = $quiz1obj->get_context(); $approveduserlist = new \core_privacy\local\request\approved_userlist($quiz1context, 'mod_quiz', - [$user1->id, $user3->id]); + [$user1->id, $user3->id]); provider::delete_data_for_users($approveduserlist); // Only the attempt of user2 should be remained in quiz1. diff --git a/mod/quiz/upgrade.txt b/mod/quiz/upgrade.txt index e91e37fd1fc5e..f5ce83ccf2316 100644 --- a/mod/quiz/upgrade.txt +++ b/mod/quiz/upgrade.txt @@ -22,6 +22,12 @@ This file describes API changes in the quiz code. * The following previously deprecated methods have been removed and can no longer be used: - `get_slot_tags_for_slot_id` - `quiz_retrieve_tags_for_slot_ids` +* Quiz override logic has been refactored and put in a new override_manager class. +* Quiz override cache should now be accessed using the new override_cache class. +* The quiz_delete_overrides and quiz_delete_all_overrides functions are now deprecated. Please instead use: + - override_manager::delete_override_by_id + - override_manager::delete_overrides + - override_manager::delete_all_overrides === 4.3 === diff --git a/mod/quiz/version.php b/mod/quiz/version.php index 33a399048fe87..17af57b3c3c71 100644 --- a/mod/quiz/version.php +++ b/mod/quiz/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2023112300; +$plugin->version = 2023112303; $plugin->requires = 2023100400; $plugin->component = 'mod_quiz'; diff --git a/mod/workshop/tests/privacy/provider_test.php b/mod/workshop/tests/privacy/provider_test.php index a33272fab4ee5..7f167b29a7d79 100644 --- a/mod/workshop/tests/privacy/provider_test.php +++ b/mod/workshop/tests/privacy/provider_test.php @@ -269,7 +269,7 @@ public function test_export_user_data_1() { $workshop = $writer->get_data([]); $this->assertEquals('Workshop11', $workshop->name); - $this->assertObjectHasAttribute('phase', $workshop); + $this->assertObjectHasProperty('phase', $workshop); $mysubmission = $writer->get_data([ get_string('mysubmission', 'mod_workshop'), diff --git a/question/bank/columnsortorder/tests/column_manager_test.php b/question/bank/columnsortorder/tests/column_manager_test.php index bff2d5df975ed..cdcb8685cd9ed 100644 --- a/question/bank/columnsortorder/tests/column_manager_test.php +++ b/question/bank/columnsortorder/tests/column_manager_test.php @@ -226,9 +226,9 @@ public function test_getcolumns_function(): void { $questionlistcolumns = $columnmanager->get_columns(); $this->assertIsArray($questionlistcolumns); foreach ($questionlistcolumns as $columnnobject) { - $this->assertObjectHasAttribute('class', $columnnobject); - $this->assertObjectHasAttribute('name', $columnnobject); - $this->assertObjectHasAttribute('colname', $columnnobject); + $this->assertObjectHasProperty('class', $columnnobject); + $this->assertObjectHasProperty('name', $columnnobject); + $this->assertObjectHasProperty('colname', $columnnobject); } } diff --git a/question/tests/externallib_test.php b/question/tests/externallib_test.php index 7776f84d0bc23..c34ddc2d85760 100644 --- a/question/tests/externallib_test.php +++ b/question/tests/externallib_test.php @@ -356,10 +356,10 @@ public function test_get_random_question_summaries_formats_returned_questions() $this->assertEquals($expected->qtype, $actual->qtype); // These values are added by the formatting. It doesn't matter what the // exact values are just that they are returned. - $this->assertObjectHasAttribute('icon', $actual); - $this->assertObjectHasAttribute('key', $actual->icon); - $this->assertObjectHasAttribute('component', $actual->icon); - $this->assertObjectHasAttribute('alttext', $actual->icon); + $this->assertObjectHasProperty('icon', $actual); + $this->assertObjectHasProperty('key', $actual->icon); + $this->assertObjectHasProperty('component', $actual->icon); + $this->assertObjectHasProperty('alttext', $actual->icon); } /** diff --git a/question/type/match/tests/question_type_test.php b/question/type/match/tests/question_type_test.php index da3b51514cdf5..792011f21ef2f 100644 --- a/question/type/match/tests/question_type_test.php +++ b/question/type/match/tests/question_type_test.php @@ -201,7 +201,7 @@ public function test_question_saving_foursubq() { } } - $this->assertObjectHasAttribute('subquestions', $actualquestiondata->options); + $this->assertObjectHasProperty('subquestions', $actualquestiondata->options); $subqpropstoignore = array('id'); foreach ($questiondata->options->subquestions as $subq) { diff --git a/question/type/multianswer/tests/question_type_test.php b/question/type/multianswer/tests/question_type_test.php index fe36a8b0a3ac9..49a924b4f94c7 100644 --- a/question/type/multianswer/tests/question_type_test.php +++ b/question/type/multianswer/tests/question_type_test.php @@ -313,7 +313,7 @@ public function test_question_saving_twosubq() { } } - $this->assertObjectHasAttribute('questions', $actualquestiondata->options); + $this->assertObjectHasProperty('questions', $actualquestiondata->options); $subqpropstoignore = ['id', 'category', 'parent', 'contextid', 'question', 'options', 'stamp', 'timemodified', diff --git a/reportbuilder/tests/external/custom_report_details_exporter_test.php b/reportbuilder/tests/external/custom_report_details_exporter_test.php index 25b74f34a0fd1..4e143003e8b68 100644 --- a/reportbuilder/tests/external/custom_report_details_exporter_test.php +++ b/reportbuilder/tests/external/custom_report_details_exporter_test.php @@ -58,7 +58,7 @@ public function test_export(): void { $this->assertEquals(users::get_name(), $export->sourcename); // We use the user exporter for the modifier of the report. - $this->assertObjectHasAttribute('modifiedby', $export); + $this->assertObjectHasProperty('modifiedby', $export); $this->assertEquals(fullname($user), $export->modifiedby->fullname); } } diff --git a/reportbuilder/tests/external/custom_report_exporter_test.php b/reportbuilder/tests/external/custom_report_exporter_test.php index 297db31ddb07a..6256826a7dda5 100644 --- a/reportbuilder/tests/external/custom_report_exporter_test.php +++ b/reportbuilder/tests/external/custom_report_exporter_test.php @@ -98,11 +98,11 @@ public function test_export_viewing(): void { ], $export->attributes); // The following are all generated by additional exporters, and should not be present when not editing. - $this->assertObjectNotHasAttribute('sidebarmenucards', $export); - $this->assertObjectNotHasAttribute('conditions', $export); - $this->assertObjectNotHasAttribute('filters', $export); - $this->assertObjectNotHasAttribute('sorting', $export); - $this->assertObjectNotHasAttribute('cardview', $export); + $this->assertObjectNotHasProperty('sidebarmenucards', $export); + $this->assertObjectNotHasProperty('conditions', $export); + $this->assertObjectNotHasProperty('filters', $export); + $this->assertObjectNotHasProperty('sorting', $export); + $this->assertObjectNotHasProperty('cardview', $export); } /** diff --git a/theme/boost/amd/build/aria.min.js b/theme/boost/amd/build/aria.min.js index 677391beb29df..f742bd778b373 100644 --- a/theme/boost/amd/build/aria.min.js +++ b/theme/boost/amd/build/aria.min.js @@ -1,10 +1,10 @@ -define("theme_boost/aria",["exports","jquery","core/pending"],(function(_exports,_jquery,_pending){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} +define("theme_boost/aria",["exports","jquery","core/pending","core/local/aria/focuslock"],(function(_exports,_jquery,_pending,FocusLockManager){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)}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} /** * Enhancements to Bootstrap components for accessibility. * * @module theme_boost/aria * @copyright 2018 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_jquery=_interopRequireDefault(_jquery),_pending=_interopRequireDefault(_pending);const dropdownFix=()=>{let focusEnd=!1;const setFocusEnd=function(){let end=!(arguments.length>0&&void 0!==arguments[0])||arguments[0];focusEnd=end},shiftFocus=function(element){let focusCheck=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null;const pendingPromise=new _pending.default("core/aria:delayed-focus");setTimeout((()=>{focusCheck&&!focusCheck()||element.focus(),pendingPromise.resolve()}),50)},handleMenuButton=e=>{const trigger=e.key;let fixFocus=!1;if(" "!==trigger&&"Enter"!==trigger||(fixFocus=!0,e.preventDefault(),e.target.click()),"ArrowUp"!==trigger&&"ArrowDown"!==trigger||(fixFocus=!0),!fixFocus)return;const menu=e.target.parentElement.querySelector('[role="menu"]');let menuItems=!1,foundMenuItem=!1,textInput=!1;menu&&(menuItems=menu.querySelectorAll('[role="menuitem"]'),textInput=e.target.parentElement.querySelector('[data-action="search"]')),menuItems&&menuItems.length>0&&("ArrowUp"===trigger?setFocusEnd():setFocusEnd(!1),foundMenuItem=(()=>{const result=focusEnd;return focusEnd=!1,result})()?menuItems[menuItems.length-1]:menuItems[0]),textInput&&shiftFocus(textInput),foundMenuItem&&null===textInput&&shiftFocus(foundMenuItem)};document.addEventListener("keypress",(e=>{if(e.target.matches('.dropdown [role="menu"] [role="menuitem"]')){const menu=e.target.closest('[role="menu"]');if(!menu)return;const menuItems=menu.querySelectorAll('[role="menuitem"]');if(!menuItems)return;const trigger=e.key.toLowerCase();for(let i=0;i{if(e.target.matches('[data-toggle="dropdown"]')&&handleMenuButton(e),e.target.matches('.dropdown [role="menu"] [role="menuitem"]')){const trigger=e.key;let next=!1;const menu=e.target.closest('[role="menu"]');if(!menu)return;const menuItems=menu.querySelectorAll('[role="menuitem"]');if(!menuItems)return;if("ArrowDown"==trigger){for(let i=0;i{const trigger=e.target.querySelector('[data-toggle="dropdown"]'),focused=document.activeElement!=document.body?document.activeElement:null;trigger&&focused&&e.target.contains(focused)&&shiftFocus(trigger,(()=>document.activeElement===document.body||e.target.contains(document.activeElement)))}))},tabElementFix=()=>{document.addEventListener("keydown",(e=>{["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End"].includes(e.key)&&e.target.matches('[role="tablist"] [role="tab"]')&&(e=>{const tabList=e.target.closest('[role="tablist"]'),vertical="vertical"==tabList.getAttribute("aria-orientation"),rtl=window.right_to_left(),arrowNext=vertical?"ArrowDown":rtl?"ArrowLeft":"ArrowRight",arrowPrevious=vertical?"ArrowUp":rtl?"ArrowRight":"ArrowLeft",tabs=Array.prototype.filter.call(tabList.querySelectorAll('[role="tab"]'),(tab=>!!tab.offsetHeight));for(let i=0;i{if(e.target.matches('[role="tablist"] [data-toggle="tab"], [role="tablist"] [data-toggle="pill"]')){const tabs=e.target.closest('[role="tablist"]').querySelectorAll('[data-toggle="tab"], [data-toggle="pill"]');e.preventDefault(),(0,_jquery.default)(e.target).tab("show"),tabs.forEach((tab=>{tab.tabIndex=-1})),e.target.tabIndex=0}}))};_exports.init=()=>{dropdownFix(),(()=>{(0,_jquery.default)(document).on("show.bs.dropdown",(e=>{if(e.relatedTarget.matches('[role="combobox"]')){const combobox=e.relatedTarget,listbox=document.querySelector("#".concat(combobox.getAttribute("aria-controls"),'[role="listbox"]'));if(listbox){const selectedOption=listbox.querySelector('[role="option"][aria-selected="true"]');setTimeout((()=>{if(selectedOption)selectedOption.classList.add("active"),combobox.setAttribute("aria-activedescendant",selectedOption.id);else{const firstOption=listbox.querySelector('[role="option"]');firstOption.setAttribute("aria-selected","true"),firstOption.classList.add("active"),combobox.setAttribute("aria-activedescendant",firstOption.id)}}),0)}}})),(0,_jquery.default)(document).on("hidden.bs.dropdown",(e=>{if(e.relatedTarget.matches('[role="combobox"]')){const combobox=e.relatedTarget,listbox=document.querySelector("#".concat(combobox.getAttribute("aria-controls"),'[role="listbox"]'));combobox.removeAttribute("aria-activedescendant"),listbox&&setTimeout((()=>{listbox.querySelectorAll('.active[role="option"]').forEach((option=>{option.classList.remove("active")}))}),0)}})),document.addEventListener("keydown",(e=>{if(e.target.matches('[role="combobox"][aria-controls]:not([aria-haspopup=dialog])')){const combobox=e.target,trigger=e.key;let next=null;const listbox=document.querySelector("#".concat(combobox.getAttribute("aria-controls"),'[role="listbox"]')),options=listbox.querySelectorAll('[role="option"]'),activeOption=listbox.querySelector('.active[role="option"]'),editable=combobox.hasAttribute("aria-autocomplete");if(options&&(activeOption||editable)){if("ArrowDown"==trigger){for(let i=0;i{const option=e.target.closest('[role="listbox"] [role="option"]');if(option){const listbox=option.closest('[role="listbox"]'),combobox=document.querySelector('[role="combobox"][aria-controls="'.concat(listbox.id,'"]'));combobox&&(combobox.focus(),selectOption(combobox,option))}})),document.addEventListener("change",(e=>{if(e.target.matches('input[type="hidden"][id]')){const combobox=document.querySelector('[role="combobox"][data-input-element="'.concat(e.target.id,'"]')),option=e.target.parentElement.querySelector('[role="option"][data-value="'.concat(e.target.value,'"]'));combobox&&option&&selectOption(combobox,option)}}));const selectOption=(combobox,option)=>{const oldSelectedOption=option.closest('[role="listbox"]').querySelector('[role="option"][aria-selected="true"]');if(oldSelectedOption!=option&&(oldSelectedOption&&oldSelectedOption.removeAttribute("aria-selected"),option.setAttribute("aria-selected","true")),combobox.hasAttribute("value")?combobox.value=option.textContent.replace(/[\n\r]+|[\s]{2,}/g," ").trim():combobox.textContent=option.textContent,combobox.dataset.inputElement){const inputElement=document.getElementById(combobox.dataset.inputElement);inputElement&&inputElement.value!=option.dataset.value&&(inputElement.value=option.dataset.value,inputElement.dispatchEvent(new Event("change",{bubbles:!0})))}}})(),window.addEventListener("load",(()=>{const alerts=document.querySelectorAll('[data-aria-autofocus="true"][role="alert"]');Array.prototype.forEach.call(alerts,(autofocusElement=>{autofocusElement.innerHTML+=" ",autofocusElement.removeAttribute("data-aria-autofocus")}))})),tabElementFix(),document.addEventListener("keydown",(e=>{e.target.matches('[data-toggle="collapse"]')&&" "===e.key&&(e.preventDefault(),e.target.click())}))}})); + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_jquery=_interopRequireDefault(_jquery),_pending=_interopRequireDefault(_pending),FocusLockManager=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}(FocusLockManager);const dropdownFix=()=>{let focusEnd=!1;const setFocusEnd=function(){let end=!(arguments.length>0&&void 0!==arguments[0])||arguments[0];focusEnd=end},shiftFocus=function(element){let focusCheck=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null;const pendingPromise=new _pending.default("core/aria:delayed-focus");setTimeout((()=>{focusCheck&&!focusCheck()||element.focus(),pendingPromise.resolve()}),50)},handleMenuButton=e=>{const trigger=e.key;let fixFocus=!1;if(" "!==trigger&&"Enter"!==trigger||(fixFocus=!0,e.preventDefault(),e.target.click()),"ArrowUp"!==trigger&&"ArrowDown"!==trigger||(fixFocus=!0),!fixFocus)return;const menu=e.target.parentElement.querySelector('[role="menu"]');let menuItems=!1,foundMenuItem=!1;menu&&(menuItems=menu.querySelectorAll('[role="menuitem"]')),menuItems&&menuItems.length>0&&("ArrowUp"===trigger?setFocusEnd():setFocusEnd(!1),foundMenuItem=(()=>{const result=focusEnd;return focusEnd=!1,result})()?menuItems[menuItems.length-1]:menuItems[0]),foundMenuItem&&shiftFocus(foundMenuItem)};document.addEventListener("keypress",(e=>{if(e.target.matches('.dropdown [role="menu"] [role="menuitem"]')){const menu=e.target.closest('[role="menu"]');if(!menu)return;const menuItems=menu.querySelectorAll('[role="menuitem"]');if(!menuItems)return;const trigger=e.key.toLowerCase();for(let i=0;i{if(e.target.matches('[data-toggle="dropdown"]')&&handleMenuButton(e),e.target.matches('.dropdown [role="menu"] [role="menuitem"]')){const trigger=e.key;let next=!1;const menu=e.target.closest('[role="menu"]');if(!menu)return;const menuItems=menu.querySelectorAll('[role="menuitem"]');if(!menuItems)return;if("ArrowDown"==trigger){for(let i=0;i{const dialog=e.target.querySelector("#".concat(e.relatedTarget.getAttribute("aria-controls"),'[role="dialog"]'));dialog&&setTimeout((()=>{FocusLockManager.trapFocus(dialog)}))})),(0,_jquery.default)(".dropdown").on("hidden.bs.dropdown",(e=>{var _e$clickEvent;e.target.querySelector("#".concat(e.relatedTarget.getAttribute("aria-controls"),'[role="dialog"]'))&&FocusLockManager.untrapFocus();const trigger=e.target.querySelector('[data-toggle="dropdown"]'),focused=(null===(_e$clickEvent=e.clickEvent)||void 0===_e$clickEvent?void 0:_e$clickEvent.target)||(document.activeElement!==document.body?document.activeElement:null);trigger&&focused&&e.target.contains(focused)&&shiftFocus(trigger,(()=>document.activeElement===document.body||e.target.contains(document.activeElement)))}))},tabElementFix=()=>{document.addEventListener("keydown",(e=>{["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End"].includes(e.key)&&e.target.matches('[role="tablist"] [role="tab"]')&&(e=>{const tabList=e.target.closest('[role="tablist"]'),vertical="vertical"==tabList.getAttribute("aria-orientation"),rtl=window.right_to_left(),arrowNext=vertical?"ArrowDown":rtl?"ArrowLeft":"ArrowRight",arrowPrevious=vertical?"ArrowUp":rtl?"ArrowRight":"ArrowLeft",tabs=Array.prototype.filter.call(tabList.querySelectorAll('[role="tab"]'),(tab=>!!tab.offsetHeight));for(let i=0;i{if(e.target.matches('[role="tablist"] [data-toggle="tab"], [role="tablist"] [data-toggle="pill"]')){const tabs=e.target.closest('[role="tablist"]').querySelectorAll('[data-toggle="tab"], [data-toggle="pill"]');e.preventDefault(),(0,_jquery.default)(e.target).tab("show"),tabs.forEach((tab=>{tab.tabIndex=-1})),e.target.tabIndex=0}}))};_exports.init=()=>{dropdownFix(),(()=>{(0,_jquery.default)(document).on("show.bs.dropdown",(e=>{if(e.relatedTarget.matches('[role="combobox"]')){const combobox=e.relatedTarget,listbox=document.querySelector("#".concat(combobox.getAttribute("aria-controls"),'[role="listbox"]'));if(listbox){const selectedOption=listbox.querySelector('[role="option"][aria-selected="true"]');setTimeout((()=>{if(selectedOption)selectedOption.classList.add("active"),combobox.setAttribute("aria-activedescendant",selectedOption.id);else{const firstOption=listbox.querySelector('[role="option"]');firstOption.setAttribute("aria-selected","true"),firstOption.classList.add("active"),combobox.setAttribute("aria-activedescendant",firstOption.id)}}),0)}}})),(0,_jquery.default)(document).on("hidden.bs.dropdown",(e=>{if(e.relatedTarget.matches('[role="combobox"]')){const combobox=e.relatedTarget,listbox=document.querySelector("#".concat(combobox.getAttribute("aria-controls"),'[role="listbox"]'));combobox.removeAttribute("aria-activedescendant"),listbox&&setTimeout((()=>{listbox.querySelectorAll('.active[role="option"]').forEach((option=>{option.classList.remove("active")}))}),0)}})),document.addEventListener("keydown",(e=>{if(e.target.matches('[role="combobox"][aria-controls]:not([aria-haspopup=dialog])')){const combobox=e.target,trigger=e.key;let next=null;const listbox=document.querySelector("#".concat(combobox.getAttribute("aria-controls"),'[role="listbox"]')),options=listbox.querySelectorAll('[role="option"]'),activeOption=listbox.querySelector('.active[role="option"]'),editable=combobox.hasAttribute("aria-autocomplete");if(options&&(activeOption||editable)){if("ArrowDown"==trigger){for(let i=0;i{const option=e.target.closest('[role="listbox"] [role="option"]');if(option){const listbox=option.closest('[role="listbox"]'),combobox=document.querySelector('[role="combobox"][aria-controls="'.concat(listbox.id,'"]'));combobox&&selectOption(combobox,option)}})),document.addEventListener("change",(e=>{if(e.target.matches('input[type="hidden"][id]')){const combobox=document.querySelector('[role="combobox"][data-input-element="'.concat(e.target.id,'"]')),option=e.target.parentElement.querySelector('[role="option"][data-value="'.concat(e.target.value,'"]'));combobox&&option&&selectOption(combobox,option)}}));const selectOption=(combobox,option)=>{const oldSelectedOption=option.closest('[role="listbox"]').querySelector('[role="option"][aria-selected="true"]');if(oldSelectedOption!=option&&(oldSelectedOption&&oldSelectedOption.removeAttribute("aria-selected"),option.setAttribute("aria-selected","true")),combobox.hasAttribute("value")?combobox.value=option.dataset.shortText||option.textContent.replace(/[\n\r]+|[\s]{2,}/g," ").trim():combobox.textContent=option.dataset.shortText||option.textContent,combobox.dataset.inputElement){const inputElement=document.getElementById(combobox.dataset.inputElement);inputElement&&inputElement.value!=option.dataset.value&&(inputElement.value=option.dataset.value,inputElement.dispatchEvent(new Event("change",{bubbles:!0})))}}})(),window.addEventListener("load",(()=>{const alerts=document.querySelectorAll('[data-aria-autofocus="true"][role="alert"]');Array.prototype.forEach.call(alerts,(autofocusElement=>{autofocusElement.innerHTML+=" ",autofocusElement.removeAttribute("data-aria-autofocus")}))})),tabElementFix(),document.addEventListener("keydown",(e=>{e.target.matches('[data-toggle="collapse"]')&&" "===e.key&&(e.preventDefault(),e.target.click())}))}})); //# sourceMappingURL=aria.min.js.map \ No newline at end of file diff --git a/theme/boost/amd/build/aria.min.js.map b/theme/boost/amd/build/aria.min.js.map index 397c1eaff7e0c..e09ca507e69cc 100644 --- a/theme/boost/amd/build/aria.min.js.map +++ b/theme/boost/amd/build/aria.min.js.map @@ -1 +1 @@ -{"version":3,"file":"aria.min.js","sources":["../src/aria.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 * Enhancements to Bootstrap components for accessibility.\n *\n * @module theme_boost/aria\n * @copyright 2018 Damyon Wiese \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\nimport Pending from 'core/pending';\n\n/**\n * Drop downs from bootstrap don't support keyboard accessibility by default.\n */\nconst dropdownFix = () => {\n let focusEnd = false;\n const setFocusEnd = (end = true) => {\n focusEnd = end;\n };\n const getFocusEnd = () => {\n const result = focusEnd;\n focusEnd = false;\n return result;\n };\n\n // Special handling for navigation keys when menu is open.\n const shiftFocus = (element, focusCheck = null) => {\n const pendingPromise = new Pending('core/aria:delayed-focus');\n setTimeout(() => {\n if (!focusCheck || focusCheck()) {\n element.focus();\n }\n\n pendingPromise.resolve();\n }, 50);\n };\n\n // Event handling for the dropdown menu button.\n const handleMenuButton = e => {\n const trigger = e.key;\n let fixFocus = false;\n\n // Space key or Enter key opens the menu.\n if (trigger === ' ' || trigger === 'Enter') {\n fixFocus = true;\n // Cancel random scroll.\n e.preventDefault();\n // Open the menu instead.\n e.target.click();\n }\n\n // Up and Down keys also open the menu.\n if (trigger === 'ArrowUp' || trigger === 'ArrowDown') {\n fixFocus = true;\n }\n\n if (!fixFocus) {\n // No need to fix the focus. Return early.\n return;\n }\n\n // Fix the focus on the menu items when the menu is opened.\n const menu = e.target.parentElement.querySelector('[role=\"menu\"]');\n let menuItems = false;\n let foundMenuItem = false;\n let textInput = false;\n\n if (menu) {\n menuItems = menu.querySelectorAll('[role=\"menuitem\"]');\n textInput = e.target.parentElement.querySelector('[data-action=\"search\"]');\n }\n\n if (menuItems && menuItems.length > 0) {\n // Up key opens the menu at the end.\n if (trigger === 'ArrowUp') {\n setFocusEnd();\n } else {\n setFocusEnd(false);\n }\n\n if (getFocusEnd()) {\n foundMenuItem = menuItems[menuItems.length - 1];\n } else {\n // The first menu entry, pretty reasonable.\n foundMenuItem = menuItems[0];\n }\n }\n\n if (textInput) {\n shiftFocus(textInput);\n }\n if (foundMenuItem && textInput === null) {\n shiftFocus(foundMenuItem);\n }\n };\n\n // Search for menu items by finding the first item that has\n // text starting with the typed character (case insensitive).\n document.addEventListener('keypress', e => {\n if (e.target.matches('.dropdown [role=\"menu\"] [role=\"menuitem\"]')) {\n const menu = e.target.closest('[role=\"menu\"]');\n if (!menu) {\n return;\n }\n const menuItems = menu.querySelectorAll('[role=\"menuitem\"]');\n if (!menuItems) {\n return;\n }\n\n const trigger = e.key.toLowerCase();\n\n for (let i = 0; i < menuItems.length; i++) {\n const item = menuItems[i];\n const itemText = item.text.trim().toLowerCase();\n if (itemText.indexOf(trigger) == 0) {\n shiftFocus(item);\n break;\n }\n }\n }\n });\n\n // Keyboard navigation for arrow keys, home and end keys.\n document.addEventListener('keydown', e => {\n\n // We only want to set focus when users access the dropdown via keyboard as per\n // guidelines defined in w3 aria practices 1.1 menu-button.\n if (e.target.matches('[data-toggle=\"dropdown\"]')) {\n handleMenuButton(e);\n }\n\n if (e.target.matches('.dropdown [role=\"menu\"] [role=\"menuitem\"]')) {\n const trigger = e.key;\n let next = false;\n const menu = e.target.closest('[role=\"menu\"]');\n\n if (!menu) {\n return;\n }\n const menuItems = menu.querySelectorAll('[role=\"menuitem\"]');\n if (!menuItems) {\n return;\n }\n // Down key.\n if (trigger == 'ArrowDown') {\n for (let i = 0; i < menuItems.length - 1; i++) {\n if (menuItems[i] == e.target) {\n next = menuItems[i + 1];\n break;\n }\n }\n if (!next) {\n // Wrap to first item.\n next = menuItems[0];\n }\n } else if (trigger == 'ArrowUp') {\n // Up key.\n for (let i = 1; i < menuItems.length; i++) {\n if (menuItems[i] == e.target) {\n next = menuItems[i - 1];\n break;\n }\n }\n if (!next) {\n // Wrap to last item.\n next = menuItems[menuItems.length - 1];\n }\n } else if (trigger == 'Home') {\n // Home key.\n next = menuItems[0];\n\n } else if (trigger == 'End') {\n // End key.\n next = menuItems[menuItems.length - 1];\n }\n\n // Variable next is set if we do want to act on the keypress.\n if (next) {\n e.preventDefault();\n shiftFocus(next);\n }\n return;\n }\n });\n\n $('.dropdown').on('hidden.bs.dropdown', e => {\n // We need to focus on the menu trigger.\n const trigger = e.target.querySelector('[data-toggle=\"dropdown\"]');\n const focused = document.activeElement != document.body ? document.activeElement : null;\n if (trigger && focused && e.target.contains(focused)) {\n shiftFocus(trigger, () => {\n if (document.activeElement === document.body) {\n // If the focus is currently on the body, then we can safely assume that the focus needs to be updated.\n return true;\n }\n\n // If the focus is on a child of the clicked element still, then update the focus.\n return e.target.contains(document.activeElement);\n });\n }\n });\n};\n\n/**\n * A lot of Bootstrap's out of the box features don't work if dropdown items are not focusable.\n */\nconst comboboxFix = () => {\n $(document).on('show.bs.dropdown', e => {\n if (e.relatedTarget.matches('[role=\"combobox\"]')) {\n const combobox = e.relatedTarget;\n const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role=\"listbox\"]`);\n\n if (listbox) {\n const selectedOption = listbox.querySelector('[role=\"option\"][aria-selected=\"true\"]');\n\n // To make sure ArrowDown doesn't move the active option afterwards.\n setTimeout(() => {\n if (selectedOption) {\n selectedOption.classList.add('active');\n combobox.setAttribute('aria-activedescendant', selectedOption.id);\n } else {\n const firstOption = listbox.querySelector('[role=\"option\"]');\n firstOption.setAttribute('aria-selected', 'true');\n firstOption.classList.add('active');\n combobox.setAttribute('aria-activedescendant', firstOption.id);\n }\n }, 0);\n }\n }\n });\n\n $(document).on('hidden.bs.dropdown', e => {\n if (e.relatedTarget.matches('[role=\"combobox\"]')) {\n const combobox = e.relatedTarget;\n const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role=\"listbox\"]`);\n\n combobox.removeAttribute('aria-activedescendant');\n\n if (listbox) {\n setTimeout(() => {\n // Undo all previously highlighted options.\n listbox.querySelectorAll('.active[role=\"option\"]').forEach(option => {\n option.classList.remove('active');\n });\n }, 0);\n }\n }\n });\n\n // Handling keyboard events for both navigating through and selecting options.\n document.addEventListener('keydown', e => {\n if (e.target.matches('[role=\"combobox\"][aria-controls]:not([aria-haspopup=dialog])')) {\n const combobox = e.target;\n const trigger = e.key;\n let next = null;\n const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role=\"listbox\"]`);\n const options = listbox.querySelectorAll('[role=\"option\"]');\n const activeOption = listbox.querySelector('.active[role=\"option\"]');\n const editable = combobox.hasAttribute('aria-autocomplete');\n\n // Under the special case that the dropdown menu is being shown as a result of the key press (like when the user\n // presses ArrowDown or Enter or ... to open the dropdown menu), activeOption is not set yet.\n // It's because of a race condition with show.bs.dropdown event handler.\n if (options && (activeOption || editable)) {\n if (trigger == 'ArrowDown') {\n for (let i = 0; i < options.length - 1; i++) {\n if (options[i] == activeOption) {\n next = options[i + 1];\n break;\n }\n }\n if (editable && !next) {\n next = options[0];\n }\n } if (trigger == 'ArrowUp') {\n for (let i = 1; i < options.length; i++) {\n if (options[i] == activeOption) {\n next = options[i - 1];\n break;\n }\n }\n if (editable && !next) {\n next = options[options.length - 1];\n }\n } else if (trigger == 'Home') {\n next = options[0];\n } else if (trigger == 'End') {\n next = options[options.length - 1];\n } else if ((trigger == ' ' && !editable) || trigger == 'Enter') {\n e.preventDefault();\n selectOption(combobox, activeOption);\n } else if (!editable) {\n // Search for options by finding the first option that has\n // text starting with the typed character (case insensitive).\n for (let i = 0; i < options.length; i++) {\n const option = options[i];\n const optionText = option.textContent.trim().toLowerCase();\n const keyPressed = e.key.toLowerCase();\n if (optionText.indexOf(keyPressed) == 0) {\n next = option;\n break;\n }\n }\n }\n\n // Variable next is set if we do want to act on the keypress.\n if (next) {\n e.preventDefault();\n if (activeOption) {\n activeOption.classList.remove('active');\n }\n next.classList.add('active');\n combobox.setAttribute('aria-activedescendant', next.id);\n next.scrollIntoView({block: 'nearest'});\n }\n }\n }\n });\n\n document.addEventListener('click', e => {\n const option = e.target.closest('[role=\"listbox\"] [role=\"option\"]');\n if (option) {\n const listbox = option.closest('[role=\"listbox\"]');\n const combobox = document.querySelector(`[role=\"combobox\"][aria-controls=\"${listbox.id}\"]`);\n if (combobox) {\n combobox.focus();\n selectOption(combobox, option);\n }\n }\n });\n\n // In case some code somewhere else changes the value of the combobox.\n document.addEventListener('change', e => {\n if (e.target.matches('input[type=\"hidden\"][id]')) {\n const combobox = document.querySelector(`[role=\"combobox\"][data-input-element=\"${e.target.id}\"]`);\n const option = e.target.parentElement.querySelector(`[role=\"option\"][data-value=\"${e.target.value}\"]`);\n\n if (combobox && option) {\n selectOption(combobox, option);\n }\n }\n });\n\n const selectOption = (combobox, option) => {\n const listbox = option.closest('[role=\"listbox\"]');\n const oldSelectedOption = listbox.querySelector('[role=\"option\"][aria-selected=\"true\"]');\n\n if (oldSelectedOption != option) {\n if (oldSelectedOption) {\n oldSelectedOption.removeAttribute('aria-selected');\n }\n option.setAttribute('aria-selected', 'true');\n }\n\n if (combobox.hasAttribute('value')) {\n combobox.value = option.textContent.replace(/[\\n\\r]+|[\\s]{2,}/g, ' ').trim();\n } else {\n combobox.textContent = option.textContent;\n }\n\n if (combobox.dataset.inputElement) {\n const inputElement = document.getElementById(combobox.dataset.inputElement);\n if (inputElement && (inputElement.value != option.dataset.value)) {\n inputElement.value = option.dataset.value;\n inputElement.dispatchEvent(new Event('change', {bubbles: true}));\n }\n }\n };\n};\n\n/**\n * After page load, focus on any element with special autofocus attribute.\n */\nconst autoFocus = () => {\n window.addEventListener(\"load\", () => {\n const alerts = document.querySelectorAll('[data-aria-autofocus=\"true\"][role=\"alert\"]');\n Array.prototype.forEach.call(alerts, autofocusElement => {\n // According to the specification an role=\"alert\" region is only read out on change to the content\n // of that region.\n autofocusElement.innerHTML += ' ';\n autofocusElement.removeAttribute('data-aria-autofocus');\n });\n });\n};\n\n/**\n * Changes the focus to the correct tab based on the key that is pressed.\n * @param {KeyboardEvent} e\n */\nconst updateTabFocus = e => {\n const tabList = e.target.closest('[role=\"tablist\"]');\n const vertical = tabList.getAttribute('aria-orientation') == 'vertical';\n const rtl = window.right_to_left();\n const arrowNext = vertical ? 'ArrowDown' : (rtl ? 'ArrowLeft' : 'ArrowRight');\n const arrowPrevious = vertical ? 'ArrowUp' : (rtl ? 'ArrowRight' : 'ArrowLeft');\n const tabs = Array.prototype.filter.call(\n tabList.querySelectorAll('[role=\"tab\"]'),\n tab => !!tab.offsetHeight); // We only work with the visible tabs.\n\n for (let i = 0; i < tabs.length; i++) {\n tabs[i].index = i;\n }\n\n switch (e.key) {\n case arrowNext:\n e.preventDefault();\n if (e.target.index !== undefined && tabs[e.target.index + 1]) {\n tabs[e.target.index + 1].focus();\n } else {\n tabs[0].focus();\n }\n break;\n case arrowPrevious:\n e.preventDefault();\n if (e.target.index !== undefined && tabs[e.target.index - 1]) {\n tabs[e.target.index - 1].focus();\n } else {\n tabs[tabs.length - 1].focus();\n }\n break;\n case 'Home':\n e.preventDefault();\n tabs[0].focus();\n break;\n case 'End':\n e.preventDefault();\n tabs[tabs.length - 1].focus();\n }\n};\n\n/**\n * Fix accessibility issues regarding tab elements focus and their tab order in Bootstrap navs.\n */\nconst tabElementFix = () => {\n document.addEventListener('keydown', e => {\n if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {\n if (e.target.matches('[role=\"tablist\"] [role=\"tab\"]')) {\n updateTabFocus(e);\n }\n }\n });\n\n document.addEventListener('click', e => {\n if (e.target.matches('[role=\"tablist\"] [data-toggle=\"tab\"], [role=\"tablist\"] [data-toggle=\"pill\"]')) {\n const tabs = e.target.closest('[role=\"tablist\"]').querySelectorAll('[data-toggle=\"tab\"], [data-toggle=\"pill\"]');\n e.preventDefault();\n $(e.target).tab('show');\n tabs.forEach(tab => {\n tab.tabIndex = -1;\n });\n e.target.tabIndex = 0;\n }\n });\n};\n\n/**\n * Fix keyboard interaction with Bootstrap Collapse elements.\n *\n * @see {@link https://www.w3.org/TR/wai-aria-practices-1.1/#disclosure|WAI-ARIA Authoring Practices 1.1 - Disclosure (Show/Hide)}\n */\nconst collapseFix = () => {\n document.addEventListener('keydown', e => {\n if (e.target.matches('[data-toggle=\"collapse\"]')) {\n // Pressing space should toggle expand/collapse.\n if (e.key === ' ') {\n e.preventDefault();\n e.target.click();\n }\n }\n });\n};\n\nexport const init = () => {\n dropdownFix();\n comboboxFix();\n autoFocus();\n tabElementFix();\n collapseFix();\n};\n"],"names":["dropdownFix","focusEnd","setFocusEnd","end","shiftFocus","element","focusCheck","pendingPromise","Pending","setTimeout","focus","resolve","handleMenuButton","e","trigger","key","fixFocus","preventDefault","target","click","menu","parentElement","querySelector","menuItems","foundMenuItem","textInput","querySelectorAll","length","result","getFocusEnd","document","addEventListener","matches","closest","toLowerCase","i","item","text","trim","indexOf","next","on","focused","activeElement","body","contains","tabElementFix","includes","tabList","vertical","getAttribute","rtl","window","right_to_left","arrowNext","arrowPrevious","tabs","Array","prototype","filter","call","tab","offsetHeight","index","undefined","updateTabFocus","forEach","tabIndex","relatedTarget","combobox","listbox","selectedOption","classList","add","setAttribute","id","firstOption","removeAttribute","option","remove","options","activeOption","editable","hasAttribute","selectOption","optionText","textContent","keyPressed","scrollIntoView","block","value","oldSelectedOption","replace","dataset","inputElement","getElementById","dispatchEvent","Event","bubbles","comboboxFix","alerts","autofocusElement","innerHTML"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"aria.min.js","sources":["../src/aria.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 * Enhancements to Bootstrap components for accessibility.\n *\n * @module theme_boost/aria\n * @copyright 2018 Damyon Wiese \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\nimport Pending from 'core/pending';\nimport * as FocusLockManager from 'core/local/aria/focuslock';\n\n/**\n * Drop downs from bootstrap don't support keyboard accessibility by default.\n */\nconst dropdownFix = () => {\n let focusEnd = false;\n const setFocusEnd = (end = true) => {\n focusEnd = end;\n };\n const getFocusEnd = () => {\n const result = focusEnd;\n focusEnd = false;\n return result;\n };\n\n // Special handling for navigation keys when menu is open.\n const shiftFocus = (element, focusCheck = null) => {\n const pendingPromise = new Pending('core/aria:delayed-focus');\n setTimeout(() => {\n if (!focusCheck || focusCheck()) {\n element.focus();\n }\n\n pendingPromise.resolve();\n }, 50);\n };\n\n // Event handling for the dropdown menu button.\n const handleMenuButton = e => {\n const trigger = e.key;\n let fixFocus = false;\n\n // Space key or Enter key opens the menu.\n if (trigger === ' ' || trigger === 'Enter') {\n fixFocus = true;\n // Cancel random scroll.\n e.preventDefault();\n // Open the menu instead.\n e.target.click();\n }\n\n // Up and Down keys also open the menu.\n if (trigger === 'ArrowUp' || trigger === 'ArrowDown') {\n fixFocus = true;\n }\n\n if (!fixFocus) {\n // No need to fix the focus. Return early.\n return;\n }\n\n // Fix the focus on the menu items when the menu is opened.\n const menu = e.target.parentElement.querySelector('[role=\"menu\"]');\n let menuItems = false;\n let foundMenuItem = false;\n\n if (menu) {\n menuItems = menu.querySelectorAll('[role=\"menuitem\"]');\n }\n if (menuItems && menuItems.length > 0) {\n // Up key opens the menu at the end.\n if (trigger === 'ArrowUp') {\n setFocusEnd();\n } else {\n setFocusEnd(false);\n }\n\n if (getFocusEnd()) {\n foundMenuItem = menuItems[menuItems.length - 1];\n } else {\n // The first menu entry, pretty reasonable.\n foundMenuItem = menuItems[0];\n }\n }\n\n if (foundMenuItem) {\n shiftFocus(foundMenuItem);\n }\n };\n\n // Search for menu items by finding the first item that has\n // text starting with the typed character (case insensitive).\n document.addEventListener('keypress', e => {\n if (e.target.matches('.dropdown [role=\"menu\"] [role=\"menuitem\"]')) {\n const menu = e.target.closest('[role=\"menu\"]');\n if (!menu) {\n return;\n }\n const menuItems = menu.querySelectorAll('[role=\"menuitem\"]');\n if (!menuItems) {\n return;\n }\n\n const trigger = e.key.toLowerCase();\n\n for (let i = 0; i < menuItems.length; i++) {\n const item = menuItems[i];\n const itemText = item.text.trim().toLowerCase();\n if (itemText.indexOf(trigger) == 0) {\n shiftFocus(item);\n break;\n }\n }\n }\n });\n\n // Keyboard navigation for arrow keys, home and end keys.\n document.addEventListener('keydown', e => {\n\n // We only want to set focus when users access the dropdown via keyboard as per\n // guidelines defined in w3 aria practices 1.1 menu-button.\n if (e.target.matches('[data-toggle=\"dropdown\"]')) {\n handleMenuButton(e);\n }\n\n if (e.target.matches('.dropdown [role=\"menu\"] [role=\"menuitem\"]')) {\n const trigger = e.key;\n let next = false;\n const menu = e.target.closest('[role=\"menu\"]');\n\n if (!menu) {\n return;\n }\n const menuItems = menu.querySelectorAll('[role=\"menuitem\"]');\n if (!menuItems) {\n return;\n }\n // Down key.\n if (trigger == 'ArrowDown') {\n for (let i = 0; i < menuItems.length - 1; i++) {\n if (menuItems[i] == e.target) {\n next = menuItems[i + 1];\n break;\n }\n }\n if (!next) {\n // Wrap to first item.\n next = menuItems[0];\n }\n } else if (trigger == 'ArrowUp') {\n // Up key.\n for (let i = 1; i < menuItems.length; i++) {\n if (menuItems[i] == e.target) {\n next = menuItems[i - 1];\n break;\n }\n }\n if (!next) {\n // Wrap to last item.\n next = menuItems[menuItems.length - 1];\n }\n } else if (trigger == 'Home') {\n // Home key.\n next = menuItems[0];\n\n } else if (trigger == 'End') {\n // End key.\n next = menuItems[menuItems.length - 1];\n }\n\n // Variable next is set if we do want to act on the keypress.\n if (next) {\n e.preventDefault();\n shiftFocus(next);\n }\n return;\n }\n });\n\n $('.dropdown').on('shown.bs.dropdown', e => {\n const dialog = e.target.querySelector(`#${e.relatedTarget.getAttribute('aria-controls')}[role=\"dialog\"]`);\n if (dialog) {\n // Use setTimeout to make sure the dialog is positioned correctly to prevent random scrolling.\n setTimeout(() => {\n FocusLockManager.trapFocus(dialog);\n });\n }\n });\n\n $('.dropdown').on('hidden.bs.dropdown', e => {\n const dialog = e.target.querySelector(`#${e.relatedTarget.getAttribute('aria-controls')}[role=\"dialog\"]`);\n if (dialog) {\n FocusLockManager.untrapFocus();\n }\n\n // We need to focus on the menu trigger.\n const trigger = e.target.querySelector('[data-toggle=\"dropdown\"]');\n // If it's a click event, then no element is focused because the clicked element is inside a closed dropdown.\n const focused = e.clickEvent?.target || (document.activeElement !== document.body ? document.activeElement : null);\n if (trigger && focused && e.target.contains(focused)) {\n shiftFocus(trigger, () => {\n if (document.activeElement === document.body) {\n // If the focus is currently on the body, then we can safely assume that the focus needs to be updated.\n return true;\n }\n\n // If the focus is on a child of the clicked element still, then update the focus.\n return e.target.contains(document.activeElement);\n });\n }\n });\n};\n\n/**\n * A lot of Bootstrap's out of the box features don't work if dropdown items are not focusable.\n */\nconst comboboxFix = () => {\n $(document).on('show.bs.dropdown', e => {\n if (e.relatedTarget.matches('[role=\"combobox\"]')) {\n const combobox = e.relatedTarget;\n const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role=\"listbox\"]`);\n\n if (listbox) {\n const selectedOption = listbox.querySelector('[role=\"option\"][aria-selected=\"true\"]');\n\n // To make sure ArrowDown doesn't move the active option afterwards.\n setTimeout(() => {\n if (selectedOption) {\n selectedOption.classList.add('active');\n combobox.setAttribute('aria-activedescendant', selectedOption.id);\n } else {\n const firstOption = listbox.querySelector('[role=\"option\"]');\n firstOption.setAttribute('aria-selected', 'true');\n firstOption.classList.add('active');\n combobox.setAttribute('aria-activedescendant', firstOption.id);\n }\n }, 0);\n }\n }\n });\n\n $(document).on('hidden.bs.dropdown', e => {\n if (e.relatedTarget.matches('[role=\"combobox\"]')) {\n const combobox = e.relatedTarget;\n const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role=\"listbox\"]`);\n\n combobox.removeAttribute('aria-activedescendant');\n\n if (listbox) {\n setTimeout(() => {\n // Undo all previously highlighted options.\n listbox.querySelectorAll('.active[role=\"option\"]').forEach(option => {\n option.classList.remove('active');\n });\n }, 0);\n }\n }\n });\n\n // Handling keyboard events for both navigating through and selecting options.\n document.addEventListener('keydown', e => {\n if (e.target.matches('[role=\"combobox\"][aria-controls]:not([aria-haspopup=dialog])')) {\n const combobox = e.target;\n const trigger = e.key;\n let next = null;\n const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role=\"listbox\"]`);\n const options = listbox.querySelectorAll('[role=\"option\"]');\n const activeOption = listbox.querySelector('.active[role=\"option\"]');\n const editable = combobox.hasAttribute('aria-autocomplete');\n\n // Under the special case that the dropdown menu is being shown as a result of the key press (like when the user\n // presses ArrowDown or Enter or ... to open the dropdown menu), activeOption is not set yet.\n // It's because of a race condition with show.bs.dropdown event handler.\n if (options && (activeOption || editable)) {\n if (trigger == 'ArrowDown') {\n for (let i = 0; i < options.length - 1; i++) {\n if (options[i] == activeOption) {\n next = options[i + 1];\n break;\n }\n }\n if (editable && !next) {\n next = options[0];\n }\n } if (trigger == 'ArrowUp') {\n for (let i = 1; i < options.length; i++) {\n if (options[i] == activeOption) {\n next = options[i - 1];\n break;\n }\n }\n if (editable && !next) {\n next = options[options.length - 1];\n }\n } else if (trigger == 'Home' && !editable) {\n next = options[0];\n } else if (trigger == 'End' && !editable) {\n next = options[options.length - 1];\n } else if ((trigger == ' ' && !editable) || trigger == 'Enter') {\n e.preventDefault();\n selectOption(combobox, activeOption);\n } else if (!editable) {\n // Search for options by finding the first option that has\n // text starting with the typed character (case insensitive).\n for (let i = 0; i < options.length; i++) {\n const option = options[i];\n const optionText = option.textContent.trim().toLowerCase();\n const keyPressed = e.key.toLowerCase();\n if (optionText.indexOf(keyPressed) == 0) {\n next = option;\n break;\n }\n }\n }\n\n // Variable next is set if we do want to act on the keypress.\n if (next) {\n e.preventDefault();\n if (activeOption) {\n activeOption.classList.remove('active');\n }\n next.classList.add('active');\n combobox.setAttribute('aria-activedescendant', next.id);\n next.scrollIntoView({block: 'nearest'});\n }\n }\n }\n });\n\n document.addEventListener('click', e => {\n const option = e.target.closest('[role=\"listbox\"] [role=\"option\"]');\n if (option) {\n const listbox = option.closest('[role=\"listbox\"]');\n const combobox = document.querySelector(`[role=\"combobox\"][aria-controls=\"${listbox.id}\"]`);\n if (combobox) {\n selectOption(combobox, option);\n }\n }\n });\n\n // In case some code somewhere else changes the value of the combobox.\n document.addEventListener('change', e => {\n if (e.target.matches('input[type=\"hidden\"][id]')) {\n const combobox = document.querySelector(`[role=\"combobox\"][data-input-element=\"${e.target.id}\"]`);\n const option = e.target.parentElement.querySelector(`[role=\"option\"][data-value=\"${e.target.value}\"]`);\n\n if (combobox && option) {\n selectOption(combobox, option);\n }\n }\n });\n\n const selectOption = (combobox, option) => {\n const listbox = option.closest('[role=\"listbox\"]');\n const oldSelectedOption = listbox.querySelector('[role=\"option\"][aria-selected=\"true\"]');\n\n if (oldSelectedOption != option) {\n if (oldSelectedOption) {\n oldSelectedOption.removeAttribute('aria-selected');\n }\n option.setAttribute('aria-selected', 'true');\n }\n\n if (combobox.hasAttribute('value')) {\n combobox.value = option.dataset.shortText || option.textContent.replace(/[\\n\\r]+|[\\s]{2,}/g, ' ').trim();\n } else {\n combobox.textContent = option.dataset.shortText || option.textContent;\n }\n\n if (combobox.dataset.inputElement) {\n const inputElement = document.getElementById(combobox.dataset.inputElement);\n if (inputElement && (inputElement.value != option.dataset.value)) {\n inputElement.value = option.dataset.value;\n inputElement.dispatchEvent(new Event('change', {bubbles: true}));\n }\n }\n };\n};\n\n/**\n * After page load, focus on any element with special autofocus attribute.\n */\nconst autoFocus = () => {\n window.addEventListener(\"load\", () => {\n const alerts = document.querySelectorAll('[data-aria-autofocus=\"true\"][role=\"alert\"]');\n Array.prototype.forEach.call(alerts, autofocusElement => {\n // According to the specification an role=\"alert\" region is only read out on change to the content\n // of that region.\n autofocusElement.innerHTML += ' ';\n autofocusElement.removeAttribute('data-aria-autofocus');\n });\n });\n};\n\n/**\n * Changes the focus to the correct tab based on the key that is pressed.\n * @param {KeyboardEvent} e\n */\nconst updateTabFocus = e => {\n const tabList = e.target.closest('[role=\"tablist\"]');\n const vertical = tabList.getAttribute('aria-orientation') == 'vertical';\n const rtl = window.right_to_left();\n const arrowNext = vertical ? 'ArrowDown' : (rtl ? 'ArrowLeft' : 'ArrowRight');\n const arrowPrevious = vertical ? 'ArrowUp' : (rtl ? 'ArrowRight' : 'ArrowLeft');\n const tabs = Array.prototype.filter.call(\n tabList.querySelectorAll('[role=\"tab\"]'),\n tab => !!tab.offsetHeight); // We only work with the visible tabs.\n\n for (let i = 0; i < tabs.length; i++) {\n tabs[i].index = i;\n }\n\n switch (e.key) {\n case arrowNext:\n e.preventDefault();\n if (e.target.index !== undefined && tabs[e.target.index + 1]) {\n tabs[e.target.index + 1].focus();\n } else {\n tabs[0].focus();\n }\n break;\n case arrowPrevious:\n e.preventDefault();\n if (e.target.index !== undefined && tabs[e.target.index - 1]) {\n tabs[e.target.index - 1].focus();\n } else {\n tabs[tabs.length - 1].focus();\n }\n break;\n case 'Home':\n e.preventDefault();\n tabs[0].focus();\n break;\n case 'End':\n e.preventDefault();\n tabs[tabs.length - 1].focus();\n }\n};\n\n/**\n * Fix accessibility issues regarding tab elements focus and their tab order in Bootstrap navs.\n */\nconst tabElementFix = () => {\n document.addEventListener('keydown', e => {\n if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {\n if (e.target.matches('[role=\"tablist\"] [role=\"tab\"]')) {\n updateTabFocus(e);\n }\n }\n });\n\n document.addEventListener('click', e => {\n if (e.target.matches('[role=\"tablist\"] [data-toggle=\"tab\"], [role=\"tablist\"] [data-toggle=\"pill\"]')) {\n const tabs = e.target.closest('[role=\"tablist\"]').querySelectorAll('[data-toggle=\"tab\"], [data-toggle=\"pill\"]');\n e.preventDefault();\n $(e.target).tab('show');\n tabs.forEach(tab => {\n tab.tabIndex = -1;\n });\n e.target.tabIndex = 0;\n }\n });\n};\n\n/**\n * Fix keyboard interaction with Bootstrap Collapse elements.\n *\n * @see {@link https://www.w3.org/TR/wai-aria-practices-1.1/#disclosure|WAI-ARIA Authoring Practices 1.1 - Disclosure (Show/Hide)}\n */\nconst collapseFix = () => {\n document.addEventListener('keydown', e => {\n if (e.target.matches('[data-toggle=\"collapse\"]')) {\n // Pressing space should toggle expand/collapse.\n if (e.key === ' ') {\n e.preventDefault();\n e.target.click();\n }\n }\n });\n};\n\nexport const init = () => {\n dropdownFix();\n comboboxFix();\n autoFocus();\n tabElementFix();\n collapseFix();\n};\n"],"names":["dropdownFix","focusEnd","setFocusEnd","end","shiftFocus","element","focusCheck","pendingPromise","Pending","setTimeout","focus","resolve","handleMenuButton","e","trigger","key","fixFocus","preventDefault","target","click","menu","parentElement","querySelector","menuItems","foundMenuItem","querySelectorAll","length","result","getFocusEnd","document","addEventListener","matches","closest","toLowerCase","i","item","text","trim","indexOf","next","on","dialog","relatedTarget","getAttribute","FocusLockManager","trapFocus","untrapFocus","focused","clickEvent","activeElement","body","contains","tabElementFix","includes","tabList","vertical","rtl","window","right_to_left","arrowNext","arrowPrevious","tabs","Array","prototype","filter","call","tab","offsetHeight","index","undefined","updateTabFocus","forEach","tabIndex","combobox","listbox","selectedOption","classList","add","setAttribute","id","firstOption","removeAttribute","option","remove","options","activeOption","editable","hasAttribute","selectOption","optionText","textContent","keyPressed","scrollIntoView","block","value","oldSelectedOption","dataset","shortText","replace","inputElement","getElementById","dispatchEvent","Event","bubbles","comboboxFix","alerts","autofocusElement","innerHTML"],"mappings":"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"} \ No newline at end of file diff --git a/theme/boost/amd/src/aria.js b/theme/boost/amd/src/aria.js index 6793b0fb44ad5..0b42157cde2c0 100644 --- a/theme/boost/amd/src/aria.js +++ b/theme/boost/amd/src/aria.js @@ -23,6 +23,7 @@ import $ from 'jquery'; import Pending from 'core/pending'; +import * as FocusLockManager from 'core/local/aria/focuslock'; /** * Drop downs from bootstrap don't support keyboard accessibility by default. @@ -78,13 +79,10 @@ const dropdownFix = () => { const menu = e.target.parentElement.querySelector('[role="menu"]'); let menuItems = false; let foundMenuItem = false; - let textInput = false; if (menu) { menuItems = menu.querySelectorAll('[role="menuitem"]'); - textInput = e.target.parentElement.querySelector('[data-action="search"]'); } - if (menuItems && menuItems.length > 0) { // Up key opens the menu at the end. if (trigger === 'ArrowUp') { @@ -101,10 +99,7 @@ const dropdownFix = () => { } } - if (textInput) { - shiftFocus(textInput); - } - if (foundMenuItem && textInput === null) { + if (foundMenuItem) { shiftFocus(foundMenuItem); } }; @@ -198,10 +193,26 @@ const dropdownFix = () => { } }); + $('.dropdown').on('shown.bs.dropdown', e => { + const dialog = e.target.querySelector(`#${e.relatedTarget.getAttribute('aria-controls')}[role="dialog"]`); + if (dialog) { + // Use setTimeout to make sure the dialog is positioned correctly to prevent random scrolling. + setTimeout(() => { + FocusLockManager.trapFocus(dialog); + }); + } + }); + $('.dropdown').on('hidden.bs.dropdown', e => { + const dialog = e.target.querySelector(`#${e.relatedTarget.getAttribute('aria-controls')}[role="dialog"]`); + if (dialog) { + FocusLockManager.untrapFocus(); + } + // We need to focus on the menu trigger. const trigger = e.target.querySelector('[data-toggle="dropdown"]'); - const focused = document.activeElement != document.body ? document.activeElement : null; + // If it's a click event, then no element is focused because the clicked element is inside a closed dropdown. + const focused = e.clickEvent?.target || (document.activeElement !== document.body ? document.activeElement : null); if (trigger && focused && e.target.contains(focused)) { shiftFocus(trigger, () => { if (document.activeElement === document.body) { @@ -297,9 +308,9 @@ const comboboxFix = () => { if (editable && !next) { next = options[options.length - 1]; } - } else if (trigger == 'Home') { + } else if (trigger == 'Home' && !editable) { next = options[0]; - } else if (trigger == 'End') { + } else if (trigger == 'End' && !editable) { next = options[options.length - 1]; } else if ((trigger == ' ' && !editable) || trigger == 'Enter') { e.preventDefault(); @@ -338,7 +349,6 @@ const comboboxFix = () => { const listbox = option.closest('[role="listbox"]'); const combobox = document.querySelector(`[role="combobox"][aria-controls="${listbox.id}"]`); if (combobox) { - combobox.focus(); selectOption(combobox, option); } } @@ -368,9 +378,9 @@ const comboboxFix = () => { } if (combobox.hasAttribute('value')) { - combobox.value = option.textContent.replace(/[\n\r]+|[\s]{2,}/g, ' ').trim(); + combobox.value = option.dataset.shortText || option.textContent.replace(/[\n\r]+|[\s]{2,}/g, ' ').trim(); } else { - combobox.textContent = option.textContent; + combobox.textContent = option.dataset.shortText || option.textContent; } if (combobox.dataset.inputElement) { diff --git a/theme/boost/pix/screenshot.png b/theme/boost/pix/screenshot.png index 1bbcd867009c3..811aff559a776 100644 Binary files a/theme/boost/pix/screenshot.png and b/theme/boost/pix/screenshot.png differ diff --git a/theme/boost/scss/moodle/core.scss b/theme/boost/scss/moodle/core.scss index 158e53fd0f1e0..250b73bb607b8 100644 --- a/theme/boost/scss/moodle/core.scss +++ b/theme/boost/scss/moodle/core.scss @@ -3121,14 +3121,16 @@ blockquote { .usersearchdropdown, .gradesearchdropdown, .groupsearchdropdown { - max-width: 350px; - .searchresultitemscontainer { - max-height: 170px; - overflow: auto; - /* stylelint-disable declaration-no-important */ - img { - height: 48px !important; - width: 48px !important; + &.dropdown-menu { + width: 350px; + .searchresultitemscontainer { + max-height: 170px; + overflow: auto; + /* stylelint-disable declaration-no-important */ + img { + height: 48px !important; + width: 48px !important; + } } } } diff --git a/theme/boost/scss/moodle/courseindex.scss b/theme/boost/scss/moodle/courseindex.scss index a97f0d396078b..fbb098eb33ded 100644 --- a/theme/boost/scss/moodle/courseindex.scss +++ b/theme/boost/scss/moodle/courseindex.scss @@ -30,9 +30,6 @@ $courseindex-item-current: $primary !default; color: $courseindex-link-hover-color; } } - &.draggable { - cursor: pointer; - } } } diff --git a/theme/boost/style/moodle.css b/theme/boost/style/moodle.css index 0384046f9d7b1..9a190efab0916 100644 --- a/theme/boost/style/moodle.css +++ b/theme/boost/style/moodle.css @@ -26026,21 +26026,21 @@ blockquote { } /* Combobox search dropdowns */ -.usersearchdropdown, -.gradesearchdropdown, -.groupsearchdropdown { - max-width: 350px; +.usersearchdropdown.dropdown-menu, +.gradesearchdropdown.dropdown-menu, +.groupsearchdropdown.dropdown-menu { + width: 350px; } -.usersearchdropdown .searchresultitemscontainer, -.gradesearchdropdown .searchresultitemscontainer, -.groupsearchdropdown .searchresultitemscontainer { +.usersearchdropdown.dropdown-menu .searchresultitemscontainer, +.gradesearchdropdown.dropdown-menu .searchresultitemscontainer, +.groupsearchdropdown.dropdown-menu .searchresultitemscontainer { max-height: 170px; overflow: auto; /* stylelint-disable declaration-no-important */ } -.usersearchdropdown .searchresultitemscontainer img, -.gradesearchdropdown .searchresultitemscontainer img, -.groupsearchdropdown .searchresultitemscontainer img { +.usersearchdropdown.dropdown-menu .searchresultitemscontainer img, +.gradesearchdropdown.dropdown-menu .searchresultitemscontainer img, +.groupsearchdropdown.dropdown-menu .searchresultitemscontainer img { height: 48px !important; width: 48px !important; } @@ -38227,9 +38227,6 @@ div.editor_atto_toolbar button .icon { .courseindex .courseindex-item:focus.dimmed .courseindex-chevron { color: black; } -.courseindex .courseindex-item:hover.draggable, .courseindex .courseindex-item:focus.draggable { - cursor: pointer; -} .courseindex .courseindex-item.dragging { border: 1px solid #b8dce2; background-color: #e0f0f2; diff --git a/theme/classic/pix/screenshot.png b/theme/classic/pix/screenshot.png index b1d02c5c3eaf2..0073754a294fc 100644 Binary files a/theme/classic/pix/screenshot.png and b/theme/classic/pix/screenshot.png differ diff --git a/theme/classic/style/moodle.css b/theme/classic/style/moodle.css index 1781a921e3752..2ce2cd99c8432 100644 --- a/theme/classic/style/moodle.css +++ b/theme/classic/style/moodle.css @@ -26026,21 +26026,21 @@ blockquote { } /* Combobox search dropdowns */ -.usersearchdropdown, -.gradesearchdropdown, -.groupsearchdropdown { - max-width: 350px; +.usersearchdropdown.dropdown-menu, +.gradesearchdropdown.dropdown-menu, +.groupsearchdropdown.dropdown-menu { + width: 350px; } -.usersearchdropdown .searchresultitemscontainer, -.gradesearchdropdown .searchresultitemscontainer, -.groupsearchdropdown .searchresultitemscontainer { +.usersearchdropdown.dropdown-menu .searchresultitemscontainer, +.gradesearchdropdown.dropdown-menu .searchresultitemscontainer, +.groupsearchdropdown.dropdown-menu .searchresultitemscontainer { max-height: 170px; overflow: auto; /* stylelint-disable declaration-no-important */ } -.usersearchdropdown .searchresultitemscontainer img, -.gradesearchdropdown .searchresultitemscontainer img, -.groupsearchdropdown .searchresultitemscontainer img { +.usersearchdropdown.dropdown-menu .searchresultitemscontainer img, +.gradesearchdropdown.dropdown-menu .searchresultitemscontainer img, +.groupsearchdropdown.dropdown-menu .searchresultitemscontainer img { height: 48px !important; width: 48px !important; } @@ -38161,9 +38161,6 @@ div.editor_atto_toolbar button .icon { .courseindex .courseindex-item:focus.dimmed .courseindex-chevron { color: black; } -.courseindex .courseindex-item:hover.draggable, .courseindex .courseindex-item:focus.draggable { - cursor: pointer; -} .courseindex .courseindex-item.dragging { border: 1px solid #b8dce2; background-color: #e0f0f2; diff --git a/user/amd/build/comboboxsearch/user.min.js b/user/amd/build/comboboxsearch/user.min.js index 887542d27754d..49a56ffb08047 100644 --- a/user/amd/build/comboboxsearch/user.min.js +++ b/user/amd/build/comboboxsearch/user.min.js @@ -1,3 +1,3 @@ -define("core_user/comboboxsearch/user",["exports","core/comboboxsearch/search_combobox","core/str","core/templates","jquery","core/notification"],(function(_exports,_search_combobox,_str,_templates,_jquery,_notification){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_search_combobox=_interopRequireDefault(_search_combobox),_jquery=_interopRequireDefault(_jquery),_notification=_interopRequireDefault(_notification);class UserSearch extends _search_combobox.default{constructor(){var _document$querySelect,_document$querySelect2;super(),_defineProperty(this,"courseID",void 0),_defineProperty(this,"groupID",void 0),_defineProperty(this,"profilestringmap",null),document.addEventListener("click",(e=>{!e.target.closest(this.selectors.component)&&this.searchDropdown.classList.contains("show")&&this.toggleDropdown()})),this.selectors={...this.selectors,courseid:'[data-region="courseid"]',groupid:'[data-region="groupid"]',resetPageButton:'[data-action="resetpage"]'};const component=document.querySelector(this.componentSelector());this.courseID=component.querySelector(this.selectors.courseid).dataset.courseid,this.groupID=null===(_document$querySelect=document.querySelector(this.selectors.groupid))||void 0===_document$querySelect||null===(_document$querySelect2=_document$querySelect.dataset)||void 0===_document$querySelect2?void 0:_document$querySelect2.groupid}static init(){return new UserSearch}componentSelector(){return".user-search"}dropdownSelector(){return".usersearchdropdown"}triggerSelector(){return".usersearchwidget"}async renderDropdown(){const{html:html,js:js}=await(0,_templates.renderForPromise)("core_user/comboboxsearch/resultset",{users:this.getMatchedResults().slice(0,5),hasresults:this.getMatchedResults().length>0,matches:this.getMatchedResults().length,searchterm:this.getSearchTerm(),selectall:this.selectAllResultsLink()});(0,_templates.replaceNodeContents)(this.getHTMLElements().searchDropdown,html,js)}fetchDataset(){throw new Error("fetchDataset() must be implemented in ".concat(this.constructor.name))}async filterDataset(filterableData){const stringMap=await this.getStringMap();return filterableData.filter((user=>Object.keys(user).some((key=>!(""===user[key]||null===user[key]||!stringMap.get(key))&&user[key].toString().toLowerCase().includes(this.getPreppedSearchTerm())))))}async filterMatchDataset(){const stringMap=await this.getStringMap();this.setMatchedResults(this.getMatchedResults().map((user=>{for(const[key,value]of Object.entries(user)){if(null===value)continue;const valueString=value.toString().toLowerCase(),preppedSearchTerm=this.getPreppedSearchTerm(),searchTerm=this.getSearchTerm(),matchingFieldName=stringMap.get(key);if(matchingFieldName&&valueString.includes(preppedSearchTerm)){user.matchingFieldName=matchingFieldName;const escapedMatchingField=valueString.replace(/'.concat(searchTerm.replace(/"));user.matchingField="".concat(escapedMatchingField," (").concat(user.email,")"),user.link=this.selectOneLink(user.id);break}}return user})))}clickHandler(e){super.clickHandler(e).catch(_notification.default.exception),e.target.closest(this.selectors.component)&&e.stopImmediatePropagation(),e.target===this.getHTMLElements().currentViewAll&&0===e.button&&(window.location=this.selectAllResultsLink()),e.target.closest(this.selectors.resetPageButton)&&(window.location=e.target.closest(this.selectors.resetPageButton).href)}keyHandler(e){switch(super.keyHandler(e),e.target!==this.getHTMLElements().currentViewAll||"Enter"!==e.key&&"Space"!==e.key||(window.location=this.selectAllResultsLink()),e.key){case"Enter":case" ":if(document.activeElement===this.getHTMLElements().searchInput&&"Enter"===e.key&&null!==this.selectAllResultsLink()&&(window.location=this.selectAllResultsLink()),document.activeElement===this.getHTMLElements().clearSearchButton){this.closeSearch(!0);break}if(e.target.closest(this.selectors.resetPageButton)){window.location=e.target.closest(this.selectors.resetPageButton).href;break}if(e.target.closest(".dropdown-item")){e.preventDefault(),window.location=e.target.closest(".dropdown-item").href;break}break;case"Escape":this.toggleDropdown(),this.searchInput.focus({preventScroll:!0});break;case"Tab":e.target.closest(this.selectors.clearSearch)&&(this.currentViewAll&&!e.shiftKey?(e.preventDefault(),this.currentViewAll.focus({preventScroll:!0})):this.closeSearch())}}toggleDropdown(){arguments.length>0&&void 0!==arguments[0]&&arguments[0]?(this.searchDropdown.classList.add("show"),(0,_jquery.default)(this.searchDropdown).show(),this.component.setAttribute("aria-expanded","true")):(this.searchDropdown.classList.remove("show"),(0,_jquery.default)(this.searchDropdown).hide(),this.component.setAttribute("aria-expanded","false"))}selectAllResultsLink(){throw new Error("selectAllResultsLink() must be implemented in ".concat(this.constructor.name))}selectOneLink(userID){throw new Error("selectOneLink(".concat(userID,") must be implemented in ").concat(this.constructor.name))}getStringMap(){if(!this.profilestringmap){const requiredStrings=["username","fullname","firstname","lastname","email","city","country","department","institution","idnumber","phone1","phone2"];this.profilestringmap=(0,_str.getStrings)(requiredStrings.map((key=>({key:key})))).then((stringArray=>new Map(requiredStrings.map(((key,index)=>[key,stringArray[index]])))))}return this.profilestringmap}}return _exports.default=UserSearch,_exports.default})); +define("core_user/comboboxsearch/user",["exports","core/comboboxsearch/search_combobox","core/str","core/templates","jquery"],(function(_exports,_search_combobox,_str,_templates,_jquery){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_search_combobox=_interopRequireDefault(_search_combobox),_jquery=_interopRequireDefault(_jquery);class UserSearch extends _search_combobox.default{constructor(){var _document$querySelect,_document$querySelect2;super(),_defineProperty(this,"courseID",void 0),_defineProperty(this,"groupID",void 0),_defineProperty(this,"profilestringmap",null),["click","focus"].forEach((eventType=>{document.addEventListener(eventType,(e=>{this.searchDropdown.classList.contains("show")&&!this.combobox.contains(e.target)&&this.toggleDropdown()}),!0)})),this.component.addEventListener("keydown",this.keyHandler.bind(this)),this.selectors={...this.selectors,courseid:'[data-region="courseid"]',groupid:'[data-region="groupid"]',resetPageButton:'[data-action="resetpage"]'},this.courseID=this.component.querySelector(this.selectors.courseid).dataset.courseid,this.groupID=null===(_document$querySelect=document.querySelector(this.selectors.groupid))||void 0===_document$querySelect||null===(_document$querySelect2=_document$querySelect.dataset)||void 0===_document$querySelect2?void 0:_document$querySelect2.groupid,this.instance=this.component.querySelector(this.selectors.instance).dataset.instance,this.renderDefault()}static init(){return new UserSearch}componentSelector(){return".user-search"}dropdownSelector(){return".usersearchdropdown"}async renderDropdown(){const{html:html,js:js}=await(0,_templates.renderForPromise)("core_user/comboboxsearch/resultset",{users:this.getMatchedResults().slice(0,5),hasresults:this.getMatchedResults().length>0,instance:this.instance,matches:this.getMatchedResults().length,searchterm:this.getSearchTerm(),selectall:this.selectAllResultsLink()});(0,_templates.replaceNodeContents)(this.getHTMLElements().searchDropdown,html,js),this.searchInput.removeAttribute("aria-activedescendant")}async renderDefault(){this.setMatchedResults(await this.filterDataset(await this.getDataset())),this.filterMatchDataset(),await this.renderDropdown()}fetchDataset(){throw new Error("fetchDataset() must be implemented in ".concat(this.constructor.name))}async filterDataset(filterableData){if(this.getPreppedSearchTerm()){const stringMap=await this.getStringMap();return filterableData.filter((user=>Object.keys(user).some((key=>!(""===user[key]||null===user[key]||!stringMap.get(key))&&user[key].toString().toLowerCase().includes(this.getPreppedSearchTerm())))))}return[]}async filterMatchDataset(){const stringMap=await this.getStringMap();this.setMatchedResults(this.getMatchedResults().map((user=>{for(const[key,value]of Object.entries(user)){if(null===value)continue;const valueString=value.toString().toLowerCase(),preppedSearchTerm=this.getPreppedSearchTerm(),searchTerm=this.getSearchTerm(),matchingFieldName=stringMap.get(key);if(matchingFieldName&&valueString.includes(preppedSearchTerm)){user.matchingFieldName=matchingFieldName;const escapedMatchingField=valueString.replace(/'.concat(searchTerm.replace(/"));user.matchingField="".concat(escapedMatchingField," (").concat(user.email,")");break}}return user})))}changeHandler(e){this.toggleDropdown(),"0"===e.target.value?window.location=this.selectAllResultsLink():window.location=this.selectOneLink(e.target.value)}keyHandler(e){switch(e.key){case"ArrowUp":case"ArrowDown":""!==this.getSearchTerm()&&!this.searchDropdown.classList.contains("show")&&e.target.contains(this.combobox)&&this.renderAndShow();break;case"Enter":case" ":if(e.target.closest(this.selectors.resetPageButton)){e.stopPropagation(),window.location=e.target.closest(this.selectors.resetPageButton).href;break}break;case"Escape":this.toggleDropdown(),this.searchInput.focus({preventScroll:!0})}}toggleDropdown(){arguments.length>0&&void 0!==arguments[0]&&arguments[0]?(this.searchDropdown.classList.add("show"),(0,_jquery.default)(this.searchDropdown).show(),this.getHTMLElements().searchInput.setAttribute("aria-expanded","true"),this.searchInput.focus({preventScroll:!0})):(this.searchDropdown.classList.remove("show"),(0,_jquery.default)(this.searchDropdown).hide(),this.getHTMLElements().searchInput.setAttribute("aria-expanded","false"),this.searchInput.removeAttribute("aria-activedescendant"),this.searchDropdown.querySelectorAll('.active[role="option"]').forEach((option=>{option.classList.remove("active")})))}selectAllResultsLink(){throw new Error("selectAllResultsLink() must be implemented in ".concat(this.constructor.name))}selectOneLink(userID){throw new Error("selectOneLink(".concat(userID,") must be implemented in ").concat(this.constructor.name))}getStringMap(){if(!this.profilestringmap){const requiredStrings=["username","fullname","firstname","lastname","email","city","country","department","institution","idnumber","phone1","phone2"];this.profilestringmap=(0,_str.getStrings)(requiredStrings.map((key=>({key:key})))).then((stringArray=>new Map(requiredStrings.map(((key,index)=>[key,stringArray[index]])))))}return this.profilestringmap}}return _exports.default=UserSearch,_exports.default})); //# sourceMappingURL=user.min.js.map \ No newline at end of file diff --git a/user/amd/build/comboboxsearch/user.min.js.map b/user/amd/build/comboboxsearch/user.min.js.map index 87c9b6b9690e3..c7c46bf45b418 100644 --- a/user/amd/build/comboboxsearch/user.min.js.map +++ b/user/amd/build/comboboxsearch/user.min.js.map @@ -1 +1 @@ -{"version":3,"file":"user.min.js","sources":["../../src/comboboxsearch/user.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 * Allow the user to search for learners.\n *\n * @module core_user/comboboxsearch/user\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport search_combobox from 'core/comboboxsearch/search_combobox';\nimport {getStrings} from 'core/str';\nimport {renderForPromise, replaceNodeContents} from 'core/templates';\nimport $ from 'jquery';\nimport Notification from 'core/notification';\n\nexport default class UserSearch extends search_combobox {\n\n courseID;\n groupID;\n\n // A map of user profile field names that is human-readable.\n profilestringmap = null;\n\n constructor() {\n super();\n // Register a small click event onto the document since we need to check if they are clicking off the component.\n document.addEventListener('click', (e) => {\n // Since we are handling dropdowns manually, ensure we can close it when clicking off.\n if (!e.target.closest(this.selectors.component) && this.searchDropdown.classList.contains('show')) {\n this.toggleDropdown();\n }\n });\n\n // Define our standard lookups.\n this.selectors = {...this.selectors,\n courseid: '[data-region=\"courseid\"]',\n groupid: '[data-region=\"groupid\"]',\n resetPageButton: '[data-action=\"resetpage\"]',\n };\n\n const component = document.querySelector(this.componentSelector());\n this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;\n this.groupID = document.querySelector(this.selectors.groupid)?.dataset?.groupid;\n }\n\n static init() {\n return new UserSearch();\n }\n\n /**\n * The overall div that contains the searching widget.\n *\n * @returns {string}\n */\n componentSelector() {\n return '.user-search';\n }\n\n /**\n * The dropdown div that contains the searching widget result space.\n *\n * @returns {string}\n */\n dropdownSelector() {\n return '.usersearchdropdown';\n }\n\n /**\n * The triggering div that contains the searching widget.\n *\n * @returns {string}\n */\n triggerSelector() {\n return '.usersearchwidget';\n }\n\n /**\n * Build the content then replace the node.\n */\n async renderDropdown() {\n const {html, js} = await renderForPromise('core_user/comboboxsearch/resultset', {\n users: this.getMatchedResults().slice(0, 5),\n hasresults: this.getMatchedResults().length > 0,\n matches: this.getMatchedResults().length,\n searchterm: this.getSearchTerm(),\n selectall: this.selectAllResultsLink(),\n });\n replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {Promise<*>}\n */\n fetchDataset() {\n throw new Error(`fetchDataset() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Dictate to the search component how and what we want to match upon.\n *\n * @param {Array} filterableData\n * @returns {Array} The users that match the given criteria.\n */\n async filterDataset(filterableData) {\n const stringMap = await this.getStringMap();\n return filterableData.filter((user) => Object.keys(user).some((key) => {\n if (user[key] === \"\" || user[key] === null || !stringMap.get(key)) {\n return false;\n }\n return user[key].toString().toLowerCase().includes(this.getPreppedSearchTerm());\n }));\n }\n\n /**\n * Given we have a subset of the dataset, set the field that we matched upon to inform the end user.\n *\n * @returns {Array} The results with the matched fields inserted.\n */\n async filterMatchDataset() {\n const stringMap = await this.getStringMap();\n this.setMatchedResults(\n this.getMatchedResults().map((user) => {\n for (const [key, value] of Object.entries(user)) {\n // Sometimes users have null values in their profile fields.\n if (value === null) {\n continue;\n }\n\n const valueString = value.toString().toLowerCase();\n const preppedSearchTerm = this.getPreppedSearchTerm();\n const searchTerm = this.getSearchTerm();\n\n // Ensure we match only on expected keys.\n const matchingFieldName = stringMap.get(key);\n if (matchingFieldName && valueString.includes(preppedSearchTerm)) {\n user.matchingFieldName = matchingFieldName;\n\n // Safely prepare our matching results.\n const escapedValueString = valueString.replace(/${searchTerm.replace(/`\n );\n\n user.matchingField = `${escapedMatchingField} (${user.email})`;\n user.link = this.selectOneLink(user.id);\n break;\n }\n }\n return user;\n })\n );\n }\n\n /**\n * The handler for when a user interacts with the component.\n *\n * @param {MouseEvent} e The triggering event that we are working with.\n */\n clickHandler(e) {\n super.clickHandler(e).catch(Notification.exception);\n if (e.target.closest(this.selectors.component)) {\n // Forcibly prevent BS events so that we can control the open and close.\n // Really needed because by default input elements cant trigger a dropdown.\n e.stopImmediatePropagation();\n }\n if (e.target === this.getHTMLElements().currentViewAll && e.button === 0) {\n window.location = this.selectAllResultsLink();\n }\n if (e.target.closest(this.selectors.resetPageButton)) {\n window.location = e.target.closest(this.selectors.resetPageButton).href;\n }\n }\n\n /**\n * The handler for when a user presses a key within the component.\n *\n * @param {KeyboardEvent} e The triggering event that we are working with.\n */\n keyHandler(e) {\n super.keyHandler(e);\n\n if (e.target === this.getHTMLElements().currentViewAll && (e.key === 'Enter' || e.key === 'Space')) {\n window.location = this.selectAllResultsLink();\n }\n\n // Switch the key presses to handle keyboard nav.\n switch (e.key) {\n case 'Enter':\n case ' ':\n if (document.activeElement === this.getHTMLElements().searchInput) {\n if (e.key === 'Enter' && this.selectAllResultsLink() !== null) {\n window.location = this.selectAllResultsLink();\n }\n }\n if (document.activeElement === this.getHTMLElements().clearSearchButton) {\n this.closeSearch(true);\n break;\n }\n if (e.target.closest(this.selectors.resetPageButton)) {\n window.location = e.target.closest(this.selectors.resetPageButton).href;\n break;\n }\n if (e.target.closest('.dropdown-item')) {\n e.preventDefault();\n window.location = e.target.closest('.dropdown-item').href;\n break;\n }\n break;\n case 'Escape':\n this.toggleDropdown();\n this.searchInput.focus({preventScroll: true});\n break;\n case 'Tab':\n // If the current focus is on clear search, then check if viewall exists then around tab to it.\n if (e.target.closest(this.selectors.clearSearch)) {\n if (this.currentViewAll && !e.shiftKey) {\n e.preventDefault();\n this.currentViewAll.focus({preventScroll: true});\n } else {\n this.closeSearch();\n }\n }\n break;\n }\n }\n\n /**\n * When called, hide or show the users dropdown.\n *\n * @param {Boolean} on Flag to toggle hiding or showing values.\n */\n toggleDropdown(on = false) {\n if (on) {\n this.searchDropdown.classList.add('show');\n $(this.searchDropdown).show();\n this.component.setAttribute('aria-expanded', 'true');\n } else {\n this.searchDropdown.classList.remove('show');\n $(this.searchDropdown).hide();\n this.component.setAttribute('aria-expanded', 'false');\n }\n }\n\n /**\n * Build up the view all link.\n */\n selectAllResultsLink() {\n throw new Error(`selectAllResultsLink() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Build up the view all link that is dedicated to a particular result.\n *\n * @param {Number} userID The ID of the user selected.\n */\n selectOneLink(userID) {\n throw new Error(`selectOneLink(${userID}) must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Given the set of profile fields we can possibly search, fetch their strings,\n * so we can report to screen readers the field that matched.\n *\n * @returns {Promise}\n */\n getStringMap() {\n if (!this.profilestringmap) {\n const requiredStrings = [\n 'username',\n 'fullname',\n 'firstname',\n 'lastname',\n 'email',\n 'city',\n 'country',\n 'department',\n 'institution',\n 'idnumber',\n 'phone1',\n 'phone2',\n ];\n this.profilestringmap = getStrings(requiredStrings.map((key) => ({key})))\n .then((stringArray) => new Map(\n requiredStrings.map((key, index) => ([key, stringArray[index]]))\n ));\n }\n return this.profilestringmap;\n }\n}\n"],"names":["UserSearch","search_combobox","constructor","document","addEventListener","e","target","closest","this","selectors","component","searchDropdown","classList","contains","toggleDropdown","courseid","groupid","resetPageButton","querySelector","componentSelector","courseID","dataset","groupID","_document$querySelect","_document$querySelect2","dropdownSelector","triggerSelector","html","js","users","getMatchedResults","slice","hasresults","length","matches","searchterm","getSearchTerm","selectall","selectAllResultsLink","getHTMLElements","fetchDataset","Error","name","filterableData","stringMap","getStringMap","filter","user","Object","keys","some","key","get","toString","toLowerCase","includes","getPreppedSearchTerm","setMatchedResults","map","value","entries","valueString","preppedSearchTerm","searchTerm","matchingFieldName","escapedMatchingField","replace","matchingField","email","link","selectOneLink","id","clickHandler","catch","Notification","exception","stopImmediatePropagation","currentViewAll","button","window","location","href","keyHandler","activeElement","searchInput","clearSearchButton","closeSearch","preventDefault","focus","preventScroll","clearSearch","shiftKey","add","show","setAttribute","remove","hide","userID","profilestringmap","requiredStrings","then","stringArray","Map","index"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"user.min.js","sources":["../../src/comboboxsearch/user.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 * Allow the user to search for learners.\n *\n * @module core_user/comboboxsearch/user\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport search_combobox from 'core/comboboxsearch/search_combobox';\nimport {getStrings} from 'core/str';\nimport {renderForPromise, replaceNodeContents} from 'core/templates';\nimport $ from 'jquery';\n\nexport default class UserSearch extends search_combobox {\n\n courseID;\n groupID;\n\n // A map of user profile field names that is human-readable.\n profilestringmap = null;\n\n constructor() {\n super();\n // Register a couple of events onto the document since we need to check if they are moving off the component.\n ['click', 'focus'].forEach(eventType => {\n // Since we are handling dropdowns manually, ensure we can close it when moving off.\n document.addEventListener(eventType, e => {\n if (this.searchDropdown.classList.contains('show') && !this.combobox.contains(e.target)) {\n this.toggleDropdown();\n }\n }, true);\n });\n\n // Register keyboard events.\n this.component.addEventListener('keydown', this.keyHandler.bind(this));\n\n // Define our standard lookups.\n this.selectors = {...this.selectors,\n courseid: '[data-region=\"courseid\"]',\n groupid: '[data-region=\"groupid\"]',\n resetPageButton: '[data-action=\"resetpage\"]',\n };\n\n this.courseID = this.component.querySelector(this.selectors.courseid).dataset.courseid;\n this.groupID = document.querySelector(this.selectors.groupid)?.dataset?.groupid;\n this.instance = this.component.querySelector(this.selectors.instance).dataset.instance;\n\n // We need to render some content by default for ARIA purposes.\n this.renderDefault();\n }\n\n static init() {\n return new UserSearch();\n }\n\n /**\n * The overall div that contains the searching widget.\n *\n * @returns {string}\n */\n componentSelector() {\n return '.user-search';\n }\n\n /**\n * The dropdown div that contains the searching widget result space.\n *\n * @returns {string}\n */\n dropdownSelector() {\n return '.usersearchdropdown';\n }\n\n /**\n * Build the content then replace the node.\n */\n async renderDropdown() {\n const {html, js} = await renderForPromise('core_user/comboboxsearch/resultset', {\n users: this.getMatchedResults().slice(0, 5),\n hasresults: this.getMatchedResults().length > 0,\n instance: this.instance,\n matches: this.getMatchedResults().length,\n searchterm: this.getSearchTerm(),\n selectall: this.selectAllResultsLink(),\n });\n replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);\n // Remove aria-activedescendant when the available options change.\n this.searchInput.removeAttribute('aria-activedescendant');\n }\n\n /**\n * Build the content then replace the node by default we want our form to exist.\n */\n async renderDefault() {\n this.setMatchedResults(await this.filterDataset(await this.getDataset()));\n this.filterMatchDataset();\n\n await this.renderDropdown();\n }\n\n /**\n * Get the data we will be searching against in this component.\n *\n * @returns {Promise<*>}\n */\n fetchDataset() {\n throw new Error(`fetchDataset() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Dictate to the search component how and what we want to match upon.\n *\n * @param {Array} filterableData\n * @returns {Array} The users that match the given criteria.\n */\n async filterDataset(filterableData) {\n if (this.getPreppedSearchTerm()) {\n const stringMap = await this.getStringMap();\n return filterableData.filter((user) => Object.keys(user).some((key) => {\n if (user[key] === \"\" || user[key] === null || !stringMap.get(key)) {\n return false;\n }\n return user[key].toString().toLowerCase().includes(this.getPreppedSearchTerm());\n }));\n } else {\n return [];\n }\n }\n\n /**\n * Given we have a subset of the dataset, set the field that we matched upon to inform the end user.\n *\n * @returns {Array} The results with the matched fields inserted.\n */\n async filterMatchDataset() {\n const stringMap = await this.getStringMap();\n this.setMatchedResults(\n this.getMatchedResults().map((user) => {\n for (const [key, value] of Object.entries(user)) {\n // Sometimes users have null values in their profile fields.\n if (value === null) {\n continue;\n }\n\n const valueString = value.toString().toLowerCase();\n const preppedSearchTerm = this.getPreppedSearchTerm();\n const searchTerm = this.getSearchTerm();\n\n // Ensure we match only on expected keys.\n const matchingFieldName = stringMap.get(key);\n if (matchingFieldName && valueString.includes(preppedSearchTerm)) {\n user.matchingFieldName = matchingFieldName;\n\n // Safely prepare our matching results.\n const escapedValueString = valueString.replace(/${searchTerm.replace(/`\n );\n\n user.matchingField = `${escapedMatchingField} (${user.email})`;\n break;\n }\n }\n return user;\n })\n );\n }\n\n /**\n * The handler for when a user changes the value of the component (selects an option from the dropdown).\n *\n * @param {Event} e The change event.\n */\n changeHandler(e) {\n this.toggleDropdown(); // Otherwise the dropdown stays open when user choose an option using keyboard.\n\n if (e.target.value === '0') {\n window.location = this.selectAllResultsLink();\n } else {\n window.location = this.selectOneLink(e.target.value);\n }\n }\n\n /**\n * The handler for when a user presses a key within the component.\n *\n * @param {KeyboardEvent} e The triggering event that we are working with.\n */\n keyHandler(e) {\n // Switch the key presses to handle keyboard nav.\n switch (e.key) {\n case 'ArrowUp':\n case 'ArrowDown':\n if (\n this.getSearchTerm() !== ''\n && !this.searchDropdown.classList.contains('show')\n && e.target.contains(this.combobox)\n ) {\n this.renderAndShow();\n }\n break;\n case 'Enter':\n case ' ':\n if (e.target.closest(this.selectors.resetPageButton)) {\n e.stopPropagation();\n window.location = e.target.closest(this.selectors.resetPageButton).href;\n break;\n }\n break;\n case 'Escape':\n this.toggleDropdown();\n this.searchInput.focus({preventScroll: true});\n break;\n }\n }\n\n /**\n * When called, hide or show the users dropdown.\n *\n * @param {Boolean} on Flag to toggle hiding or showing values.\n */\n toggleDropdown(on = false) {\n if (on) {\n this.searchDropdown.classList.add('show');\n $(this.searchDropdown).show();\n this.getHTMLElements().searchInput.setAttribute('aria-expanded', 'true');\n this.searchInput.focus({preventScroll: true});\n } else {\n this.searchDropdown.classList.remove('show');\n $(this.searchDropdown).hide();\n\n // As we are manually handling the dropdown, we need to do some housekeeping manually.\n this.getHTMLElements().searchInput.setAttribute('aria-expanded', 'false');\n this.searchInput.removeAttribute('aria-activedescendant');\n this.searchDropdown.querySelectorAll('.active[role=\"option\"]').forEach(option => {\n option.classList.remove('active');\n });\n }\n }\n\n /**\n * Build up the view all link.\n */\n selectAllResultsLink() {\n throw new Error(`selectAllResultsLink() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Build up the view all link that is dedicated to a particular result.\n * We will call this function when a user interacts with the combobox to redirect them to show their results in the page.\n *\n * @param {Number} userID The ID of the user selected.\n */\n selectOneLink(userID) {\n throw new Error(`selectOneLink(${userID}) must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Given the set of profile fields we can possibly search, fetch their strings,\n * so we can report to screen readers the field that matched.\n *\n * @returns {Promise}\n */\n getStringMap() {\n if (!this.profilestringmap) {\n const requiredStrings = [\n 'username',\n 'fullname',\n 'firstname',\n 'lastname',\n 'email',\n 'city',\n 'country',\n 'department',\n 'institution',\n 'idnumber',\n 'phone1',\n 'phone2',\n ];\n this.profilestringmap = getStrings(requiredStrings.map((key) => ({key})))\n .then((stringArray) => new Map(\n requiredStrings.map((key, index) => ([key, stringArray[index]]))\n ));\n }\n return this.profilestringmap;\n }\n}\n"],"names":["UserSearch","search_combobox","constructor","forEach","eventType","document","addEventListener","e","this","searchDropdown","classList","contains","combobox","target","toggleDropdown","component","keyHandler","bind","selectors","courseid","groupid","resetPageButton","courseID","querySelector","dataset","groupID","_document$querySelect","_document$querySelect2","instance","renderDefault","componentSelector","dropdownSelector","html","js","users","getMatchedResults","slice","hasresults","length","matches","searchterm","getSearchTerm","selectall","selectAllResultsLink","getHTMLElements","searchInput","removeAttribute","setMatchedResults","filterDataset","getDataset","filterMatchDataset","renderDropdown","fetchDataset","Error","name","filterableData","getPreppedSearchTerm","stringMap","getStringMap","filter","user","Object","keys","some","key","get","toString","toLowerCase","includes","map","value","entries","valueString","preppedSearchTerm","searchTerm","matchingFieldName","escapedMatchingField","replace","matchingField","email","changeHandler","window","location","selectOneLink","renderAndShow","closest","stopPropagation","href","focus","preventScroll","add","show","setAttribute","remove","hide","querySelectorAll","option","userID","profilestringmap","requiredStrings","then","stringArray","Map","index"],"mappings":"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"} \ No newline at end of file diff --git a/user/amd/src/comboboxsearch/user.js b/user/amd/src/comboboxsearch/user.js index ce8655de4c0d1..c1350902980fd 100644 --- a/user/amd/src/comboboxsearch/user.js +++ b/user/amd/src/comboboxsearch/user.js @@ -24,7 +24,6 @@ import search_combobox from 'core/comboboxsearch/search_combobox'; import {getStrings} from 'core/str'; import {renderForPromise, replaceNodeContents} from 'core/templates'; import $ from 'jquery'; -import Notification from 'core/notification'; export default class UserSearch extends search_combobox { @@ -36,14 +35,19 @@ export default class UserSearch extends search_combobox { constructor() { super(); - // Register a small click event onto the document since we need to check if they are clicking off the component. - document.addEventListener('click', (e) => { - // Since we are handling dropdowns manually, ensure we can close it when clicking off. - if (!e.target.closest(this.selectors.component) && this.searchDropdown.classList.contains('show')) { - this.toggleDropdown(); - } + // Register a couple of events onto the document since we need to check if they are moving off the component. + ['click', 'focus'].forEach(eventType => { + // Since we are handling dropdowns manually, ensure we can close it when moving off. + document.addEventListener(eventType, e => { + if (this.searchDropdown.classList.contains('show') && !this.combobox.contains(e.target)) { + this.toggleDropdown(); + } + }, true); }); + // Register keyboard events. + this.component.addEventListener('keydown', this.keyHandler.bind(this)); + // Define our standard lookups. this.selectors = {...this.selectors, courseid: '[data-region="courseid"]', @@ -51,9 +55,12 @@ export default class UserSearch extends search_combobox { resetPageButton: '[data-action="resetpage"]', }; - const component = document.querySelector(this.componentSelector()); - this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid; + this.courseID = this.component.querySelector(this.selectors.courseid).dataset.courseid; this.groupID = document.querySelector(this.selectors.groupid)?.dataset?.groupid; + this.instance = this.component.querySelector(this.selectors.instance).dataset.instance; + + // We need to render some content by default for ARIA purposes. + this.renderDefault(); } static init() { @@ -78,15 +85,6 @@ export default class UserSearch extends search_combobox { return '.usersearchdropdown'; } - /** - * The triggering div that contains the searching widget. - * - * @returns {string} - */ - triggerSelector() { - return '.usersearchwidget'; - } - /** * Build the content then replace the node. */ @@ -94,11 +92,24 @@ export default class UserSearch extends search_combobox { const {html, js} = await renderForPromise('core_user/comboboxsearch/resultset', { users: this.getMatchedResults().slice(0, 5), hasresults: this.getMatchedResults().length > 0, + instance: this.instance, matches: this.getMatchedResults().length, searchterm: this.getSearchTerm(), selectall: this.selectAllResultsLink(), }); replaceNodeContents(this.getHTMLElements().searchDropdown, html, js); + // Remove aria-activedescendant when the available options change. + this.searchInput.removeAttribute('aria-activedescendant'); + } + + /** + * Build the content then replace the node by default we want our form to exist. + */ + async renderDefault() { + this.setMatchedResults(await this.filterDataset(await this.getDataset())); + this.filterMatchDataset(); + + await this.renderDropdown(); } /** @@ -117,13 +128,17 @@ export default class UserSearch extends search_combobox { * @returns {Array} The users that match the given criteria. */ async filterDataset(filterableData) { - const stringMap = await this.getStringMap(); - return filterableData.filter((user) => Object.keys(user).some((key) => { - if (user[key] === "" || user[key] === null || !stringMap.get(key)) { - return false; - } - return user[key].toString().toLowerCase().includes(this.getPreppedSearchTerm()); - })); + if (this.getPreppedSearchTerm()) { + const stringMap = await this.getStringMap(); + return filterableData.filter((user) => Object.keys(user).some((key) => { + if (user[key] === "" || user[key] === null || !stringMap.get(key)) { + return false; + } + return user[key].toString().toLowerCase().includes(this.getPreppedSearchTerm()); + })); + } else { + return []; + } } /** @@ -158,7 +173,6 @@ export default class UserSearch extends search_combobox { ); user.matchingField = `${escapedMatchingField} (${user.email})`; - user.link = this.selectOneLink(user.id); break; } } @@ -168,22 +182,17 @@ export default class UserSearch extends search_combobox { } /** - * The handler for when a user interacts with the component. + * The handler for when a user changes the value of the component (selects an option from the dropdown). * - * @param {MouseEvent} e The triggering event that we are working with. + * @param {Event} e The change event. */ - clickHandler(e) { - super.clickHandler(e).catch(Notification.exception); - if (e.target.closest(this.selectors.component)) { - // Forcibly prevent BS events so that we can control the open and close. - // Really needed because by default input elements cant trigger a dropdown. - e.stopImmediatePropagation(); - } - if (e.target === this.getHTMLElements().currentViewAll && e.button === 0) { + changeHandler(e) { + this.toggleDropdown(); // Otherwise the dropdown stays open when user choose an option using keyboard. + + if (e.target.value === '0') { window.location = this.selectAllResultsLink(); - } - if (e.target.closest(this.selectors.resetPageButton)) { - window.location = e.target.closest(this.selectors.resetPageButton).href; + } else { + window.location = this.selectOneLink(e.target.value); } } @@ -193,50 +202,30 @@ export default class UserSearch extends search_combobox { * @param {KeyboardEvent} e The triggering event that we are working with. */ keyHandler(e) { - super.keyHandler(e); - - if (e.target === this.getHTMLElements().currentViewAll && (e.key === 'Enter' || e.key === 'Space')) { - window.location = this.selectAllResultsLink(); - } - // Switch the key presses to handle keyboard nav. switch (e.key) { + case 'ArrowUp': + case 'ArrowDown': + if ( + this.getSearchTerm() !== '' + && !this.searchDropdown.classList.contains('show') + && e.target.contains(this.combobox) + ) { + this.renderAndShow(); + } + break; case 'Enter': case ' ': - if (document.activeElement === this.getHTMLElements().searchInput) { - if (e.key === 'Enter' && this.selectAllResultsLink() !== null) { - window.location = this.selectAllResultsLink(); - } - } - if (document.activeElement === this.getHTMLElements().clearSearchButton) { - this.closeSearch(true); - break; - } if (e.target.closest(this.selectors.resetPageButton)) { + e.stopPropagation(); window.location = e.target.closest(this.selectors.resetPageButton).href; break; } - if (e.target.closest('.dropdown-item')) { - e.preventDefault(); - window.location = e.target.closest('.dropdown-item').href; - break; - } break; case 'Escape': this.toggleDropdown(); this.searchInput.focus({preventScroll: true}); break; - case 'Tab': - // If the current focus is on clear search, then check if viewall exists then around tab to it. - if (e.target.closest(this.selectors.clearSearch)) { - if (this.currentViewAll && !e.shiftKey) { - e.preventDefault(); - this.currentViewAll.focus({preventScroll: true}); - } else { - this.closeSearch(); - } - } - break; } } @@ -249,11 +238,18 @@ export default class UserSearch extends search_combobox { if (on) { this.searchDropdown.classList.add('show'); $(this.searchDropdown).show(); - this.component.setAttribute('aria-expanded', 'true'); + this.getHTMLElements().searchInput.setAttribute('aria-expanded', 'true'); + this.searchInput.focus({preventScroll: true}); } else { this.searchDropdown.classList.remove('show'); $(this.searchDropdown).hide(); - this.component.setAttribute('aria-expanded', 'false'); + + // As we are manually handling the dropdown, we need to do some housekeeping manually. + this.getHTMLElements().searchInput.setAttribute('aria-expanded', 'false'); + this.searchInput.removeAttribute('aria-activedescendant'); + this.searchDropdown.querySelectorAll('.active[role="option"]').forEach(option => { + option.classList.remove('active'); + }); } } @@ -266,6 +262,7 @@ export default class UserSearch extends search_combobox { /** * Build up the view all link that is dedicated to a particular result. + * We will call this function when a user interacts with the combobox to redirect them to show their results in the page. * * @param {Number} userID The ID of the user selected. */ diff --git a/user/classes/hook/before_user_deleted.php b/user/classes/hook/before_user_deleted.php new file mode 100644 index 0000000000000..29042a2520aca --- /dev/null +++ b/user/classes/hook/before_user_deleted.php @@ -0,0 +1,59 @@ +. + +namespace core_user\hook; + +use stdClass; +use Psr\EventDispatcher\StoppableEventInterface; + +/** + * Hook before user deletion. + * + * @package core_user + * @copyright 2024 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\core\attribute\label('Allows plugins or features to perform actions before a user is deleted.')] +#[\core\attribute\tags('user')] +class before_user_deleted implements + StoppableEventInterface { + + /** + * @var bool Whether the propagation of this event has been stopped. + */ + protected bool $stopped = false; + + /** + * Constructor for the hook. + * + * @param stdClass $user The user instance + */ + public function __construct( + public readonly stdClass $user, + ) { + } + + public function isPropagationStopped(): bool { + return $this->stopped; + } + + /** + * Stop the propagation of this event. + */ + public function stop(): void { + $this->stopped = true; + } +} diff --git a/user/classes/hook/before_user_update.php b/user/classes/hook/before_user_update.php new file mode 100644 index 0000000000000..53dbf4c7bcc36 --- /dev/null +++ b/user/classes/hook/before_user_update.php @@ -0,0 +1,43 @@ +. + +namespace core_user\hook; + +use stdClass; + +/** + * Hook before user information and data updates. + * + * @package core_user + * @copyright 2024 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\core\attribute\label('Allows plugins or features to perform actions before a user is updated.')] +#[\core\attribute\tags('user')] +class before_user_update { + + /** + * Constructor for the hook. + * + * @param stdClass $user The user instance + * @param stdClass $currentuserdata The old user instance + */ + public function __construct( + public readonly stdClass $user, + public readonly stdClass $currentuserdata, + ) { + } +} diff --git a/user/lib.php b/user/lib.php index 4d437d3ac8aed..105e8ff3d76b2 100644 --- a/user/lib.php +++ b/user/lib.php @@ -150,7 +150,7 @@ function user_create_user($user, $updatepassword = true, $triggerevent = true) { * This will not affect user_password_updated event triggering. */ function user_update_user($user, $updatepassword = true, $triggerevent = true) { - global $DB, $CFG; + global $DB; // Set the timecreate field to the current time. if (!is_object($user)) { @@ -159,26 +159,12 @@ function user_update_user($user, $updatepassword = true, $triggerevent = true) { $currentrecord = $DB->get_record('user', ['id' => $user->id]); - // Communication api update for user. - if (core_communication\api::is_available()) { - $usercourses = enrol_get_users_courses($user->id); - if (!empty($currentrecord) && isset($user->suspended) && $currentrecord->suspended !== $user->suspended) { - foreach ($usercourses as $usercourse) { - $communication = \core_communication\api::load_by_instance( - context: \core\context\course::instance($usercourse->id), - component: 'core_course', - instancetype: 'coursecommunication', - instanceid: $usercourse->id - ); - // If the record updated the suspended for a user. - if ($user->suspended === 0) { - $communication->add_members_to_room([$user->id]); - } else if ($user->suspended === 1) { - $communication->remove_members_from_room([$user->id]); - } - } - } - } + // Dispatch the hook for pre user update actions. + $hook = new \core_user\hook\before_user_update( + user: $user, + currentuserdata: $currentrecord, + ); + \core\di::get(\core\hook\manager::class)->dispatch($hook); // Check username. if (isset($user->username)) { diff --git a/user/templates/comboboxsearch/resultitem.mustache b/user/templates/comboboxsearch/resultitem.mustache index f91ead9bee47c..7e1ddcf23751c 100644 --- a/user/templates/comboboxsearch/resultitem.mustache +++ b/user/templates/comboboxsearch/resultitem.mustache @@ -22,7 +22,6 @@ * profileimageurl - Link for the users' large profile image. * matchingField - The field in the user object that matched the search criteria. * matchingFieldName - The name of the field that was matched upon for A11y purposes. - * link - The link used to redirect upon self to show only this specific user. Example context (json): { @@ -30,12 +29,12 @@ "fullname": "Foo bar", "profileimageurl": "http://foo.bar/pluginfile.php/79/user/icon/boost/f1?rev=7630", "matchingField": "Foo bar", - "matchingFieldName": "Fullname", - "link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2" + "matchingFieldName": "Fullname" } }} {{ {{#profileimageurl}} diff --git a/user/templates/comboboxsearch/resultset.mustache b/user/templates/comboboxsearch/resultset.mustache index 1d6a1c6f3dc77..b0c4bb2c88b5e 100644 --- a/user/templates/comboboxsearch/resultset.mustache +++ b/user/templates/comboboxsearch/resultset.mustache @@ -17,35 +17,35 @@ Wrapping template for returned result items. Context variables required for this template: + * instance - The instance ID of the combo box. * users - Our returned users to render. * found - Count of the found users. * total - Total count of users within this report. - * selectall - The created link that allows users to select all of the results. + * selectall - Whether to show the select all option. * searchterm - The entered text to find these results. * hasusers - Allow the handling where no users exist for the returned search term. Example context (json): { + "instance": 25, "users": [ { "id": 2, "fullname": "Foo bar", "profileimageurl": "http://foo.bar/pluginfile.php/79/user/icon/boost/f1?rev=7630", "matchingField": "Foo bar", - "matchingFieldName": "Fullname", - "link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2" + "matchingFieldName": "Fullname" }, { "id": 3, "fullname": "Bar Foo", "profileimageurl": "http://foo.bar/pluginfile.php/80/user/icon/boost/f1?rev=7631", "matchingField": "Bar Foo", - "matchingFieldName": "Fullname", - "link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=3" + "matchingFieldName": "Fullname" } ], "matches": 20, - "selectall": "https://foo.bar/grade/report/grader/index.php?id=2&searchvalue=abe", + "selectall": true, "searchterm": "Foo", "hasresults": true } @@ -59,10 +59,15 @@ {{/results}} {{$selectall}} {{#selectall}} -
  • - - {{#str}}viewallresults, core, {{matches}}{{/str}} - +
  • {{/selectall}} {{/selectall}} diff --git a/user/templates/comboboxsearch/user_selector.mustache b/user/templates/comboboxsearch/user_selector.mustache index 7edd4a633e77b..c59f1b514e28c 100644 --- a/user/templates/comboboxsearch/user_selector.mustache +++ b/user/templates/comboboxsearch/user_selector.mustache @@ -20,6 +20,8 @@ The user selector trigger element. Context variables required for this template: + * name - The name of the input element representing the user search combobox. + * value - The value of the input element representing the user search combobox. * currentvalue - If the user has already searched, set the value to that. * courseid - The course ID. * group - The group ID. @@ -27,41 +29,33 @@ Example context (json): { + "name": "input-1", + "value": "0", "currentvalue": "bar", "courseid": 2, "group": 25, "resetlink": "grade/report/grader/index.php?id=2" } }} - - + + + +{{< core/search_input_auto }} + {{$label}}{{#str}}searchusers, core{{/str}}{{/label}} + {{$placeholder}}{{#str}}searchusers, core{{/str}}{{/placeholder}} + {{$value}}{{currentvalue}}{{/value}} + {{$additionalattributes}} + role="combobox" + aria-expanded="false" + aria-controls="user-{{instance}}-result-listbox" + aria-autocomplete="list" + aria-haspopup="listbox" + data-input-element="user-input-{{uniqid}}-{{instance}}" + {{/additionalattributes}} +{{/ core/search_input_auto }} {{#currentvalue}} - {{< core/search_input_auto }} - {{$label}}{{#str}} - searchusers, core - {{/str}}{{/label}} - {{$value}}{{currentvalue}}{{/value}} - {{$additionalattributes}} - aria-autocomplete="list" - data-input-element="user-input-{{uniqid}}" - {{/additionalattributes}} - {{/ core/search_input_auto }} {{#str}}clear{{/str}} {{/currentvalue}} -{{^currentvalue}} - {{< core/search_input_auto }} - {{$label}}{{#str}} - searchusers, core - {{/str}}{{/label}} - {{$placeholder}}{{#str}} - searchusers, core - {{/str}}{{/placeholder}} - {{$additionalattributes}} - aria-autocomplete="list" - data-input-element="user-input-{{uniqid}}" - {{/additionalattributes}} - {{/ core/search_input_auto }} -{{/currentvalue}} - + diff --git a/user/tests/fields_test.php b/user/tests/fields_test.php index 1d7977be335b2..2fd0b2e2a0b27 100644 --- a/user/tests/fields_test.php +++ b/user/tests/fields_test.php @@ -391,12 +391,12 @@ public function test_get_sql_variations() { $this->assertCount(2, $records); // User id was renamed. - $this->assertObjectNotHasAttribute('id', $records['XXX1']); - $this->assertObjectHasAttribute('userid', $records['XXX1']); + $this->assertObjectNotHasProperty('id', $records['XXX1']); + $this->assertObjectHasProperty('userid', $records['XXX1']); // Other fields are normal (just try a couple). - $this->assertObjectHasAttribute('firstname', $records['XXX1']); - $this->assertObjectHasAttribute('imagealt', $records['XXX1']); + $this->assertObjectHasProperty('firstname', $records['XXX1']); + $this->assertObjectHasProperty('imagealt', $records['XXX1']); // Check the user id is actually right. $this->assertEquals('XXX1', @@ -415,13 +415,13 @@ public function test_get_sql_variations() { $this->assertCount(2, $records); // User id was renamed. - $this->assertObjectNotHasAttribute('id', $records['XXX1']); - $this->assertObjectNotHasAttribute('u_id', $records['XXX1']); - $this->assertObjectHasAttribute('userid', $records['XXX1']); + $this->assertObjectNotHasProperty('id', $records['XXX1']); + $this->assertObjectNotHasProperty('u_id', $records['XXX1']); + $this->assertObjectHasProperty('userid', $records['XXX1']); // Other fields are prefixed (just try a couple). - $this->assertObjectHasAttribute('u_firstname', $records['XXX1']); - $this->assertObjectHasAttribute('u_imagealt', $records['XXX1']); + $this->assertObjectHasProperty('u_firstname', $records['XXX1']); + $this->assertObjectHasProperty('u_imagealt', $records['XXX1']); // Without a leading comma. ['selects' => $selects, 'joins' => $joins, 'params' => $joinparams] = @@ -437,8 +437,8 @@ public function test_get_sql_variations() { // ID should be the first field used by get_records_sql. $this->assertEquals($key, $record->id); // Check 2 other sample properties. - $this->assertObjectHasAttribute('firstname', $record); - $this->assertObjectHasAttribute('imagealt', $record); + $this->assertObjectHasProperty('firstname', $record); + $this->assertObjectHasProperty('imagealt', $record); } } diff --git a/user/tests/profilelib_test.php b/user/tests/profilelib_test.php index e8609d710561f..3daa968211d60 100644 --- a/user/tests/profilelib_test.php +++ b/user/tests/profilelib_test.php @@ -56,10 +56,10 @@ public function test_get_custom_fields() { $this->assertArrayNotHasKey($id1, profile_get_custom_fields(true)); // Check that profile_user_record returns same (no) fields. - $this->assertObjectNotHasAttribute('frogdesc', profile_user_record($user->id)); + $this->assertObjectNotHasProperty('frogdesc', profile_user_record($user->id)); // Check that profile_user_record returns all the fields when requested. - $this->assertObjectHasAttribute('frogdesc', profile_user_record($user->id, false)); + $this->assertObjectHasProperty('frogdesc', profile_user_record($user->id, false)); // Add another custom field, this time of normal text type. $id2 = $this->getDataGenerator()->create_custom_profile_field(array( @@ -75,10 +75,10 @@ public function test_get_custom_fields() { $this->assertArrayHasKey($id2, profile_get_custom_fields(true)); // Check profile_user_record returns same field. - $this->assertObjectHasAttribute('frogname', profile_user_record($user->id)); + $this->assertObjectHasProperty('frogname', profile_user_record($user->id)); // Check that profile_user_record returns all the fields when requested. - $this->assertObjectHasAttribute('frogname', profile_user_record($user->id, false)); + $this->assertObjectHasProperty('frogname', profile_user_record($user->id, false)); } /** @@ -233,7 +233,7 @@ public function test_profile_fields_in_generator() { $this->assertEquals('Gryffindor', $profilefields1->house); $profilefields2 = profile_user_record($harry->id); - $this->assertObjectHasAttribute('house', $profilefields2); + $this->assertObjectHasProperty('house', $profilefields2); $this->assertNull($profilefields2->house); } diff --git a/user/tests/userselector_test.php b/user/tests/userselector_test.php index 2cdf60d7714b8..78b8f261955b9 100644 --- a/user/tests/userselector_test.php +++ b/user/tests/userselector_test.php @@ -75,9 +75,9 @@ public function test_hidden_siteidentity_fields_no_access() { foreach ($selector->find_users('') as $found) { foreach ($found as $user) { - $this->assertObjectNotHasAttribute('idnumber', $user); - $this->assertObjectNotHasAttribute('country', $user); - $this->assertObjectNotHasAttribute('city', $user); + $this->assertObjectNotHasProperty('idnumber', $user); + $this->assertObjectNotHasProperty('country', $user); + $this->assertObjectNotHasProperty('city', $user); } } } @@ -95,17 +95,17 @@ public function test_hidden_siteidentity_fields_course_only_access() { foreach ($systemselector->find_users('') as $found) { foreach ($found as $user) { - $this->assertObjectNotHasAttribute('idnumber', $user); - $this->assertObjectNotHasAttribute('country', $user); - $this->assertObjectNotHasAttribute('city', $user); + $this->assertObjectNotHasProperty('idnumber', $user); + $this->assertObjectNotHasProperty('country', $user); + $this->assertObjectNotHasProperty('city', $user); } } foreach ($courseselector->find_users('') as $found) { foreach ($found as $user) { - $this->assertObjectHasAttribute('idnumber', $user); - $this->assertObjectHasAttribute('country', $user); - $this->assertObjectHasAttribute('city', $user); + $this->assertObjectHasProperty('idnumber', $user); + $this->assertObjectHasProperty('country', $user); + $this->assertObjectHasProperty('city', $user); } } } @@ -124,9 +124,9 @@ public function test_hidden_siteidentity_fields_course_prevented_access() { foreach ($courseselector->find_users('') as $found) { foreach ($found as $user) { - $this->assertObjectHasAttribute('idnumber', $user); - $this->assertObjectNotHasAttribute('country', $user); - $this->assertObjectNotHasAttribute('city', $user); + $this->assertObjectHasProperty('idnumber', $user); + $this->assertObjectNotHasProperty('country', $user); + $this->assertObjectNotHasProperty('city', $user); } } } @@ -144,17 +144,17 @@ public function test_hidden_siteidentity_fields_anywhere_access() { foreach ($systemselector->find_users('') as $found) { foreach ($found as $user) { - $this->assertObjectHasAttribute('idnumber', $user); - $this->assertObjectHasAttribute('country', $user); - $this->assertObjectHasAttribute('city', $user); + $this->assertObjectHasProperty('idnumber', $user); + $this->assertObjectHasProperty('country', $user); + $this->assertObjectHasProperty('city', $user); } } foreach ($courseselector->find_users('') as $found) { foreach ($found as $user) { - $this->assertObjectHasAttribute('idnumber', $user); - $this->assertObjectHasAttribute('country', $user); - $this->assertObjectHasAttribute('city', $user); + $this->assertObjectHasProperty('idnumber', $user); + $this->assertObjectHasProperty('country', $user); + $this->assertObjectHasProperty('city', $user); } } } @@ -178,17 +178,17 @@ public function test_hidden_siteidentity_fields_schismatic_access() { foreach ($systemselector->find_users('') as $found) { foreach ($found as $user) { - $this->assertObjectHasAttribute('idnumber', $user); - $this->assertObjectNotHasAttribute('country', $user); - $this->assertObjectNotHasAttribute('city', $user); + $this->assertObjectHasProperty('idnumber', $user); + $this->assertObjectNotHasProperty('country', $user); + $this->assertObjectNotHasProperty('city', $user); } } foreach ($courseselector->find_users('') as $found) { foreach ($found as $user) { - $this->assertObjectHasAttribute('idnumber', $user); - $this->assertObjectHasAttribute('country', $user); - $this->assertObjectHasAttribute('city', $user); + $this->assertObjectHasProperty('idnumber', $user); + $this->assertObjectHasProperty('country', $user); + $this->assertObjectHasProperty('city', $user); } } } @@ -209,17 +209,17 @@ public function test_hidden_siteidentity_fields_hard_to_prevent_access() { foreach ($systemselector->find_users('') as $found) { foreach ($found as $user) { - $this->assertObjectHasAttribute('idnumber', $user); - $this->assertObjectNotHasAttribute('country', $user); - $this->assertObjectNotHasAttribute('city', $user); + $this->assertObjectHasProperty('idnumber', $user); + $this->assertObjectNotHasProperty('country', $user); + $this->assertObjectNotHasProperty('city', $user); } } foreach ($courseselector->find_users('') as $found) { foreach ($found as $user) { - $this->assertObjectHasAttribute('idnumber', $user); - $this->assertObjectNotHasAttribute('country', $user); - $this->assertObjectNotHasAttribute('city', $user); + $this->assertObjectHasProperty('idnumber', $user); + $this->assertObjectNotHasProperty('country', $user); + $this->assertObjectNotHasProperty('city', $user); } } } @@ -242,21 +242,21 @@ public function test_hidden_siteidentity_fields_explicit_extrafields() { foreach ($implicitselector->find_users('') as $found) { foreach ($found as $user) { - $this->assertObjectHasAttribute('idnumber', $user); - $this->assertObjectHasAttribute('country', $user); - $this->assertObjectHasAttribute('city', $user); - $this->assertObjectNotHasAttribute('email', $user); - $this->assertObjectNotHasAttribute('department', $user); + $this->assertObjectHasProperty('idnumber', $user); + $this->assertObjectHasProperty('country', $user); + $this->assertObjectHasProperty('city', $user); + $this->assertObjectNotHasProperty('email', $user); + $this->assertObjectNotHasProperty('department', $user); } } foreach ($explicitselector->find_users('') as $found) { foreach ($found as $user) { - $this->assertObjectHasAttribute('idnumber', $user); - $this->assertObjectHasAttribute('country', $user); - $this->assertObjectHasAttribute('city', $user); - $this->assertObjectNotHasAttribute('email', $user); - $this->assertObjectNotHasAttribute('department', $user); + $this->assertObjectHasProperty('idnumber', $user); + $this->assertObjectHasProperty('country', $user); + $this->assertObjectHasProperty('city', $user); + $this->assertObjectNotHasProperty('email', $user); + $this->assertObjectNotHasProperty('department', $user); } } } diff --git a/version.php b/version.php index 61e210cb0029f..91e89cdda3ae2 100644 --- a/version.php +++ b/version.php @@ -29,9 +29,9 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2024032200.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2024032600.00; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. -$release = '4.4dev+ (Build: 20240322)'; // Human-friendly version name +$release = '4.4dev+ (Build: 20240326)'; // Human-friendly version name $branch = '404'; // This version's branch. $maturity = MATURITY_ALPHA; // This version's maturity level.