diff --git a/admin/amd/build/plugin_management_table.min.js b/admin/amd/build/plugin_management_table.min.js
index dcb425a81a2..886eeb66128 100644
--- a/admin/amd/build/plugin_management_table.min.js
+++ b/admin/amd/build/plugin_management_table.min.js
@@ -1,3 +1,3 @@
-define("core_admin/plugin_management_table",["exports","core_table/dynamic","core_table/local/dynamic/selectors","core/ajax","core/pending","core/notification"],(function(_exports,_dynamic,Selectors,_ajax,_pending,_notification){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)}function _defineProperty(obj,key,value){return(key=function(arg){var key=function(input,hint){if("object"!=typeof input||null===input)return input;var prim=input[Symbol.toPrimitive];if(void 0!==prim){var res=prim.call(input,hint||"default");if("object"!=typeof res)return res;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===hint?String:Number)(input)}(arg,"string");return"symbol"==typeof key?key:String(key)}(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,Selectors=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}(Selectors),_pending=(obj=_pending)&&obj.__esModule?obj:{default:obj};let watching=!1;return _exports.default=class{constructor(){_defineProperty(this,"clickHandlers",[]),this.addClickHandler(this.handleStateToggle),this.registerEventListeners()}static init(){watching||(watching=!0,new this)}addClickHandler(handler){this.clickHandlers.push(handler.bind(this))}registerEventListeners(){document.addEventListener("click",function(e){const tableRoot=this.getTableRoot(e);tableRoot&&this.clickHandlers.forEach((handler=>handler(tableRoot,e)))}.bind(this))}getTableRoot(e){const tableRoot=e.target.closest(Selectors.main.region);return tableRoot||!1}setPluginState(methodname,plugin,state){return(0,_ajax.call)([{methodname:methodname,args:{plugin:plugin,state:state}}])[0]}async handleStateToggle(tableRoot,e){const stateToggle=e.target.closest('[data-action="togglestate"][data-toggle-method]');if(stateToggle){e.preventDefault();const pendingPromise=new _pending.default("core_table/dynamic:togglestate");await this.setPluginState(stateToggle.dataset.toggleMethod,stateToggle.dataset.plugin,"1"===stateToggle.dataset.state?0:1);const[updatedRoot]=await Promise.all([(0,_dynamic.refreshTableContent)(tableRoot),(0,_notification.fetchNotifications)()]);updatedRoot.querySelector('[data-action="togglestate"][data-plugin="'.concat(stateToggle.dataset.plugin,'"]')).focus(),pendingPromise.resolve()}}},_exports.default}));
+define("core_admin/plugin_management_table",["exports","core_table/dynamic","core_table/local/dynamic/selectors","core/ajax","core/pending","core/notification"],(function(_exports,_dynamic,Selectors,_ajax,_pending,_notification){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)}function _defineProperty(obj,key,value){return(key=function(arg){var key=function(input,hint){if("object"!=typeof input||null===input)return input;var prim=input[Symbol.toPrimitive];if(void 0!==prim){var res=prim.call(input,hint||"default");if("object"!=typeof res)return res;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===hint?String:Number)(input)}(arg,"string");return"symbol"==typeof key?key:String(key)}(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,Selectors=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}(Selectors),_pending=(obj=_pending)&&obj.__esModule?obj:{default:obj};let watching=!1;return _exports.default=class{constructor(){_defineProperty(this,"clickHandlers",[]),this.addClickHandler(this.handleStateToggle),this.addClickHandler(this.handleMoveUpDown),this.registerEventListeners()}static init(){watching||(watching=!0,new this)}addClickHandler(handler){this.clickHandlers.push(handler.bind(this))}registerEventListeners(){document.addEventListener("click",function(e){const tableRoot=this.getTableRoot(e);tableRoot&&this.clickHandlers.forEach((handler=>handler(tableRoot,e)))}.bind(this))}getTableRoot(e){const tableRoot=e.target.closest(Selectors.main.region);return tableRoot||!1}setPluginState(methodname,plugin,state){return(0,_ajax.call)([{methodname:methodname,args:{plugin:plugin,state:state}}])[0]}setPluginOrder(methodname,plugin,direction){return(0,_ajax.call)([{methodname:methodname,args:{plugin:plugin,direction:direction}}])[0]}async handleStateToggle(tableRoot,e){const stateToggle=e.target.closest('[data-action="togglestate"][data-toggle-method]');if(stateToggle){e.preventDefault();const pendingPromise=new _pending.default("core_table/dynamic:togglestate");await this.setPluginState(stateToggle.dataset.toggleMethod,stateToggle.dataset.plugin,"1"===stateToggle.dataset.state?0:1);const[updatedRoot]=await Promise.all([(0,_dynamic.refreshTableContent)(tableRoot),(0,_notification.fetchNotifications)()]);updatedRoot.querySelector('[data-action="togglestate"][data-plugin="'.concat(stateToggle.dataset.plugin,'"]')).focus(),pendingPromise.resolve()}}async handleMoveUpDown(tableRoot,e){const actionLink=e.target.closest('[data-action="move"][data-method][data-direction]');if(!actionLink)return;e.preventDefault();const pendingPromise=new _pending.default("core_table/dynamic:processAction");await this.setPluginOrder(actionLink.dataset.method,actionLink.dataset.plugin,"up"===actionLink.dataset.direction?-1:1);const[updatedRoot]=await Promise.all([(0,_dynamic.refreshTableContent)(tableRoot),(0,_notification.fetchNotifications)()]),exactMatch=updatedRoot.querySelector('[data-action="move"][data-plugin="'.concat(actionLink.dataset.plugin,'"][data-direction="').concat(actionLink.dataset.direction,'"]'));var _updatedRoot$querySel;exactMatch?exactMatch.focus():null===(_updatedRoot$querySel=updatedRoot.querySelector('[data-action="move"][data-plugin="'.concat(actionLink.dataset.plugin,'"]')))||void 0===_updatedRoot$querySel||_updatedRoot$querySel.focus();pendingPromise.resolve()}},_exports.default}));
//# sourceMappingURL=plugin_management_table.min.js.map
\ No newline at end of file
diff --git a/admin/amd/build/plugin_management_table.min.js.map b/admin/amd/build/plugin_management_table.min.js.map
index 719b4853f71..d20ef08f086 100644
--- a/admin/amd/build/plugin_management_table.min.js.map
+++ b/admin/amd/build/plugin_management_table.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"plugin_management_table.min.js","sources":["../src/plugin_management_table.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\nimport {refreshTableContent} from 'core_table/dynamic';\nimport * as Selectors from 'core_table/local/dynamic/selectors';\nimport {call as fetchMany} from 'core/ajax';\nimport Pending from 'core/pending';\nimport {fetchNotifications} from 'core/notification';\n\nlet watching = false;\n\nexport default class {\n /**\n * @property {function[]} clickHandlers a list of handlers to call on click.\n */\n clickHandlers = [];\n\n constructor() {\n this.addClickHandler(this.handleStateToggle);\n this.registerEventListeners();\n }\n\n /**\n * Initialise an instance of the class.\n *\n * This is just a way of making it easier to initialise an instance of the class from PHP.\n */\n static init() {\n if (watching) {\n return;\n }\n watching = true;\n new this();\n }\n\n /**\n * Add a click handler to the list of handlers.\n *\n * @param {Function} handler A handler to call on a click event\n */\n addClickHandler(handler) {\n this.clickHandlers.push(handler.bind(this));\n }\n\n /**\n * Register the event listeners for this instance.\n */\n registerEventListeners() {\n document.addEventListener('click', function(e) {\n const tableRoot = this.getTableRoot(e);\n\n if (!tableRoot) {\n return;\n }\n\n this.clickHandlers.forEach((handler) => handler(tableRoot, e));\n }.bind(this));\n }\n\n /**\n * Get the table root from an event.\n *\n * @param {Event} e\n * @returns {HTMLElement|bool}\n */\n getTableRoot(e) {\n const tableRoot = e.target.closest(Selectors.main.region);\n if (!tableRoot) {\n return false;\n }\n\n return tableRoot;\n }\n\n /**\n * Set the plugin state (enabled or disabled)\n *\n * @param {string} methodname The web service to call\n * @param {string} plugin The name of the plugin to set the state for\n * @param {number} state The state to set\n * @returns {Promise}\n */\n setPluginState(methodname, plugin, state) {\n return fetchMany([{\n methodname,\n args: {\n plugin,\n state,\n },\n }])[0];\n }\n\n /**\n * Handle state toggling.\n *\n * @param {HTMLElement} tableRoot\n * @param {Event} e\n */\n async handleStateToggle(tableRoot, e) {\n const stateToggle = e.target.closest('[data-action=\"togglestate\"][data-toggle-method]');\n if (stateToggle) {\n e.preventDefault();\n const pendingPromise = new Pending('core_table/dynamic:togglestate');\n\n await this.setPluginState(\n stateToggle.dataset.toggleMethod,\n stateToggle.dataset.plugin,\n stateToggle.dataset.state === '1' ? 0 : 1\n );\n\n const [updatedRoot] = await Promise.all([\n refreshTableContent(tableRoot),\n fetchNotifications(),\n ]);\n\n // Refocus on the link that as pressed in the first place.\n updatedRoot.querySelector(`[data-action=\"togglestate\"][data-plugin=\"${stateToggle.dataset.plugin}\"]`).focus();\n pendingPromise.resolve();\n }\n }\n}\n"],"names":["obj","_getRequireWildcardCache","nodeInterop","WeakMap","cacheBabelInterop","cacheNodeInterop","_defineProperty","key","value","arg","input","hint","prim","Symbol","toPrimitive","undefined","res","call","TypeError","String","Number","_toPrimitive","_toPropertyKey","Object","defineProperty","enumerable","configurable","writable","Selectors","__esModule","default","cache","has","get","newObj","hasPropertyDescriptor","getOwnPropertyDescriptor","prototype","hasOwnProperty","desc","set","_interopRequireWildcard","_pending","watching","_exports","constructor","this","addClickHandler","handleStateToggle","registerEventListeners","static","handler","clickHandlers","push","bind","document","addEventListener","e","tableRoot","getTableRoot","forEach","target","closest","main","region","setPluginState","methodname","plugin","state","fetchMany","args","async","stateToggle","preventDefault","pendingPromise","Pending","dataset","toggleMethod","updatedRoot","Promise","all","refreshTableContent","fetchNotifications","querySelector","concat","focus","resolve"],"mappings":"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"}
\ No newline at end of file
+{"version":3,"file":"plugin_management_table.min.js","sources":["../src/plugin_management_table.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\nimport {refreshTableContent} from 'core_table/dynamic';\nimport * as Selectors from 'core_table/local/dynamic/selectors';\nimport {call as fetchMany} from 'core/ajax';\nimport Pending from 'core/pending';\nimport {fetchNotifications} from 'core/notification';\n\nlet watching = false;\n\nexport default class {\n /**\n * @property {function[]} clickHandlers a list of handlers to call on click.\n */\n clickHandlers = [];\n\n constructor() {\n this.addClickHandler(this.handleStateToggle);\n this.addClickHandler(this.handleMoveUpDown);\n this.registerEventListeners();\n }\n\n /**\n * Initialise an instance of the class.\n *\n * This is just a way of making it easier to initialise an instance of the class from PHP.\n */\n static init() {\n if (watching) {\n return;\n }\n watching = true;\n new this();\n }\n\n /**\n * Add a click handler to the list of handlers.\n *\n * @param {Function} handler A handler to call on a click event\n */\n addClickHandler(handler) {\n this.clickHandlers.push(handler.bind(this));\n }\n\n /**\n * Register the event listeners for this instance.\n */\n registerEventListeners() {\n document.addEventListener('click', function(e) {\n const tableRoot = this.getTableRoot(e);\n\n if (!tableRoot) {\n return;\n }\n\n this.clickHandlers.forEach((handler) => handler(tableRoot, e));\n }.bind(this));\n }\n\n /**\n * Get the table root from an event.\n *\n * @param {Event} e\n * @returns {HTMLElement|bool}\n */\n getTableRoot(e) {\n const tableRoot = e.target.closest(Selectors.main.region);\n if (!tableRoot) {\n return false;\n }\n\n return tableRoot;\n }\n\n /**\n * Set the plugin state (enabled or disabled)\n *\n * @param {string} methodname The web service to call\n * @param {string} plugin The name of the plugin to set the state for\n * @param {number} state The state to set\n * @returns {Promise}\n */\n setPluginState(methodname, plugin, state) {\n return fetchMany([{\n methodname,\n args: {\n plugin,\n state,\n },\n }])[0];\n }\n\n setPluginOrder(methodname, plugin, direction) {\n return fetchMany([{\n methodname,\n args: {\n plugin,\n direction,\n },\n }])[0];\n }\n\n /**\n * Handle state toggling.\n *\n * @param {HTMLElement} tableRoot\n * @param {Event} e\n */\n async handleStateToggle(tableRoot, e) {\n const stateToggle = e.target.closest('[data-action=\"togglestate\"][data-toggle-method]');\n if (stateToggle) {\n e.preventDefault();\n const pendingPromise = new Pending('core_table/dynamic:togglestate');\n\n await this.setPluginState(\n stateToggle.dataset.toggleMethod,\n stateToggle.dataset.plugin,\n stateToggle.dataset.state === '1' ? 0 : 1\n );\n\n const [updatedRoot] = await Promise.all([\n refreshTableContent(tableRoot),\n fetchNotifications(),\n ]);\n\n // Refocus on the link that as pressed in the first place.\n updatedRoot.querySelector(`[data-action=\"togglestate\"][data-plugin=\"${stateToggle.dataset.plugin}\"]`).focus();\n pendingPromise.resolve();\n }\n }\n\n async handleMoveUpDown(tableRoot, e) {\n const actionLink = e.target.closest('[data-action=\"move\"][data-method][data-direction]');\n if (!actionLink) {\n return;\n }\n\n e.preventDefault();\n\n const pendingPromise = new Pending('core_table/dynamic:processAction');\n\n await this.setPluginOrder(\n actionLink.dataset.method,\n actionLink.dataset.plugin,\n actionLink.dataset.direction === 'up' ? -1 : 1,\n );\n\n const [updatedRoot] = await Promise.all([\n refreshTableContent(tableRoot),\n fetchNotifications(),\n ]);\n\n // Refocus on the link that as pressed in the first place.\n const exactMatch = updatedRoot.querySelector(\n `[data-action=\"move\"][data-plugin=\"${actionLink.dataset.plugin}\"][data-direction=\"${actionLink.dataset.direction}\"]`\n );\n if (exactMatch) {\n exactMatch.focus();\n } else {\n // The move link is not present anymore, so we need to focus on the other one.\n updatedRoot.querySelector(`[data-action=\"move\"][data-plugin=\"${actionLink.dataset.plugin}\"]`)?.focus();\n }\n\n pendingPromise.resolve();\n }\n}\n"],"names":["obj","_getRequireWildcardCache","nodeInterop","WeakMap","cacheBabelInterop","cacheNodeInterop","_defineProperty","key","value","arg","input","hint","prim","Symbol","toPrimitive","undefined","res","call","TypeError","String","Number","_toPrimitive","_toPropertyKey","Object","defineProperty","enumerable","configurable","writable","Selectors","__esModule","default","cache","has","get","newObj","hasPropertyDescriptor","getOwnPropertyDescriptor","prototype","hasOwnProperty","desc","set","_interopRequireWildcard","_pending","watching","_exports","constructor","this","addClickHandler","handleStateToggle","handleMoveUpDown","registerEventListeners","static","handler","clickHandlers","push","bind","document","addEventListener","e","tableRoot","getTableRoot","forEach","target","closest","main","region","setPluginState","methodname","plugin","state","fetchMany","args","setPluginOrder","direction","async","stateToggle","preventDefault","pendingPromise","Pending","dataset","toggleMethod","updatedRoot","Promise","all","refreshTableContent","fetchNotifications","querySelector","concat","focus","resolve","actionLink","method","exactMatch","_updatedRoot$querySel"],"mappings":"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"}
\ No newline at end of file
diff --git a/admin/amd/src/plugin_management_table.js b/admin/amd/src/plugin_management_table.js
index 622f4e6e02d..6a6644b98bf 100644
--- a/admin/amd/src/plugin_management_table.js
+++ b/admin/amd/src/plugin_management_table.js
@@ -29,6 +29,7 @@ export default class {
constructor() {
this.addClickHandler(this.handleStateToggle);
+ this.addClickHandler(this.handleMoveUpDown);
this.registerEventListeners();
}
@@ -102,6 +103,16 @@ export default class {
}])[0];
}
+ setPluginOrder(methodname, plugin, direction) {
+ return fetchMany([{
+ methodname,
+ args: {
+ plugin,
+ direction,
+ },
+ }])[0];
+ }
+
/**
* Handle state toggling.
*
@@ -130,4 +141,39 @@ export default class {
pendingPromise.resolve();
}
}
+
+ async handleMoveUpDown(tableRoot, e) {
+ const actionLink = e.target.closest('[data-action="move"][data-method][data-direction]');
+ if (!actionLink) {
+ return;
+ }
+
+ e.preventDefault();
+
+ const pendingPromise = new Pending('core_table/dynamic:processAction');
+
+ await this.setPluginOrder(
+ actionLink.dataset.method,
+ actionLink.dataset.plugin,
+ actionLink.dataset.direction === 'up' ? -1 : 1,
+ );
+
+ const [updatedRoot] = await Promise.all([
+ refreshTableContent(tableRoot),
+ fetchNotifications(),
+ ]);
+
+ // Refocus on the link that as pressed in the first place.
+ const exactMatch = updatedRoot.querySelector(
+ `[data-action="move"][data-plugin="${actionLink.dataset.plugin}"][data-direction="${actionLink.dataset.direction}"]`
+ );
+ if (exactMatch) {
+ exactMatch.focus();
+ } else {
+ // The move link is not present anymore, so we need to focus on the other one.
+ updatedRoot.querySelector(`[data-action="move"][data-plugin="${actionLink.dataset.plugin}"]`)?.focus();
+ }
+
+ pendingPromise.resolve();
+ }
}
diff --git a/admin/classes/external/set_plugin_order.php b/admin/classes/external/set_plugin_order.php
new file mode 100644
index 00000000000..d3cf3cd30a8
--- /dev/null
+++ b/admin/classes/external/set_plugin_order.php
@@ -0,0 +1,84 @@
+.
+
+namespace core_admin\external;
+
+use core_external\external_api;
+use core_external\external_function_parameters;
+use core_external\external_single_structure;
+use core_external\external_value;
+
+/**
+ * Web Service to control the order of a plugin.
+ *
+ * @package core_admin
+ * @category external
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class set_plugin_order extends external_api {
+ /**
+ * Returns description of method parameters
+ *
+ * @return external_function_parameters
+ */
+ public static function execute_parameters(): external_function_parameters {
+ return new external_function_parameters([
+ 'plugin' => new external_value(PARAM_PLUGIN, 'The name of the plugin', VALUE_REQUIRED),
+ 'direction' => new external_value(PARAM_INT, 'The direction to move', VALUE_REQUIRED),
+ ]);
+ }
+
+ /**
+ * Set the plugin state.
+ *
+ * @param string $plugin The name of the plugin
+ * @param int $direction The direction to move the plugin
+ * @return array
+ */
+ public static function execute(
+ string $plugin,
+ int $direction,
+ ): array {
+ [
+ 'plugin' => $plugin,
+ 'direction' => $direction,
+ ] = self::validate_parameters(self::execute_parameters(), [
+ 'plugin' => $plugin,
+ 'direction' => $direction,
+ ]);
+
+ $context = \context_system::instance();
+ self::validate_context($context);
+ require_capability('moodle/site:config', $context);
+
+ [$plugintype, $pluginname] = explode('_', \core_component::normalize_componentname($plugin), 2);
+
+ $manager = \core_plugin_manager::resolve_plugininfo_class($plugintype);
+ $manager::change_plugin_order($pluginname, $direction);
+
+ return [];
+ }
+
+ /**
+ * Describe the return structure of the external service.
+ *
+ * @return external_single_structure
+ */
+ public static function execute_returns(): external_single_structure {
+ return new external_single_structure([]);
+ }
+}
diff --git a/admin/classes/table/editor_management_table.php b/admin/classes/table/editor_management_table.php
index a8f566cceca..556d55fc18e 100644
--- a/admin/classes/table/editor_management_table.php
+++ b/admin/classes/table/editor_management_table.php
@@ -40,10 +40,6 @@ class editor_management_table extends \core_admin\table\plugin_management_table
return new moodle_url('/admin/editors.php', $params);
}
- protected function supports_ordering(): bool {
- return true;
- }
-
protected function order_plugins(array $plugins): array {
global $CFG;
diff --git a/admin/classes/table/plugin_management_table.php b/admin/classes/table/plugin_management_table.php
index 712ef4e944d..b7d1f5a1aed 100644
--- a/admin/classes/table/plugin_management_table.php
+++ b/admin/classes/table/plugin_management_table.php
@@ -65,10 +65,6 @@ abstract class plugin_management_table extends flexible_table implements dynamic
return $plugin->is_enabled();
}));
- $this->enabledplugincount = count(array_filter($this->plugins, function ($plugin) {
- return $plugin->is_enabled();
- }));
-
$this->setup_column_configuration();
$this->set_filterset(new plugin_management_table_filterset());
$this->setup();
@@ -80,8 +76,12 @@ abstract class plugin_management_table extends flexible_table implements dynamic
* @return \core\plugininfo\base[]
*/
protected function get_sorted_plugins(): array {
- $plugins = $this->pluginmanager->get_plugins_of_type($this->get_plugintype());
- return self::sort_plugins($plugins);
+ if ($this->plugininfoclass::plugintype_supports_ordering()) {
+ return $this->plugininfoclass::get_sorted_plugins();
+ } else {
+ $plugins = $this->pluginmanager->get_plugins_of_type($this->get_plugintype());
+ return self::sort_plugins($plugins);
+ }
}
/**
@@ -176,7 +176,7 @@ abstract class plugin_management_table extends flexible_table implements dynamic
* @return null|string
*/
protected function get_sortorder_service(): ?string {
- return null;
+ return 'core_admin_set_plugin_order';
}
/**
@@ -348,7 +348,7 @@ abstract class plugin_management_table extends flexible_table implements dynamic
$hasdown = false;
}
- if ($this->get_sortorder_service()) {
+ if ($this->supports_ordering()) {
$dataattributes = [
'data-method' => $this->get_sortorder_service(),
'data-action' => 'move',
@@ -490,6 +490,6 @@ abstract class plugin_management_table extends flexible_table implements dynamic
* @return bool
*/
protected function supports_ordering(): bool {
- return false;
+ return $this->plugininfoclass::plugintype_supports_ordering();
}
}
diff --git a/admin/tests/external/set_plugin_order_test.php b/admin/tests/external/set_plugin_order_test.php
new file mode 100644
index 00000000000..474889ed070
--- /dev/null
+++ b/admin/tests/external/set_plugin_order_test.php
@@ -0,0 +1,170 @@
+.
+
+declare(strict_types=1);
+
+namespace core_admin\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+/**
+ * Unit tests to configure the plugin order.
+ *
+ * Note: Not all plugins can be ordered, so this test is limited to those which support it.
+ *
+ * @package core
+ * @covers \core_admin\external\set_plugin_state
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class set_plugin_order_test extends \externallib_advanced_testcase {
+ /**
+ * Text execute method for editor plugins, which support ordering.
+ *
+ * @dataProvider execute_editor_provider
+ * @param string $initialstate The initial state of the plugintype
+ * @param string $plugin The name of the plugin
+ * @param int $direction
+ * @param array $neworder
+ * @param string $newstate
+ */
+ public function test_execute_editors(
+ string $initialstate,
+ string $plugin,
+ int $direction,
+ array $neworder,
+ string $newstate,
+ ): void {
+ global $CFG;
+
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $CFG->texteditors = $initialstate;
+
+ set_plugin_order::execute($plugin, $direction);
+
+ $this->assertSame(
+ $neworder,
+ array_keys(\core\plugininfo\editor::get_sorted_plugins()),
+ );
+ $this->assertSame($newstate, $CFG->texteditors);
+ }
+
+ /**
+ * Data provider for base tests of the execute method.
+ *
+ * @return array
+ */
+ public function execute_editor_provider(): array {
+ return [
+ [
+ 'initialstate' => 'textarea,tiny',
+ 'pluginname' => 'editor_textarea',
+ 1, // DOWN.
+ 'expected' => [
+ 'tiny',
+ 'textarea',
+ 'atto',
+ 'tinymce',
+ ],
+ 'newtexteditors' => 'tiny,textarea',
+ ],
+ [
+ 'initialstate' => 'textarea,tiny',
+ 'pluginname' => 'editor_textarea',
+ -1, // UP.
+ 'expected' => [
+ 'textarea',
+ 'tiny',
+ 'atto',
+ 'tinymce',
+ ],
+ 'newtexteditors' => 'textarea,tiny',
+ ],
+ [
+ 'initialstate' => 'textarea,tiny',
+ 'pluginname' => 'editor_tiny',
+ 1, // DOWN.
+ // Tiny is already at the bottom of the list of enabled plugins.
+ 'expected' => [
+ 'textarea',
+ 'tiny',
+ 'atto',
+ 'tinymce',
+ ],
+ 'newtexteditors' => 'textarea,tiny',
+ ],
+ [
+ 'initialstate' => 'textarea,tiny',
+ 'pluginname' => 'editor_atto',
+ 1, // DOWN.
+ // Atto is not enabled. Disabled editors are listed lexically after enabled editors.
+ 'expected' => [
+ 'textarea',
+ 'tiny',
+ 'atto',
+ 'tinymce',
+ ],
+ 'newtexteditors' => 'textarea,tiny',
+ ],
+ ];
+ }
+
+ /**
+ * Text execute method for plugins which do not support ordering.
+ *
+ * @dataProvider execute_non_orderable_provider
+ * @param string $plugin
+ */
+ public function test_execute_editors_non_orderable(string $plugin): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $this->assertIsArray(set_plugin_order::execute($plugin, 1));
+ }
+
+ public function execute_non_orderable_provider(): array {
+ return [
+ // Activities do not support ordering.
+ ['mod_assign'],
+ // Nor to blocks.
+ ['block_login'],
+ ];
+ }
+
+ /**
+ * Test execute method with no login.
+ */
+ public function test_execute_no_login(): void {
+ $this->expectException(\require_login_exception::class);
+ set_plugin_order::execute('editor_tiny', 1);
+ }
+
+ /**
+ * Test execute method with no login.
+ */
+ public function test_execute_no_capability(): void {
+ $this->resetAfterTest();
+ $user = $this->getDataGenerator()->create_user();
+ $this->setUser($user);
+ $this->expectException(\required_capability_exception::class);
+ set_plugin_order::execute('editor_tiny', 1);
+ }
+}
diff --git a/lib/classes/plugininfo/base.php b/lib/classes/plugininfo/base.php
index ba9cc56d42c..b7ce65ff9bf 100644
--- a/lib/classes/plugininfo/base.php
+++ b/lib/classes/plugininfo/base.php
@@ -23,10 +23,10 @@
*/
namespace core\plugininfo;
-use core_component, core_plugin_manager, moodle_url, coding_exception;
-
-defined('MOODLE_INTERNAL') || die();
-
+use coding_exception;
+use core_component;
+use core_plugin_manager;
+use moodle_url;
/**
* Base class providing access to the information about a plugin
@@ -71,6 +71,12 @@ abstract class base {
/** @var array|null array of {@link \core\update\info} for this plugin */
protected $availableupdates;
+ /** @var int Move a plugin up in the plugin order */
+ public const MOVE_UP = -1;
+
+ /** @var int Move a plugin down in the plugin order */
+ public const MOVE_DOWN = 1;
+
/**
* Whether this plugintype supports its plugins being disabled.
*
@@ -665,4 +671,39 @@ abstract class base {
'return' => $return,
));
}
+
+ /**
+ * Whether this plugintype supports ordering of plugins using native functionality.
+ *
+ * Please note that plugintypes which pre-date this native functionality may still support ordering
+ * but will not use the built-in functionality.
+ *
+ * @return bool
+ */
+ public static function plugintype_supports_ordering(): bool {
+ return false;
+ }
+
+ /**
+ * Finds all enabled plugins, the result may include missing plugins.
+ *
+ * @param bool $enabledonly Show all plugins, or only those which are enabled
+ * @return array|null of sorted plugins $pluginname => $pluginname, null means unknown
+ */
+ public static function get_sorted_plugins(bool $enabledonly = false): ?array {
+ return null;
+ }
+
+ /**
+ * Change the order of the plugin relative to other plugins in the plugintype.
+ *
+ * When possible, the change will be stored into the config_log table, to let admins check when/who has modified it.
+ *
+ * @param string $pluginname The plugin name to enable/disable.
+ * @param int $direction The direction to move the plugin. Negative numbers mean up, Positive mean down.
+ * @return bool Whether $pluginname has been updated or not.
+ */
+ public static function change_plugin_order(string $pluginname, int $direction): bool {
+ return false;
+ }
}
diff --git a/lib/classes/plugininfo/editor.php b/lib/classes/plugininfo/editor.php
index 2d19b0c8d09..d0547a91fab 100644
--- a/lib/classes/plugininfo/editor.php
+++ b/lib/classes/plugininfo/editor.php
@@ -23,10 +23,9 @@
*/
namespace core\plugininfo;
-use moodle_url, part_of_admin_tree, admin_settingpage;
-
-defined('MOODLE_INTERNAL') || die();
-
+use admin_settingpage;
+use moodle_url;
+use part_of_admin_tree;
/**
* Class for HTML editors
@@ -138,4 +137,76 @@ class editor extends base {
public static function get_manage_url() {
return new moodle_url('/admin/settings.php', array('section'=>'manageeditors'));
}
+
+ public static function plugintype_supports_ordering(): bool {
+ return true;
+ }
+
+ public static function get_sorted_plugins(bool $enabledonly = false): ?array {
+ global $CFG;
+
+ $pluginmanager = \core_plugin_manager::instance();
+ $plugins = $pluginmanager->get_plugins_of_type('editor');
+
+ // The Editor list is stored in an ordered string.
+ $activeeditors = explode(',', $CFG->texteditors);
+
+ $sortedplugins = [];
+ foreach ($activeeditors as $editor) {
+ if (isset($plugins[$editor])) {
+ $sortedplugins[$editor] = $plugins[$editor];
+ unset($plugins[$editor]);
+ }
+ }
+
+ if ($enabledonly) {
+ return $sortedplugins;
+ }
+
+ // Sort the rest of the plugins lexically.
+ uasort($plugins, function ($a, $b) {
+ return strnatcasecmp($a->name, $b->name);
+ });
+
+ return array_merge(
+ $sortedplugins,
+ $plugins,
+ );
+ }
+
+ public static function change_plugin_order(string $pluginname, int $direction): bool {
+ $activeeditors = array_keys(self::get_sorted_plugins(true));
+ $key = array_search($pluginname, $activeeditors);
+
+ if ($key === false) {
+ return false;
+ }
+
+ if ($direction === self::MOVE_DOWN) {
+ // Move down the list.
+ if ($key < (count($activeeditors) - 1)) {
+ $fsave = $activeeditors[$key];
+ $activeeditors[$key] = $activeeditors[$key + 1];
+ $activeeditors[$key + 1] = $fsave;
+ add_to_config_log('editor_position', $key, $key + 1, $pluginname);
+ set_config('texteditors', implode(',', $activeeditors));
+ \core_plugin_manager::reset_caches();
+
+ return true;
+ }
+ } else if ($direction === self::MOVE_UP) {
+ if ($key >= 1) {
+ $fsave = $activeeditors[$key];
+ $activeeditors[$key] = $activeeditors[$key - 1];
+ $activeeditors[$key - 1] = $fsave;
+ add_to_config_log('editor_position', $key, $key - 1, $pluginname);
+ set_config('texteditors', implode(',', $activeeditors));
+ \core_plugin_manager::reset_caches();
+
+ return true;
+ }
+ }
+
+ return false;
+ }
}
diff --git a/lib/db/services.php b/lib/db/services.php
index 4773429f4fa..df01c242b0e 100644
--- a/lib/db/services.php
+++ b/lib/db/services.php
@@ -2959,6 +2959,12 @@ $functions = array(
'type' => 'write',
'ajax' => true,
],
+ 'core_admin_set_plugin_order' => [
+ 'classname' => 'core_admin\external\set_plugin_order',
+ 'description' => 'Set the order of a plugin',
+ 'type' => 'write',
+ 'ajax' => true,
+ ],
);
$services = array(
diff --git a/lib/tests/plugininfo/base_test.php b/lib/tests/plugininfo/base_test.php
index 72f7c86f897..6a04b96aec6 100644
--- a/lib/tests/plugininfo/base_test.php
+++ b/lib/tests/plugininfo/base_test.php
@@ -14,7 +14,7 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see .
-declare(strict_types = 1);
+declare(strict_types=1);
namespace core\plugininfo;
@@ -272,4 +272,49 @@ class base_test extends \advanced_testcase {
],
];
}
+
+ /**
+ * Ensure that plugintype_supports_ordering() returns true.
+ * @covers ::plugintype_supports_ordering
+ */
+ public function test_plugintype_supports_ordering(): void {
+ $this->assertFalse(base::plugintype_supports_ordering());
+ }
+
+ /**
+ * Ensure that the base implementation is used for plugins not supporting ordering.
+ *
+ * @dataProvider plugins_not_supporting_ordering
+ * @param string $plugin
+ * @coversNothing
+ *
+ * Note: This test cannot declare coverage because it covers the various plugin implementations.
+ */
+ public function test_get_sorted_plugins(
+ string $plugin,
+ ): void {
+ [$plugintype, $pluginname] = explode('_', $plugin, 2);
+ $classname = \core_plugin_manager::resolve_plugininfo_class($plugintype);
+
+ $this->assertFalse($classname::plugintype_supports_ordering());
+
+ $this->assertNull($classname::get_sorted_plugins());
+ $this->assertNull($classname::get_sorted_plugins(true));
+ $this->assertNull($classname::get_sorted_plugins(false));
+
+ $this->assertFalse($classname::change_plugin_order($pluginname, base::MOVE_UP));
+ $this->assertFalse($classname::change_plugin_order($pluginname, base::MOVE_DOWN));
+ }
+
+ /**
+ * Data provider for plugins_not_supporting_ordering.
+ *
+ * @return string[]
+ */
+ public function plugins_not_supporting_ordering(): array {
+ return [
+ ['mod_assign'],
+ ['block_login'],
+ ];
+ }
}
diff --git a/lib/tests/plugininfo/editor_test.php b/lib/tests/plugininfo/editor_test.php
index a3d2b863ad5..11072692e82 100644
--- a/lib/tests/plugininfo/editor_test.php
+++ b/lib/tests/plugininfo/editor_test.php
@@ -65,4 +65,239 @@ class editor_test extends advanced_testcase {
$this->assertCount(1, $plugins);
$this->assertArrayHasKey('textarea', $plugins);
}
+
+ /**
+ * Ensure that plugintype_supports_ordering() returns true.
+ */
+ public function test_plugintype_supports_ordering(): void {
+ $this->assertTrue(editor::plugintype_supports_ordering());
+ }
+
+ /**
+ * Ensure that get_sorted_plugins() returns the correct list of plugins.
+ *
+ * @dataProvider get_sorted_plugins_provider
+ * @param string $texteditors The $CFG->texteditors value to use as a base
+ * @param bool $enabledonly
+ * @param array $expected The expected order
+ */
+ public function test_get_sorted_plugins(
+ string $texteditors,
+ bool $enabledonly,
+ array $expected,
+ ): void {
+ global $CFG;
+ $this->resetAfterTest(true);
+
+ $CFG->texteditors = $texteditors;
+ $this->assertSame(
+ $expected,
+ array_keys(editor::get_sorted_plugins($enabledonly)),
+ );
+ }
+
+ /**
+ * Data provider for the get_sorted_plugins tests.
+ *
+ * @return array
+ */
+ public function get_sorted_plugins_provider(): array {
+ return [
+ [
+ 'texteditors' => 'textarea,tiny',
+ 'enabledonly' => true,
+ 'expected' => [
+ 'textarea',
+ 'tiny',
+ ],
+ ],
+ [
+ 'texteditors' => 'tiny,textarea',
+ 'enabledonly' => true,
+ 'expected' => [
+ 'tiny',
+ 'textarea',
+ ],
+ ],
+ [
+ 'texteditors' => 'tiny',
+ 'enabledonly' => true,
+ 'expected' => [
+ 'tiny',
+ ],
+ ],
+ 'Phantom values are removed from the list' => [
+ 'texteditors' => 'fakeeditor',
+ 'enabledonly' => true,
+ 'expected' => [
+ ],
+ ],
+ [
+ 'texteditors' => 'textarea,tiny',
+ 'enabledonly' => false,
+ 'expected' => [
+ 'textarea',
+ 'tiny',
+
+ // Disabled editors are listed alphabetically at the end.
+ 'atto',
+ 'tinymce',
+ ],
+ ],
+ [
+ 'texteditors' => 'tiny',
+ 'enabledonly' => false,
+ 'expected' => [
+ 'tiny',
+
+ // Disabled editors are listed alphabetically at the end.
+ 'atto',
+ 'textarea',
+ 'tinymce',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Ensure that change_plugin_order() changes the order of the plugins.
+ *
+ * @dataProvider change_plugin_order_provider
+ * @param string $texteditors
+ * @param string $pluginname
+ * @param int $direction
+ * @param array $neworder
+ * @param string $newtexteditors
+ */
+ public function test_change_plugin_order(
+ string $texteditors,
+ string $pluginname,
+ int $direction,
+ array $neworder,
+ string $newtexteditors,
+ ): void {
+ global $CFG;
+ $this->resetAfterTest(true);
+
+ $CFG->texteditors = $texteditors;
+ editor::change_plugin_order($pluginname, $direction);
+
+ $this->assertSame(
+ $neworder,
+ array_keys(editor::get_sorted_plugins()),
+ );
+ $this->assertSame($newtexteditors, $CFG->texteditors);
+ }
+
+ /**
+ * Data provider fro the change_plugin_order() tests.
+ *
+ * @return array
+ */
+ public function change_plugin_order_provider(): array {
+ return [
+ [
+ 'texteditors' => 'textarea,tiny',
+ 'pluginname' => 'textarea',
+ 'direction' => base::MOVE_DOWN,
+ 'expected' => [
+ 'tiny',
+ 'textarea',
+ 'atto',
+ 'tinymce',
+ ],
+ 'newtexteditors' => 'tiny,textarea',
+ ],
+ [
+ 'texteditors' => 'textarea,tiny',
+ 'pluginname' => 'tiny',
+ 'direction' => base::MOVE_DOWN,
+ // Tiny is already at the bottom of the enabled plugins.
+ 'expected' => [
+ 'textarea',
+ 'tiny',
+ 'atto',
+ 'tinymce',
+ ],
+ 'newtexteditors' => 'textarea,tiny',
+ ],
+ [
+ 'texteditors' => 'textarea,tiny',
+ 'pluginname' => 'atto',
+ 'direction' => base::MOVE_DOWN,
+ // Atto is not enabled. No change expected.
+ 'expected' => [
+ 'textarea',
+ 'tiny',
+ 'atto',
+ 'tinymce',
+ ],
+ 'newtexteditors' => 'textarea,tiny',
+ ],
+ [
+ 'texteditors' => 'textarea,tiny',
+ 'pluginname' => 'tiny',
+ 'direction' => base::MOVE_UP,
+ 'expected' => [
+ 'tiny',
+ 'textarea',
+ 'atto',
+ 'tinymce',
+ ],
+ 'newtexteditors' => 'tiny,textarea',
+ ],
+ [
+ 'texteditors' => 'textarea,tiny',
+ 'pluginname' => 'tiny',
+ 'direction' => base::MOVE_UP,
+ // Tiny is already at the top of the enabled plugins.
+ 'expected' => [
+ 'tiny',
+ 'textarea',
+ 'atto',
+ 'tinymce',
+ ],
+ 'newtexteditors' => 'tiny,textarea',
+ ],
+ [
+ 'texteditors' => 'textarea,tiny',
+ 'pluginname' => 'atto',
+ 'direction' => base::MOVE_UP,
+ // Atto is not enabled. No change expected.
+ 'expected' => [
+ 'textarea',
+ 'tiny',
+ 'atto',
+ 'tinymce',
+ ],
+ 'newtexteditors' => 'textarea,tiny',
+ ],
+ [
+ 'texteditors' => 'textarea,tiny',
+ 'pluginname' => 'tinymce',
+ 'direction' => base::MOVE_UP,
+ // TinyMCE is not enabled. No change expected.
+ 'expected' => [
+ 'textarea',
+ 'tiny',
+ 'atto',
+ 'tinymce',
+ ],
+ 'newtexteditors' => 'textarea,tiny',
+ ],
+ [
+ 'texteditors' => 'textarea,tiny',
+ 'pluginname' => 'fakeeditor',
+ 'direction' => base::MOVE_UP,
+ // The fakeeditor plugin does not exist. No change expected.
+ 'expected' => [
+ 'textarea',
+ 'tiny',
+ 'atto',
+ 'tinymce',
+ ],
+ 'newtexteditors' => 'textarea,tiny',
+ ],
+ ];
+ }
}
diff --git a/version.php b/version.php
index 467637cedb4..c1d7010490f 100644
--- a/version.php
+++ b/version.php
@@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
-$version = 2023031000.01; // YYYYMMDD = weekly release date of this DEV branch.
+$version = 2023031000.02; // YYYYMMDD = weekly release date of this DEV branch.
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.
$release = '4.2dev (Build: 20230310)'; // Human-friendly version name