From a0001c8776b648b38aa317da422b823bbcbf4eb5 Mon Sep 17 00:00:00 2001 From: David Carrillo Date: Fri, 29 Aug 2025 16:11:15 +0200 Subject: [PATCH] MDL-86065 customfield: Allow to select shared categories on each entity --- .../amd/build/repository/toggle_shared.min.js | 10 ++ .../build/repository/toggle_shared.min.js.map | 1 + .../amd/build/toggle_shared.min.js | 3 + .../amd/build/toggle_shared.min.js.map | 1 + .../amd/src/repository/toggle_shared.js | 44 +++++++ public/customfield/amd/src/toggle_shared.js | 63 ++++++++++ public/customfield/classes/api.php | 20 +++ .../event/shared_category_usage_disabled.php | 85 +++++++++++++ .../event/shared_category_usage_enabled.php | 85 +++++++++++++ .../external/toggle_shared_category.php | 116 ++++++++++++++++++ public/customfield/classes/handler.php | 14 ++- .../customfield/classes/output/management.php | 31 ++++- public/customfield/classes/shared.php | 57 +++++++++ public/customfield/externallib.php | 1 + public/customfield/templates/list.mustache | 10 +- .../tests/behat/shared_custom_fields.feature | 28 +++++ .../external/toggle_shared_category_test.php | 112 +++++++++++++++++ public/lang/en/customfield.php | 3 + public/lib/db/install.xml | 20 +++ public/lib/db/services.php | 7 ++ 20 files changed, 707 insertions(+), 4 deletions(-) create mode 100644 public/customfield/amd/build/repository/toggle_shared.min.js create mode 100644 public/customfield/amd/build/repository/toggle_shared.min.js.map create mode 100644 public/customfield/amd/build/toggle_shared.min.js create mode 100644 public/customfield/amd/build/toggle_shared.min.js.map create mode 100644 public/customfield/amd/src/repository/toggle_shared.js create mode 100644 public/customfield/amd/src/toggle_shared.js create mode 100644 public/customfield/classes/event/shared_category_usage_disabled.php create mode 100644 public/customfield/classes/event/shared_category_usage_enabled.php create mode 100755 public/customfield/classes/external/toggle_shared_category.php create mode 100644 public/customfield/classes/shared.php create mode 100644 public/customfield/tests/external/toggle_shared_category_test.php diff --git a/public/customfield/amd/build/repository/toggle_shared.min.js b/public/customfield/amd/build/repository/toggle_shared.min.js new file mode 100644 index 00000000000..331b1b46a5e --- /dev/null +++ b/public/customfield/amd/build/repository/toggle_shared.min.js @@ -0,0 +1,10 @@ +define("core_customfield/repository/toggle_shared",["exports","core/ajax"],(function(_exports,_ajax){var obj; +/** + * Module to handle toggle shared category AJAX requests + * + * @module core_customfield/repository/toggle_shared + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.toggleCategory=void 0,_ajax=(obj=_ajax)&&obj.__esModule?obj:{default:obj};_exports.toggleCategory=(categoryId,component,area,itemid,state)=>{const request={methodname:"core_customfield_toggle_shared",args:{categoryid:categoryId,component:component,area:area,itemid:itemid,state:state}};return _ajax.default.call([request])[0]}})); + +//# sourceMappingURL=toggle_shared.min.js.map \ No newline at end of file diff --git a/public/customfield/amd/build/repository/toggle_shared.min.js.map b/public/customfield/amd/build/repository/toggle_shared.min.js.map new file mode 100644 index 00000000000..9a4559b7c0c --- /dev/null +++ b/public/customfield/amd/build/repository/toggle_shared.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"toggle_shared.min.js","sources":["../../src/repository/toggle_shared.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 * Module to handle toggle shared category AJAX requests\n *\n * @module core_customfield/repository/toggle_shared\n * @copyright 2025 David Carrillo \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Ajax from 'core/ajax';\n\n/**\n * Toggle shared category enabled\n *\n * @method\n * @param {Number} categoryId\n * @param {String} component\n * @param {String} area\n * @param {Number} itemid\n * @param {Boolean} state\n * @return {Promise}\n */\nexport const toggleCategory = (categoryId, component, area, itemid, state) => {\n const request = {\n methodname: 'core_customfield_toggle_shared',\n args: {categoryid: categoryId, component: component, area: area, itemid: itemid, state: state}\n };\n\n return Ajax.call([request])[0];\n};\n"],"names":["categoryId","component","area","itemid","state","request","methodname","args","categoryid","Ajax","call"],"mappings":";;;;;;;wKAoC8B,CAACA,WAAYC,UAAWC,KAAMC,OAAQC,eAC1DC,QAAU,CACZC,WAAY,iCACZC,KAAM,CAACC,WAAYR,WAAYC,UAAWA,UAAWC,KAAMA,KAAMC,OAAQA,OAAQC,MAAOA,eAGrFK,cAAKC,KAAK,CAACL,UAAU"} \ No newline at end of file diff --git a/public/customfield/amd/build/toggle_shared.min.js b/public/customfield/amd/build/toggle_shared.min.js new file mode 100644 index 00000000000..0bfa577552d --- /dev/null +++ b/public/customfield/amd/build/toggle_shared.min.js @@ -0,0 +1,3 @@ +define("core_customfield/toggle_shared",["exports","core/inplace_editable","core/notification","core/pending","core_customfield/repository/toggle_shared"],(function(_exports,_inplace_editable,_notification,_pending,_toggle_shared){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_notification=_interopRequireDefault(_notification),_pending=_interopRequireDefault(_pending);let initialized=!1;_exports.init=()=>{initialized||(document.addEventListener("click",(event=>{const sharedToggle=event.target.closest('[data-action="shared-toggle"]');if(sharedToggle){const pendingPromise=new _pending.default("core_customfield/shared:toggle"),categoryId=sharedToggle.dataset.id,component=sharedToggle.dataset.component,area=sharedToggle.dataset.area,itemid=sharedToggle.dataset.itemid,sharedStateToggle=+!Number(sharedToggle.dataset.state);(0,_toggle_shared.toggleCategory)(categoryId,component,area,itemid,sharedStateToggle).then((()=>(sharedToggle.dataset.state=sharedStateToggle,pendingPromise.resolve()))).catch(_notification.default.exception)}})),initialized=!0)}})); + +//# sourceMappingURL=toggle_shared.min.js.map \ No newline at end of file diff --git a/public/customfield/amd/build/toggle_shared.min.js.map b/public/customfield/amd/build/toggle_shared.min.js.map new file mode 100644 index 00000000000..829a187418b --- /dev/null +++ b/public/customfield/amd/build/toggle_shared.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"toggle_shared.min.js","sources":["../src/toggle_shared.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 * Custom fields shared category toggle\n *\n * @module core_customfield/toggle_shared\n * @copyright 2025 David Carrillo \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n\"use strict\";\n\nimport 'core/inplace_editable';\nimport Notification from 'core/notification';\nimport Pending from 'core/pending';\nimport {toggleCategory} from 'core_customfield/repository/toggle_shared';\n\nlet initialized = false;\n\n/**\n * Initialise toggle\n */\nexport const init = () => {\n if (initialized) {\n // We already added the event listeners (can be called multiple times by mustache template).\n return;\n }\n\n document.addEventListener('click', event => {\n // Toggle shared category.\n const sharedToggle = event.target.closest('[data-action=\"shared-toggle\"]');\n if (sharedToggle) {\n const pendingPromise = new Pending('core_customfield/shared:toggle');\n const categoryId = sharedToggle.dataset.id;\n const component = sharedToggle.dataset.component;\n const area = sharedToggle.dataset.area;\n const itemid = sharedToggle.dataset.itemid;\n const sharedStateToggle = +!Number(sharedToggle.dataset.state);\n\n toggleCategory(categoryId, component, area, itemid, sharedStateToggle)\n .then(() => {\n sharedToggle.dataset.state = sharedStateToggle;\n return pendingPromise.resolve();\n })\n .catch(Notification.exception);\n }\n });\n\n initialized = true;\n};\n"],"names":["initialized","document","addEventListener","event","sharedToggle","target","closest","pendingPromise","Pending","categoryId","dataset","id","component","area","itemid","sharedStateToggle","Number","state","then","resolve","catch","Notification","exception"],"mappings":"weA8BIA,aAAc,gBAKE,KACZA,cAKJC,SAASC,iBAAiB,SAASC,cAEzBC,aAAeD,MAAME,OAAOC,QAAQ,oCACtCF,aAAc,OACRG,eAAiB,IAAIC,iBAAQ,kCAC7BC,WAAaL,aAAaM,QAAQC,GAClCC,UAAYR,aAAaM,QAAQE,UACjCC,KAAOT,aAAaM,QAAQG,KAC5BC,OAASV,aAAaM,QAAQI,OAC9BC,oBAAsBC,OAAOZ,aAAaM,QAAQO,yCAEzCR,WAAYG,UAAWC,KAAMC,OAAQC,mBAC/CG,MAAK,KACFd,aAAaM,QAAQO,MAAQF,kBACtBR,eAAeY,aAEzBC,MAAMC,sBAAaC,eAIhCtB,aAAc"} \ No newline at end of file diff --git a/public/customfield/amd/src/repository/toggle_shared.js b/public/customfield/amd/src/repository/toggle_shared.js new file mode 100644 index 00000000000..809a05d630b --- /dev/null +++ b/public/customfield/amd/src/repository/toggle_shared.js @@ -0,0 +1,44 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Module to handle toggle shared category AJAX requests + * + * @module core_customfield/repository/toggle_shared + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import Ajax from 'core/ajax'; + +/** + * Toggle shared category enabled + * + * @method + * @param {Number} categoryId + * @param {String} component + * @param {String} area + * @param {Number} itemid + * @param {Boolean} state + * @return {Promise} + */ +export const toggleCategory = (categoryId, component, area, itemid, state) => { + const request = { + methodname: 'core_customfield_toggle_shared', + args: {categoryid: categoryId, component: component, area: area, itemid: itemid, state: state} + }; + + return Ajax.call([request])[0]; +}; diff --git a/public/customfield/amd/src/toggle_shared.js b/public/customfield/amd/src/toggle_shared.js new file mode 100644 index 00000000000..734773c6403 --- /dev/null +++ b/public/customfield/amd/src/toggle_shared.js @@ -0,0 +1,63 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Custom fields shared category toggle + * + * @module core_customfield/toggle_shared + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +"use strict"; + +import 'core/inplace_editable'; +import Notification from 'core/notification'; +import Pending from 'core/pending'; +import {toggleCategory} from 'core_customfield/repository/toggle_shared'; + +let initialized = false; + +/** + * Initialise toggle + */ +export const init = () => { + if (initialized) { + // We already added the event listeners (can be called multiple times by mustache template). + return; + } + + document.addEventListener('click', event => { + // Toggle shared category. + const sharedToggle = event.target.closest('[data-action="shared-toggle"]'); + if (sharedToggle) { + const pendingPromise = new Pending('core_customfield/shared:toggle'); + const categoryId = sharedToggle.dataset.id; + const component = sharedToggle.dataset.component; + const area = sharedToggle.dataset.area; + const itemid = sharedToggle.dataset.itemid; + const sharedStateToggle = +!Number(sharedToggle.dataset.state); + + toggleCategory(categoryId, component, area, itemid, sharedStateToggle) + .then(() => { + sharedToggle.dataset.state = sharedStateToggle; + return pendingPromise.resolve(); + }) + .catch(Notification.exception); + } + }); + + initialized = true; +}; diff --git a/public/customfield/classes/api.php b/public/customfield/classes/api.php index 13f7ecbd8f0..cbec0dc9577 100644 --- a/public/customfield/classes/api.php +++ b/public/customfield/classes/api.php @@ -467,4 +467,24 @@ class api { } return $ret; } + + /** + * Checks if a shared category is enabled for the given entity + * + * @param int $categoryid + * @param string $component + * @param string $area + * @param int $itemid + * @return bool + */ + public static function is_shared_category_enabled(int $categoryid, string $component, string $area, int $itemid): bool { + $sql = "categoryid = :categoryid AND component = :component AND area = :area AND itemid = :itemid"; + $params = [ + 'categoryid' => $categoryid, + 'component' => $component, + 'area' => $area, + 'itemid' => $itemid, + ]; + return shared::record_exists_select($sql, $params); + } } diff --git a/public/customfield/classes/event/shared_category_usage_disabled.php b/public/customfield/classes/event/shared_category_usage_disabled.php new file mode 100644 index 00000000000..0ac38ebfdb5 --- /dev/null +++ b/public/customfield/classes/event/shared_category_usage_disabled.php @@ -0,0 +1,85 @@ +. + +declare(strict_types=1); + +namespace core_customfield\event; + +use context; +use core_customfield\shared; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Custom field shared category usage disabled event class. + * + * @package core_customfield + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class shared_category_usage_disabled extends \core\event\base { + + /** + * Initialise the event data. + */ + protected function init(): void { + $this->data['objecttable'] = 'customfield_shared'; + $this->data['crud'] = 'd'; + $this->data['edulevel'] = self::LEVEL_OTHER; + } + + /** + * Creates an instance from a shared object + * + * @param shared $shared + * @param context $context + * @return category_updated + */ + public static function create_from_object(shared $shared, context $context): shared_category_usage_disabled { + $eventparams = [ + 'objectid' => $shared->get('id'), + 'context' => $context, + 'other' => [ + 'categoryid' => $shared->get('categoryid'), + 'component' => $shared->get('component'), + 'area' => $shared->get('area'), + 'itemid' => $shared->get('itemid'), + ], + ]; + $event = self::create($eventparams); + $event->add_record_snapshot($event->objecttable, $shared->to_record()); + return $event; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name(): string { + return get_string('eventsharedcategoryusagedisabled', 'core_customfield'); + } + + /** + * Returns non-localised description of what happened. + * + * @return string + */ + public function get_description(): string { + return "The user with ID '$this->userid' disabled usage of shared category with ID '{$this->other['categoryid']}'" . + " in '{$this->other['component']}/{$this->other['area']}'"; + } +} diff --git a/public/customfield/classes/event/shared_category_usage_enabled.php b/public/customfield/classes/event/shared_category_usage_enabled.php new file mode 100644 index 00000000000..a4614a03b7d --- /dev/null +++ b/public/customfield/classes/event/shared_category_usage_enabled.php @@ -0,0 +1,85 @@ +. + +declare(strict_types=1); + +namespace core_customfield\event; + +use context; +use core_customfield\shared; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Custom field shared category usage enabled event class. + * + * @package core_customfield + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class shared_category_usage_enabled extends \core\event\base { + + /** + * Initialise the event data. + */ + protected function init(): void { + $this->data['objecttable'] = 'customfield_shared'; + $this->data['crud'] = 'c'; + $this->data['edulevel'] = self::LEVEL_OTHER; + } + + /** + * Creates an instance from a shared object + * + * @param shared $shared + * @param context $context + * @return category_updated + */ + public static function create_from_object(shared $shared, context $context): shared_category_usage_enabled { + $eventparams = [ + 'objectid' => $shared->get('id'), + 'context' => $context, + 'other' => [ + 'categoryid' => $shared->get('categoryid'), + 'component' => $shared->get('component'), + 'area' => $shared->get('area'), + 'itemid' => $shared->get('itemid'), + ], + ]; + $event = self::create($eventparams); + $event->add_record_snapshot($event->objecttable, $shared->to_record()); + return $event; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name(): string { + return get_string('eventsharedcategoryusageenabled', 'core_customfield'); + } + + /** + * Returns non-localised description of what happened. + * + * @return string + */ + public function get_description(): string { + return "The user with ID '$this->userid' enabled usage of shared category with ID '{$this->other['categoryid']}'" . + " in '{$this->other['component']}/{$this->other['area']}'"; + } +} diff --git a/public/customfield/classes/external/toggle_shared_category.php b/public/customfield/classes/external/toggle_shared_category.php new file mode 100755 index 00000000000..66fe0b01498 --- /dev/null +++ b/public/customfield/classes/external/toggle_shared_category.php @@ -0,0 +1,116 @@ +. + +declare(strict_types=1); + +namespace core_customfield\external; + +use core_customfield\event\shared_category_usage_disabled; +use core_customfield\event\shared_category_usage_enabled; +use core_customfield\handler; +use core_customfield\shared; +use core_external\external_api; +use core_external\external_value; +use core_external\external_function_parameters; + +/** + * External method for toggling shared categories + * + * @package core_customfield + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class toggle_shared_category extends external_api { + /** + * External method parameters + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'categoryid' => new external_value(PARAM_INT, 'Category ID'), + 'component' => new external_value(PARAM_COMPONENT, 'Component'), + 'area' => new external_value(PARAM_AREA, 'Area'), + 'itemid' => new external_value(PARAM_INT, 'Item ID'), + 'state' => new external_value(PARAM_BOOL, 'New state'), + ]); + } + + /** + * External method execution + * + * @param int $categoryid + * @param string $component + * @param string $area + * @param int $itemid + * @param bool $state + * @return bool + */ + public static function execute(int $categoryid, string $component, string $area, int $itemid, bool $state): bool { + [ + 'categoryid' => $categoryid, + 'component' => $component, + 'area' => $area, + 'itemid' => $itemid, + 'state' => $state, + ] = self::validate_parameters(self::execute_parameters(), [ + 'categoryid' => $categoryid, + 'component' => $component, + 'area' => $area, + 'itemid' => $itemid, + 'state' => $state, + ]); + + // Validate context. + $context = \core\context\system::instance(); + self::validate_context($context); + + $handler = handler::get_handler($component, $area, $itemid); + if (!$handler->can_configure()) { + throw new \moodle_exception('nopermissions', 'error', '', get_string('customfield:configureshared', 'core_role')); + } + + global $DB; + + $params = [ + 'categoryid' => $categoryid, + 'component' => $component, + 'area' => $area, + 'itemid' => $itemid, + ]; + + if ($state) { + $record = new shared(0, (object) $params); + $record->create(); + shared_category_usage_enabled::create_from_object($record, $handler->get_configuration_context())->trigger(); + } else { + $record = shared::get_record($params); + $DB->delete_records('customfield_shared', $params); + shared_category_usage_disabled::create_from_object($record, $handler->get_configuration_context())->trigger(); + } + + return true; + } + + /** + * External method return value + * + * @return external_value + */ + public static function execute_returns(): external_value { + return new external_value(PARAM_BOOL); + } +} diff --git a/public/customfield/classes/handler.php b/public/customfield/classes/handler.php index 46ba019fa5e..f3dc95f6b05 100644 --- a/public/customfield/classes/handler.php +++ b/public/customfield/classes/handler.php @@ -519,15 +519,27 @@ abstract class handler { /** * Returns array of categories, each of them contains a list of fields definitions. * + * @param bool $ismanagementpage Whether we are on the management page to show all shared categories or not. * @return category_controller[] */ - public function get_categories_with_fields(): array { + public function get_categories_with_fields(bool $ismanagementpage = false): array { if ($this->categories === null) { $sharedcategories = []; $this->categories = api::get_categories_with_fields($this->get_component(), $this->get_area(), $this->get_itemid()); // Avoid duplication when we are in the shared custom fields page. if ($this->get_component() !== 'core_customfield' && $this->get_area() !== 'shared') { $sharedcategories = api::get_categories_with_fields('core_customfield', 'shared', 0); + // Filter only by enabled shared categories. + if (!$ismanagementpage) { + $sharedcategories = array_filter($sharedcategories, function (category_controller $cc) { + return api::is_shared_category_enabled( + $cc->get('id'), + $this->get_component(), + $this->get_area(), + $this->get_itemid() + ); + }); + } } $this->categories = array_merge($this->categories, $sharedcategories); } diff --git a/public/customfield/classes/output/management.php b/public/customfield/classes/output/management.php index 02eb9cbd097..1ff22a3b696 100644 --- a/public/customfield/classes/output/management.php +++ b/public/customfield/classes/output/management.php @@ -26,6 +26,7 @@ namespace core_customfield\output; use core_customfield\api; use core_customfield\handler; +use core_customfield\shared; use renderable; use templatable; @@ -65,6 +66,7 @@ class management implements renderable, templatable { * @return array|object|\stdClass */ public function export_for_template(\renderer_base $output) { + global $DB; $data = new \stdClass(); $fieldtypes = $this->handler->get_available_field_types(); @@ -73,7 +75,14 @@ class management implements renderable, templatable { $data->area = $this->handler->get_area(); $data->itemid = $this->handler->get_itemid(); $data->usescategories = $this->handler->uses_categories(); - $categories = $this->handler->get_categories_with_fields(); + $categories = $this->handler->get_categories_with_fields(true); + + // Get all enabled shared categories at once. + $sharedcategoriesenabled = shared::get_records([ + 'component' => $data->component, + 'area' => $data->area, + 'itemid' => $data->itemid, + ]); $categoriesarray = array(); @@ -89,6 +98,26 @@ class management implements renderable, templatable { $category->get_formatted_name()); $categoryarray['canedit'] = $canedit; + $toggleenabled = (bool) array_filter( + $sharedcategoriesenabled, + fn($record) => $record->get('categoryid') === $category->get('id') + ); + $attributes = [ + ['name' => 'data-id', 'value' => $category->get('id')], + ['name' => 'data-action', 'value' => 'shared-toggle'], + ['name' => 'data-state', 'value' => $toggleenabled], + ['name' => 'data-component', 'value' => $data->component], + ['name' => 'data-area', 'value' => $data->area], + ['name' => 'data-itemid', 'value' => $data->itemid], + ]; + $categoryarray['toggle'] = $output->render_from_template('core/toggle', [ + 'id' => 'shared-toggle-' . $category->get('id'), + 'checked' => $toggleenabled, + 'extraattributes' => $attributes, + 'label' => get_string('enableplugin', 'core_admin', $category->get_formatted_name()), + 'labelclasses' => 'visually-hidden', + ]); + $categoryarray['fields'] = array(); foreach ($category->get_fields() as $field) { diff --git a/public/customfield/classes/shared.php b/public/customfield/classes/shared.php new file mode 100644 index 00000000000..86d1825e432 --- /dev/null +++ b/public/customfield/classes/shared.php @@ -0,0 +1,57 @@ +. + +namespace core_customfield; + +use core\persistent; + +/** + * Customfield shared persistent class + * + * @package core_customfield + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class shared extends persistent { + /** + * Database table. + */ + const TABLE = 'customfield_shared'; + + /** + * Return the definition of the properties of this model. + * + * @return array + */ + protected static function define_properties(): array { + return [ + 'categoryid' => [ + 'type' => PARAM_INT, + ], + 'component' => [ + 'type' => PARAM_COMPONENT, + ], + 'area' => [ + 'type' => PARAM_COMPONENT, + ], + 'itemid' => [ + 'type' => PARAM_INT, + 'optional' => true, + 'default' => 0, + ], + ]; + } +} diff --git a/public/customfield/externallib.php b/public/customfield/externallib.php index 795bcf34354..e18e2dcd7aa 100644 --- a/public/customfield/externallib.php +++ b/public/customfield/externallib.php @@ -131,6 +131,7 @@ class core_customfield_external extends external_api { ) ) , '', VALUE_OPTIONAL), + 'toggle' => new external_value(PARAM_RAW, 'toggle'), ) ) ), diff --git a/public/customfield/templates/list.mustache b/public/customfield/templates/list.mustache index 1dd450ef00d..5ca89ab2cc4 100644 --- a/public/customfield/templates/list.mustache +++ b/public/customfield/templates/list.mustache @@ -77,7 +77,7 @@ {{#canedit}}
{{/canedit}}
-
+
{{#usescategories}}

{{#canedit}}{{> core/drag_handle}}{{/canedit}} @@ -88,6 +88,11 @@ {{/canedit}}

+ {{^canedit}} +
+ {{{toggle}}} +
+ {{/canedit}} {{/usescategories}}
@@ -132,7 +137,8 @@
{{#js}} - require(['core_customfield/form'], function(s) { + require(['core_customfield/form', 'core_customfield/toggle_shared'], function(s, toggle) { s.init(); + toggle.init(); }); {{/js}} diff --git a/public/customfield/tests/behat/shared_custom_fields.feature b/public/customfield/tests/behat/shared_custom_fields.feature index 53b72a0ba6a..e503b885fa6 100644 --- a/public/customfield/tests/behat/shared_custom_fields.feature +++ b/public/customfield/tests/behat/shared_custom_fields.feature @@ -53,3 +53,31 @@ Feature: Create shared categories and fields # Check that the move field option exists for course fields but not for shared fields. And "//tr[@data-field-name='Course field 1']//span[@title='Move \"Course field 1\"']" "xpath_element" should exist And "//tr[@data-field-name='Shared field 1']//span[@title='Move \"Shared field 1\"']" "xpath_element" should not exist + + Scenario: Select which shared custom fields categories are used in the course entity + Given the following "custom field categories" exist: + | name | component | area | itemid | + | My shared category 1 | core_customfield | shared | 0 | + | My shared category 2 | core_customfield | shared | 0 | + | My course category | core_course | course | 0 | + And the following "custom fields" exist: + | name | category | type | shortname | + | Shared field 1 | My shared category 1 | text | f1 | + | Shared field 2 | My shared category 2 | text | f2 | + | Course field 1 | My course category | text | f3 | + And the following "courses" exist: + | shortname | fullname | + | C1 | Course 1 | + And I log in as "admin" + When I am on the "C1" "Course" page + And I navigate to "Settings" in current page administration + Then I should see "My course category" + And I should not see "My shared category 1" + And I should not see "My shared category 2" + And I navigate to "Courses > Default settings > Course custom fields" in site administration + And I toggle the "Enable My shared category 1" admin switch "on" + And I am on the "C1" "Course" page + And I navigate to "Settings" in current page administration + And I should see "My course category" + And I should see "My shared category 1" + And I should not see "My shared category 2" diff --git a/public/customfield/tests/external/toggle_shared_category_test.php b/public/customfield/tests/external/toggle_shared_category_test.php new file mode 100644 index 00000000000..4199b23b75c --- /dev/null +++ b/public/customfield/tests/external/toggle_shared_category_test.php @@ -0,0 +1,112 @@ +. + +declare(strict_types=1); + +namespace core_customfield\external; + +use core_external\external_api; +use core_customfield\shared; +use core_customfield_generator; + +/** + * Unit tests for custom field toggle_shared_category external method + * + * @package core_customfield + * @covers \core_customfield\external\toggle_shared_category + * @copyright 2025 David Carrillo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class toggle_shared_category_test extends \core_external\tests\externallib_testcase { + /** + * Test execute + */ + public function test_execute(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + /** @var core_customfield_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); + $sharedcategory = $generator->create_category(['component' => 'core_customfield', 'area' => 'shared']); + $generator->create_field([ + 'categoryid' => $sharedcategory->get('id'), + 'name' => 'My shared field', + 'shortname' => 'mysharedfield', + 'type' => 'text', + ]); + + $this->assertEmpty(shared::get_records()); + + $result = toggle_shared_category::execute( + $sharedcategory->get('id'), + 'core_course', + 'course', + 0, + true + ); + $result = external_api::clean_returnvalue(toggle_shared_category::execute_returns(), $result); + $this->assertTrue($result); + $records = shared::get_records(); + $record = reset($records); + $this->assertEquals($sharedcategory->get('id'), $record->get('categoryid')); + $this->assertEquals('core_course', $record->get('component')); + $this->assertEquals('course', $record->get('area')); + $this->assertEquals(0, $record->get('itemid')); + + $result = toggle_shared_category::execute( + $sharedcategory->get('id'), + 'core_course', + 'course', + 0, + false + ); + $result = external_api::clean_returnvalue(toggle_shared_category::execute_returns(), $result); + $this->assertTrue($result); + $this->assertEmpty(shared::get_records()); + } + + /** + * Test execute with no permission + */ + public function test_execute_no_permission(): void { + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + + /** @var core_customfield_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); + $sharedcategory = $generator->create_category(['component' => 'core_customfield', 'area' => 'shared']); + $generator->create_field([ + 'categoryid' => $sharedcategory->get('id'), + 'name' => 'My shared field', + 'shortname' => 'mysharedfield', + 'type' => 'text', + ]); + + $this->setUser($user); + + $this->expectException(\moodle_exception::class); + $str = "Sorry, but you do not currently have permissions to do that (Configure shared custom fields)."; + $this->expectExceptionMessage($str); + toggle_shared_category::execute( + $sharedcategory->get('id'), + 'core_course', + 'course', + 0, + true + ); + } +} diff --git a/public/lang/en/customfield.php b/public/lang/en/customfield.php index 72a58f3f97a..19ed96fbbd3 100644 --- a/public/lang/en/customfield.php +++ b/public/lang/en/customfield.php @@ -50,6 +50,8 @@ $string['eventcategoryupdated'] = 'Custom field category updated'; $string['eventfieldcreated'] = 'Custom field created'; $string['eventfielddeleted'] = 'Custom field deleted'; $string['eventfieldupdated'] = 'Custom field updated'; +$string['eventsharedcategoryusagedisabled'] = 'Shared category usage disabled'; +$string['eventsharedcategoryusageenabled'] = 'Shared category usage enabled'; $string['fieldname'] = 'Name'; $string['fieldnotfound'] = 'Field not found'; $string['fieldshortname'] = 'Short name'; @@ -85,6 +87,7 @@ $string['privacy:metadata:customfield_data:valueformat'] = 'The format of the va $string['privacy:metadata:customfield_data:valuetrust'] = 'The trust flag of the value, when it is text'; $string['privacy:metadata:customfieldpluginsummary'] = 'Fields for various components'; $string['privacy:metadata:filepurpose'] = 'File attached to the custom field data'; +$string['sharedcategoryhelptext'] = 'When enabled, the custom fields in this category will be used'; $string['sharedcustomfields'] = 'Shared custom fields'; $string['shortname'] = 'Short name'; $string['shortname_help'] = 'The short name must be unique and can only contain alphanumeric lowercase characters and underscores (_). It is not displayed anywhere on the site, but may be used for synchronisation with external systems or in web services.'; diff --git a/public/lib/db/install.xml b/public/lib/db/install.xml index ac9d1669456..95eed21f29a 100644 --- a/public/lib/db/install.xml +++ b/public/lib/db/install.xml @@ -4355,6 +4355,26 @@ + + + + + + + + + + + + + + + + + + + +
diff --git a/public/lib/db/services.php b/public/lib/db/services.php index 851005b42ae..c400e5e1fab 100644 --- a/public/lib/db/services.php +++ b/public/lib/db/services.php @@ -2927,6 +2927,13 @@ $functions = array( 'type' => 'write', 'ajax' => true, ), + 'core_customfield_toggle_shared' => [ + 'classname' => core_customfield\external\toggle_shared_category::class, + 'methodname' => 'execute', + 'description' => 'Toggle shared category state', + 'type' => 'write', + 'ajax' => true, + ], 'core_h5p_get_trusted_h5p_file' => [ 'classname' => 'core_h5p\external', 'methodname' => 'get_trusted_h5p_file',