From 2bbd42ee13671a1edf3f62babbcceb44b2b8a1fc Mon Sep 17 00:00:00 2001 From: teruselearning <49330603+teruselearning@users.noreply.github.com> Date: Tue, 9 Aug 2022 00:12:35 +0700 Subject: [PATCH 1/2] Fix issues with AJAX functionality in Moodle 4 --- README.md | 34 - amd/build/form.min.js | 10 + amd/build/form.min.js.map | 1 + amd/src/form.js | 335 ++++++++ classes/api.php | 442 ++++++++++ classes/category.php | 87 ++ classes/category_controller.php | 231 +++++ classes/data.php | 107 +++ classes/data_controller.php | 355 +++++--- classes/event/category_created.php | 84 ++ classes/event/category_deleted.php | 84 ++ classes/event/category_updated.php | 84 ++ classes/event/field_created.php | 87 ++ classes/event/field_deleted.php | 87 ++ classes/event/field_updated.php | 87 ++ classes/field.php | 97 +++ classes/field_config_form.php | 214 +++++ classes/field_controller.php | 294 +++++-- classes/handler.php | 789 ++++++++++++++++++ classes/output/field_data.php | 114 +++ classes/output/management.php | 126 +++ classes/output/renderer.php | 62 ++ classes/privacy/customfield_provider.php | 84 ++ classes/privacy/provider.php | 478 ++++++++++- externallib.php | 295 +++++++ field/checkbox/classes/data_controller.php | 87 ++ field/checkbox/classes/field_controller.php | 94 +++ field/checkbox/classes/privacy/provider.php | 81 ++ .../checkbox/lang/en/customfield_checkbox.php | 30 + field/checkbox/tests/behat/field.feature | 78 ++ field/checkbox/tests/plugin_test.php | 172 ++++ field/checkbox/version.php | 28 + field/date/classes/data_controller.php | 147 ++++ field/date/classes/field_controller.php | 130 +++ field/date/classes/privacy/provider.php | 85 ++ field/date/lang/en/customfield_date.php | 35 + field/date/lib.php | 35 + field/date/pix/checked.png | Bin 0 -> 372 bytes field/date/pix/checked.svg | 6 + field/date/pix/notchecked.png | Bin 0 -> 266 bytes field/date/pix/notchecked.svg | 6 + field/date/tests/behat/field.feature | 102 +++ field/date/tests/plugin_test.php | 202 +++++ field/date/version.php | 30 + .travis.yml => field/multiselect/.travis.yml | 17 +- LICENSE.txt => field/multiselect/LICENSE.txt | 0 field/multiselect/README.md | 19 + field/multiselect/amd/build/clear.min.js | 1 + field/multiselect/amd/src/clear.js | 33 + field/multiselect/classes/data_controller.php | 239 ++++++ .../multiselect/classes/field_controller.php | 105 +++ .../multiselect/classes/privacy/provider.php | 84 ++ .../lang}/en/customfield_multiselect.php | 2 + .../multiselect/tests}/behat/field.feature | 0 .../multiselect/tests}/plugin_test.php | 44 +- version.php => field/multiselect/version.php | 4 +- field/select/classes/data_controller.php | 128 +++ field/select/classes/field_controller.php | 146 ++++ field/select/classes/privacy/provider.php | 83 ++ field/select/lang/en/customfield_select.php | 33 + field/select/tests/behat/field.feature | 85 ++ field/select/tests/plugin_test.php | 195 +++++ field/select/version.php | 29 + field/text/classes/data_controller.php | 116 +++ field/text/classes/field_controller.php | 152 ++++ field/text/classes/privacy/provider.php | 84 ++ field/text/lang/en/customfield_text.php | 44 + field/text/tests/behat/field.feature | 140 ++++ field/text/tests/plugin_test.php | 169 ++++ field/text/version.php | 29 + field/textarea/classes/data_controller.php | 189 +++++ field/textarea/classes/field_controller.php | 145 ++++ field/textarea/classes/privacy/provider.php | 97 +++ .../textarea/lang/en/customfield_textarea.php | 29 + field/textarea/lib.php | 76 ++ .../tests/behat/default_value.feature | 83 ++ field/textarea/tests/behat/field.feature | 49 ++ field/textarea/tests/plugin_test.php | 195 +++++ field/textarea/version.php | 29 + field/upgrade.txt | 5 + lib.php | 85 ++ screenshot.png | Bin 28177 -> 0 bytes templates/field_data.mustache | 33 + templates/list.mustache | 131 +++ templates/nofields.mustache | 39 + tests/api_test.php | 254 ++++++ tests/behat/edit_categories.feature | 104 +++ tests/behat/edit_fields_settings.feature | 118 +++ tests/behat/required_field.feature | 58 ++ tests/behat/unique_field.feature | 74 ++ tests/category_controller_test.php | 251 ++++++ tests/data_controller_test.php | 180 ++++ tests/field_controller_test.php | 245 ++++++ tests/fixtures/test_instance_form.php | 79 ++ tests/generator/lib.php | 164 ++++ tests/generator_test.php | 105 +++ tests/privacy/provider_test.php | 290 +++++++ upgrade.txt | 13 + 98 files changed, 10829 insertions(+), 288 deletions(-) delete mode 100644 README.md create mode 100644 amd/build/form.min.js create mode 100644 amd/build/form.min.js.map create mode 100644 amd/src/form.js create mode 100644 classes/api.php create mode 100644 classes/category.php create mode 100644 classes/category_controller.php create mode 100644 classes/data.php create mode 100644 classes/event/category_created.php create mode 100644 classes/event/category_deleted.php create mode 100644 classes/event/category_updated.php create mode 100644 classes/event/field_created.php create mode 100644 classes/event/field_deleted.php create mode 100644 classes/event/field_updated.php create mode 100644 classes/field.php create mode 100644 classes/field_config_form.php create mode 100644 classes/handler.php create mode 100644 classes/output/field_data.php create mode 100644 classes/output/management.php create mode 100644 classes/output/renderer.php create mode 100644 classes/privacy/customfield_provider.php create mode 100644 externallib.php create mode 100644 field/checkbox/classes/data_controller.php create mode 100644 field/checkbox/classes/field_controller.php create mode 100644 field/checkbox/classes/privacy/provider.php create mode 100644 field/checkbox/lang/en/customfield_checkbox.php create mode 100644 field/checkbox/tests/behat/field.feature create mode 100644 field/checkbox/tests/plugin_test.php create mode 100644 field/checkbox/version.php create mode 100644 field/date/classes/data_controller.php create mode 100644 field/date/classes/field_controller.php create mode 100644 field/date/classes/privacy/provider.php create mode 100644 field/date/lang/en/customfield_date.php create mode 100644 field/date/lib.php create mode 100644 field/date/pix/checked.png create mode 100644 field/date/pix/checked.svg create mode 100644 field/date/pix/notchecked.png create mode 100644 field/date/pix/notchecked.svg create mode 100644 field/date/tests/behat/field.feature create mode 100644 field/date/tests/plugin_test.php create mode 100644 field/date/version.php rename .travis.yml => field/multiselect/.travis.yml (57%) rename LICENSE.txt => field/multiselect/LICENSE.txt (100%) create mode 100644 field/multiselect/README.md create mode 100644 field/multiselect/amd/build/clear.min.js create mode 100644 field/multiselect/amd/src/clear.js create mode 100644 field/multiselect/classes/data_controller.php create mode 100644 field/multiselect/classes/field_controller.php create mode 100644 field/multiselect/classes/privacy/provider.php rename {lang => field/multiselect/lang}/en/customfield_multiselect.php (93%) rename {tests => field/multiselect/tests}/behat/field.feature (100%) rename {tests => field/multiselect/tests}/plugin_test.php (86%) rename version.php => field/multiselect/version.php (91%) create mode 100644 field/select/classes/data_controller.php create mode 100644 field/select/classes/field_controller.php create mode 100644 field/select/classes/privacy/provider.php create mode 100644 field/select/lang/en/customfield_select.php create mode 100644 field/select/tests/behat/field.feature create mode 100644 field/select/tests/plugin_test.php create mode 100644 field/select/version.php create mode 100644 field/text/classes/data_controller.php create mode 100644 field/text/classes/field_controller.php create mode 100644 field/text/classes/privacy/provider.php create mode 100644 field/text/lang/en/customfield_text.php create mode 100644 field/text/tests/behat/field.feature create mode 100644 field/text/tests/plugin_test.php create mode 100644 field/text/version.php create mode 100644 field/textarea/classes/data_controller.php create mode 100644 field/textarea/classes/field_controller.php create mode 100644 field/textarea/classes/privacy/provider.php create mode 100644 field/textarea/lang/en/customfield_textarea.php create mode 100644 field/textarea/lib.php create mode 100644 field/textarea/tests/behat/default_value.feature create mode 100644 field/textarea/tests/behat/field.feature create mode 100644 field/textarea/tests/plugin_test.php create mode 100644 field/textarea/version.php create mode 100644 field/upgrade.txt create mode 100644 lib.php delete mode 100644 screenshot.png create mode 100644 templates/field_data.mustache create mode 100644 templates/list.mustache create mode 100644 templates/nofields.mustache create mode 100644 tests/api_test.php create mode 100644 tests/behat/edit_categories.feature create mode 100644 tests/behat/edit_fields_settings.feature create mode 100644 tests/behat/required_field.feature create mode 100644 tests/behat/unique_field.feature create mode 100644 tests/category_controller_test.php create mode 100644 tests/data_controller_test.php create mode 100644 tests/field_controller_test.php create mode 100644 tests/fixtures/test_instance_form.php create mode 100644 tests/generator/lib.php create mode 100644 tests/generator_test.php create mode 100644 tests/privacy/provider_test.php create mode 100644 upgrade.txt diff --git a/README.md b/README.md deleted file mode 100644 index 5709049..0000000 --- a/README.md +++ /dev/null @@ -1,34 +0,0 @@ -Multiselect Custom Field -======================== - -[![Build Status](https://travis-ci.org/call-learning/moodle-customfield_multiselect.svg?branch=master)](https://travis-ci.org/call-learning/moodle-customfield_multiselect) - - -This plugin is a new multiselect profile inspired from the existing select customfield (customfield/field/select) - -It allows for several choices to be selected. -The data is stored in the database as comma separated values of option indexes. - - -NOTE -=== -There is a very similar development made by https://github.com/devlionco a couple -of years ago. -After a discussion (see https://tracker.moodle.org/browse/MDL-66321), the -form filter was changed to autocomplete as it made more sense. The "clear" button has been -removed. -The field should behave as the one developed on: https://github.com/devlionco/moodle-customfield_multiselect - -There are a couple of differences though: - - * In this implementation, the base class is not the customfield_select data or field classes. The reason was that there were so many little differences in the - method implementations that we ended up overriding all methods from customfield_select\data_controller or field_controller. The disadvantage being that for example - the new course grouping feature is not automatically supported. - * We use 'value' instead of 'charvalue' for datastorage. This is down to the fact that we wanted to make sure there was no limit on the number of selected option. - This can be reviewed if needed. - - -TODO -==== - * Allow to change the select into a series of cheboxes for small amounts of choices. - * When values are removed from the list, should we re-index ? \ No newline at end of file diff --git a/amd/build/form.min.js b/amd/build/form.min.js new file mode 100644 index 0000000..4764b3f --- /dev/null +++ b/amd/build/form.min.js @@ -0,0 +1,10 @@ +define("core_customfield/form",["exports","core/inplace_editable","core/ajax","core/str","core_form/modalform","core/notification","core/pending","core/sortable_list","core/templates","jquery"],(function(_exports,_inplace_editable,_ajax,_str,_modalform,_notification,_pending,_sortable_list,_templates,_jquery){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} +/** + * Custom Field interaction management for Moodle. + * + * @module core_customfield/form + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_modalform=_interopRequireDefault(_modalform),_notification=_interopRequireDefault(_notification),_pending=_interopRequireDefault(_pending),_sortable_list=_interopRequireDefault(_sortable_list),_templates=_interopRequireDefault(_templates),_jquery=_interopRequireDefault(_jquery);const confirmDelete=(id,type,component,area,itemid)=>{const pendingPromise=new _pending.default("core_customfield/form:confirmDelete");(0,_str.get_strings)([{key:"confirm"},{key:"confirmdelete"+type,component:"core_customfield"},{key:"yes"},{key:"no"}]).then((strings=>_notification.default.confirm(strings[0],strings[1],strings[2],strings[3],(function(){const pendingDeletePromise=new _pending.default("core_customfield/form:confirmDelete");(0,_ajax.call)([{methodname:"field"===type?"core_customfield_delete_field":"core_customfield_delete_category",args:{id:id}},{methodname:"core_customfield_reload_template",args:{component:component,area:area,itemid:itemid}}])[1].then((response=>_templates.default.render("core_customfield/list",response))).then(((html,js)=>_templates.default.replaceNode((0,_jquery.default)('[data-region="list-page"]'),html,js))).then(pendingDeletePromise.resolve).catch(_notification.default.exception)})))).then(pendingPromise.resolve).catch(_notification.default.exception)},getCategoryNameFor=nodeElement=>nodeElement.closest("[data-category-id]").find("[data-inplaceeditable][data-itemtype=category][data-component=core_customfield]").attr("data-value");_exports.init=()=>{const rootNode=document.querySelector("#customfield_catlist"),component=rootNode.dataset.component,area=rootNode.dataset.area,itemid=rootNode.dataset.itemid;rootNode.addEventListener("click",(e=>{const roleHolder=e.target.closest("[data-role]");if(roleHolder)return"deletefield"===roleHolder.dataset.role?(e.preventDefault(),void confirmDelete(roleHolder.dataset.id,"field",component,area,itemid)):"deletecategory"===roleHolder.dataset.role?(e.preventDefault(),void confirmDelete(roleHolder.dataset.id,"category",component,area,itemid)):"addnewcategory"===roleHolder.dataset.role?(e.preventDefault(),void((component,area,itemid)=>{const pendingPromise=new _pending.default("core_customfield/form:createNewCategory");(0,_ajax.call)([{methodname:"core_customfield_create_category",args:{component:component,area:area,itemid:itemid}},{methodname:"core_customfield_reload_template",args:{component:component,area:area,itemid:itemid}}])[1].then((response=>_templates.default.render("core_customfield/list",response))).then(((html,js)=>_templates.default.replaceNode((0,_jquery.default)('[data-region="list-page"]'),html,js))).then((()=>pendingPromise.resolve())).catch(_notification.default.exception)})(component,area,itemid)):"addfield"===roleHolder.dataset.role?(e.preventDefault(),void((element,component,area,itemid)=>{const pendingPromise=new _pending.default("core_customfield/form:createNewField"),returnFocus=element.closest(".action-menu").querySelector(".dropdown-toggle"),form=new _modalform.default({formClass:"core_customfield\\field_config_form",args:{categoryid:element.getAttribute("data-categoryid"),type:element.getAttribute("data-type")},modalConfig:{title:(0,_str.get_string)("addingnewcustomfield","core_customfield",element.getAttribute("data-typename"))},returnFocus:returnFocus});form.addEventListener(form.events.FORM_SUBMITTED,(()=>{const pendingCreatedPromise=new _pending.default("core_customfield/form:createdNewField");(0,_ajax.call)([{methodname:"core_customfield_reload_template",args:{component:component,area:area,itemid:itemid}}])[0].then((response=>_templates.default.render("core_customfield/list",response))).then(((html,js)=>_templates.default.replaceNode((0,_jquery.default)('[data-region="list-page"]'),html,js))).then((()=>pendingCreatedPromise.resolve())).catch((()=>window.location.reload()))})),form.show(),pendingPromise.resolve()})(roleHolder,component,area,itemid)):"editfield"===roleHolder.dataset.role?(e.preventDefault(),void((element,component,area,itemid)=>{const pendingPromise=new _pending.default("core_customfield/form:editField"),form=new _modalform.default({formClass:"core_customfield\\field_config_form",args:{id:element.getAttribute("data-id")},modalConfig:{title:(0,_str.get_string)("editingfield","core_customfield",element.getAttribute("data-name"))},returnFocus:element});form.addEventListener(form.events.FORM_SUBMITTED,(()=>{const pendingCreatedPromise=new _pending.default("core_customfield/form:createdNewField");(0,_ajax.call)([{methodname:"core_customfield_reload_template",args:{component:component,area:area,itemid:itemid}}])[0].then((response=>_templates.default.render("core_customfield/list",response))).then(((html,js)=>_templates.default.replaceNode((0,_jquery.default)('[data-region="list-page"]'),html,js))).then((()=>pendingCreatedPromise.resolve())).catch((()=>window.location.reload()))})),form.show(),pendingPromise.resolve()})(roleHolder,component,area,itemid)):void 0})),(rootNode=>{new _sortable_list.default("#customfield_catlist .categorieslist",{moveHandlerSelector:".movecategory [data-drag-type=move]"}).getElementName=nodeElement=>Promise.resolve(getCategoryNameFor(nodeElement)),(0,_jquery.default)("[data-category-id]").on(_sortable_list.default.EVENTS.DROP,((evt,info)=>{if(info.positionChanged){const pendingPromise=new _pending.default("core_customfield/form:categoryid:on:sortablelist-drop");(0,_ajax.call)([{methodname:"core_customfield_move_category",args:{id:info.element.data("category-id"),beforeid:info.targetNextElement.data("category-id")}}])[0].then(pendingPromise.resolve).catch(_notification.default.exception)}evt.stopPropagation()})),new _sortable_list.default("#customfield_catlist .fieldslist tbody",{moveHandlerSelector:".movefield [data-drag-type=move]"}).getDestinationName=(parentElement,afterElement)=>afterElement.length?afterElement.attr("data-field-name")?(0,_str.get_string)("afterfield","customfield",afterElement.attr("data-field-name")):Promise.resolve(""):(0,_str.get_string)("totopofcategory","customfield",getCategoryNameFor(parentElement)),(0,_jquery.default)("[data-field-name]").on(_sortable_list.default.EVENTS.DROP,((evt,info)=>{if(info.positionChanged){const pendingPromise=new _pending.default("core_customfield/form:fieldname:on:sortablelist-drop");(0,_ajax.call)([{methodname:"core_customfield_move_field",args:{id:info.element.data("field-id"),beforeid:info.targetNextElement.data("field-id"),categoryid:Number(info.targetList.closest("[data-category-id]").attr("data-category-id"))}}])[0].then(pendingPromise.resolve).catch(_notification.default.exception)}evt.stopPropagation()})),(0,_jquery.default)("[data-field-name]").on(_sortable_list.default.EVENTS.DRAG,(evt=>{var pendingPromise=new _pending.default("core_customfield/form:fieldname:on:sortablelist-drag");evt.stopPropagation(),_templates.default.render("core_customfield/nofields",{}).then((html=>{rootNode.querySelectorAll(".categorieslist > *").forEach((category=>{const fields=category.querySelectorAll(".field:not(.sortable-list-is-dragged)"),noFields=category.querySelector(".nofields");fields.length||noFields?fields.length&&noFields&&noFields.remove():category.querySelector("tbody").innerHTML=html}))})).then(pendingPromise.resolve).catch(_notification.default.exception)})),(0,_jquery.default)("[data-category-id], [data-field-name]").on(_sortable_list.default.EVENTS.DRAGSTART,((evt,info)=>{setTimeout((()=>{(0,_jquery.default)(".sortable-list-is-dragged").width(info.element.width())}),501)}))})(rootNode)}})); + +//# sourceMappingURL=form.min.js.map \ No newline at end of file diff --git a/amd/build/form.min.js.map b/amd/build/form.min.js.map new file mode 100644 index 0000000..44cb1dc --- /dev/null +++ b/amd/build/form.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"form.min.js","sources":["../src/form.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 Field interaction management for Moodle.\n *\n * @module core_customfield/form\n * @copyright 2018 Toni Barbera\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport 'core/inplace_editable';\nimport {call as fetchMany} from 'core/ajax';\nimport {\n get_string as getString,\n get_strings as getStrings,\n} from 'core/str';\nimport ModalForm from 'core_form/modalform';\nimport Notification from 'core/notification';\nimport Pending from 'core/pending';\nimport SortableList from 'core/sortable_list';\nimport Templates from 'core/templates';\nimport jQuery from 'jquery';\n\n/**\n * Display confirmation dialogue\n *\n * @param {Number} id\n * @param {String} type\n * @param {String} component\n * @param {String} area\n * @param {Number} itemid\n */\nconst confirmDelete = (id, type, component, area, itemid) => {\n const pendingPromise = new Pending('core_customfield/form:confirmDelete');\n\n getStrings([\n {'key': 'confirm'},\n {'key': 'confirmdelete' + type, component: 'core_customfield'},\n {'key': 'yes'},\n {'key': 'no'},\n ])\n .then(strings => {\n return Notification.confirm(strings[0], strings[1], strings[2], strings[3], function() {\n const pendingDeletePromise = new Pending('core_customfield/form:confirmDelete');\n fetchMany([\n {\n methodname: (type === 'field') ? 'core_customfield_delete_field' : 'core_customfield_delete_category',\n args: {id},\n },\n {methodname: 'core_customfield_reload_template', args: {component, area, itemid}}\n ])[1]\n .then(response => Templates.render('core_customfield/list', response))\n .then((html, js) => Templates.replaceNode(jQuery('[data-region=\"list-page\"]'), html, js))\n .then(pendingDeletePromise.resolve)\n .catch(Notification.exception);\n });\n })\n .then(pendingPromise.resolve)\n .catch(Notification.exception);\n};\n\n\n/**\n * Creates a new custom fields category with default name and updates the list\n *\n * @param {String} component\n * @param {String} area\n * @param {Number} itemid\n */\nconst createNewCategory = (component, area, itemid) => {\n const pendingPromise = new Pending('core_customfield/form:createNewCategory');\n const promises = fetchMany([\n {methodname: 'core_customfield_create_category', args: {component, area, itemid}},\n {methodname: 'core_customfield_reload_template', args: {component, area, itemid}}\n ]);\n\n promises[1].then(response => Templates.render('core_customfield/list', response))\n .then((html, js) => Templates.replaceNode(jQuery('[data-region=\"list-page\"]'), html, js))\n .then(() => pendingPromise.resolve())\n .catch(Notification.exception);\n};\n\n/**\n * Create new custom field\n *\n * @param {HTMLElement} element\n * @param {String} component\n * @param {String} area\n * @param {Number} itemid\n */\nconst createNewField = (element, component, area, itemid) => {\n const pendingPromise = new Pending('core_customfield/form:createNewField');\n\n const returnFocus = element.closest(\".action-menu\").querySelector(\".dropdown-toggle\");\n const form = new ModalForm({\n formClass: \"core_customfield\\\\field_config_form\",\n args: {\n categoryid: element.getAttribute('data-categoryid'),\n type: element.getAttribute('data-type'),\n },\n modalConfig: {\n title: getString('addingnewcustomfield', 'core_customfield', element.getAttribute('data-typename')),\n },\n returnFocus,\n });\n\n form.addEventListener(form.events.FORM_SUBMITTED, () => {\n const pendingCreatedPromise = new Pending('core_customfield/form:createdNewField');\n const promises = fetchMany([\n {methodname: 'core_customfield_reload_template', args: {component: component, area: area, itemid: itemid}}\n ]);\n\n promises[0].then(response => Templates.render('core_customfield/list', response))\n .then((html, js) => Templates.replaceNode(jQuery('[data-region=\"list-page\"]'), html, js))\n .then(() => pendingCreatedPromise.resolve())\n .catch(() => window.location.reload());\n });\n\n form.show();\n\n pendingPromise.resolve();\n};\n\n/**\n * Edit custom field\n *\n * @param {HTMLElement} element\n * @param {String} component\n * @param {String} area\n * @param {Number} itemid\n */\nconst editField = (element, component, area, itemid) => {\n const pendingPromise = new Pending('core_customfield/form:editField');\n\n const form = new ModalForm({\n formClass: \"core_customfield\\\\field_config_form\",\n args: {\n id: element.getAttribute('data-id'),\n },\n modalConfig: {\n title: getString('editingfield', 'core_customfield', element.getAttribute('data-name')),\n },\n returnFocus: element,\n });\n\n form.addEventListener(form.events.FORM_SUBMITTED, () => {\n const pendingCreatedPromise = new Pending('core_customfield/form:createdNewField');\n const promises = fetchMany([\n {methodname: 'core_customfield_reload_template', args: {component: component, area: area, itemid: itemid}}\n ]);\n\n promises[0].then(response => Templates.render('core_customfield/list', response))\n .then((html, js) => Templates.replaceNode(jQuery('[data-region=\"list-page\"]'), html, js))\n .then(() => pendingCreatedPromise.resolve())\n .catch(() => window.location.reload());\n });\n\n form.show();\n\n pendingPromise.resolve();\n};\n\n/**\n * Fetch the category name from an inplace editable, given a child node of that field.\n *\n * @param {NodeElement} nodeElement\n * @returns {String}\n */\nconst getCategoryNameFor = nodeElement => nodeElement\n .closest('[data-category-id]')\n .find('[data-inplaceeditable][data-itemtype=category][data-component=core_customfield]')\n .attr('data-value');\n\nconst setupSortableLists = rootNode => {\n // Sort category.\n const sortCat = new SortableList(\n '#customfield_catlist .categorieslist',\n {\n moveHandlerSelector: '.movecategory [data-drag-type=move]',\n }\n );\n sortCat.getElementName = nodeElement => Promise.resolve(getCategoryNameFor(nodeElement));\n\n // Note: The sortable list currently uses jQuery events.\n jQuery('[data-category-id]').on(SortableList.EVENTS.DROP, (evt, info) => {\n if (info.positionChanged) {\n const pendingPromise = new Pending('core_customfield/form:categoryid:on:sortablelist-drop');\n fetchMany([{\n methodname: 'core_customfield_move_category',\n args: {\n id: info.element.data('category-id'),\n beforeid: info.targetNextElement.data('category-id')\n }\n\n }])[0]\n .then(pendingPromise.resolve)\n .catch(Notification.exception);\n }\n evt.stopPropagation(); // Important for nested lists to prevent multiple targets.\n });\n\n // Sort fields.\n var sort = new SortableList(\n '#customfield_catlist .fieldslist tbody',\n {\n moveHandlerSelector: '.movefield [data-drag-type=move]',\n }\n );\n\n sort.getDestinationName = (parentElement, afterElement) => {\n if (!afterElement.length) {\n return getString('totopofcategory', 'customfield', getCategoryNameFor(parentElement));\n } else if (afterElement.attr('data-field-name')) {\n return getString('afterfield', 'customfield', afterElement.attr('data-field-name'));\n } else {\n return Promise.resolve('');\n }\n };\n\n jQuery('[data-field-name]').on(SortableList.EVENTS.DROP, (evt, info) => {\n if (info.positionChanged) {\n const pendingPromise = new Pending('core_customfield/form:fieldname:on:sortablelist-drop');\n fetchMany([{\n methodname: 'core_customfield_move_field',\n args: {\n id: info.element.data('field-id'),\n beforeid: info.targetNextElement.data('field-id'),\n categoryid: Number(info.targetList.closest('[data-category-id]').attr('data-category-id'))\n },\n }])[0]\n .then(pendingPromise.resolve)\n .catch(Notification.exception);\n }\n evt.stopPropagation(); // Important for nested lists to prevent multiple targets.\n });\n\n jQuery('[data-field-name]').on(SortableList.EVENTS.DRAG, evt => {\n var pendingPromise = new Pending('core_customfield/form:fieldname:on:sortablelist-drag');\n\n evt.stopPropagation(); // Important for nested lists to prevent multiple targets.\n\n // Refreshing fields tables.\n Templates.render('core_customfield/nofields', {})\n .then(html => {\n rootNode.querySelectorAll('.categorieslist > *')\n .forEach(category => {\n const fields = category.querySelectorAll('.field:not(.sortable-list-is-dragged)');\n const noFields = category.querySelector('.nofields');\n\n if (!fields.length && !noFields) {\n category.querySelector('tbody').innerHTML = html;\n } else if (fields.length && noFields) {\n noFields.remove();\n }\n });\n return;\n })\n .then(pendingPromise.resolve)\n .catch(Notification.exception);\n });\n\n jQuery('[data-category-id], [data-field-name]').on(SortableList.EVENTS.DRAGSTART, (evt, info) => {\n setTimeout(() => {\n jQuery('.sortable-list-is-dragged').width(info.element.width());\n }, 501);\n });\n};\n\n/**\n * Initialise the custom fields manager.\n */\nexport const init = () => {\n const rootNode = document.querySelector('#customfield_catlist');\n\n const component = rootNode.dataset.component;\n const area = rootNode.dataset.area;\n const itemid = rootNode.dataset.itemid;\n\n rootNode.addEventListener('click', e => {\n const roleHolder = e.target.closest('[data-role]');\n if (!roleHolder) {\n return;\n }\n\n if (roleHolder.dataset.role === 'deletefield') {\n e.preventDefault();\n\n confirmDelete(roleHolder.dataset.id, 'field', component, area, itemid);\n return;\n }\n\n if (roleHolder.dataset.role === 'deletecategory') {\n e.preventDefault();\n\n confirmDelete(roleHolder.dataset.id, 'category', component, area, itemid);\n return;\n }\n\n if (roleHolder.dataset.role === 'addnewcategory') {\n e.preventDefault();\n createNewCategory(component, area, itemid);\n\n return;\n }\n\n if (roleHolder.dataset.role === 'addfield') {\n e.preventDefault();\n createNewField(roleHolder, component, area, itemid);\n\n return;\n }\n\n if (roleHolder.dataset.role === 'editfield') {\n e.preventDefault();\n editField(roleHolder, component, area, itemid);\n\n return;\n }\n });\n\n setupSortableLists(rootNode, component, area, itemid);\n};\n"],"names":["confirmDelete","id","type","component","area","itemid","pendingPromise","Pending","then","strings","Notification","confirm","pendingDeletePromise","methodname","args","response","Templates","render","html","js","replaceNode","resolve","catch","exception","getCategoryNameFor","nodeElement","closest","find","attr","rootNode","document","querySelector","dataset","addEventListener","e","roleHolder","target","role","preventDefault","createNewCategory","element","returnFocus","form","ModalForm","formClass","categoryid","getAttribute","modalConfig","title","events","FORM_SUBMITTED","pendingCreatedPromise","window","location","reload","show","createNewField","editField","SortableList","moveHandlerSelector","getElementName","Promise","on","EVENTS","DROP","evt","info","positionChanged","data","beforeid","targetNextElement","stopPropagation","getDestinationName","parentElement","afterElement","length","Number","targetList","DRAG","querySelectorAll","forEach","category","fields","noFields","remove","innerHTML","DRAGSTART","setTimeout","width","setupSortableLists"],"mappings":"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"} \ No newline at end of file diff --git a/amd/src/form.js b/amd/src/form.js new file mode 100644 index 0000000..68c3d75 --- /dev/null +++ b/amd/src/form.js @@ -0,0 +1,335 @@ +// 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 Field interaction management for Moodle. + * + * @module core_customfield/form + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import 'core/inplace_editable'; +import {call as fetchMany} from 'core/ajax'; +import { + get_string as getString, + get_strings as getStrings, +} from 'core/str'; +import ModalForm from 'core_form/modalform'; +import Notification from 'core/notification'; +import Pending from 'core/pending'; +import SortableList from 'core/sortable_list'; +import Templates from 'core/templates'; +import jQuery from 'jquery'; + +/** + * Display confirmation dialogue + * + * @param {Number} id + * @param {String} type + * @param {String} component + * @param {String} area + * @param {Number} itemid + */ +const confirmDelete = (id, type, component, area, itemid) => { + const pendingPromise = new Pending('core_customfield/form:confirmDelete'); + + getStrings([ + {'key': 'confirm'}, + {'key': 'confirmdelete' + type, component: 'core_customfield'}, + {'key': 'yes'}, + {'key': 'no'}, + ]) + .then(strings => { + return Notification.confirm(strings[0], strings[1], strings[2], strings[3], function() { + const pendingDeletePromise = new Pending('core_customfield/form:confirmDelete'); + fetchMany([ + { + methodname: (type === 'field') ? 'core_customfield_delete_field' : 'core_customfield_delete_category', + args: {id}, + }, + {methodname: 'core_customfield_reload_template', args: {component, area, itemid}} + ])[1] + .then(response => Templates.render('core_customfield/list', response)) + .then((html, js) => Templates.replaceNode(jQuery('[data-region="list-page"]'), html, js)) + .then(pendingDeletePromise.resolve) + .catch(Notification.exception); + }); + }) + .then(pendingPromise.resolve) + .catch(Notification.exception); +}; + + +/** + * Creates a new custom fields category with default name and updates the list + * + * @param {String} component + * @param {String} area + * @param {Number} itemid + */ +const createNewCategory = (component, area, itemid) => { + const pendingPromise = new Pending('core_customfield/form:createNewCategory'); + const promises = fetchMany([ + {methodname: 'core_customfield_create_category', args: {component, area, itemid}}, + {methodname: 'core_customfield_reload_template', args: {component, area, itemid}} + ]); + + promises[1].then(response => Templates.render('core_customfield/list', response)) + .then((html, js) => Templates.replaceNode(jQuery('[data-region="list-page"]'), html, js)) + .then(() => pendingPromise.resolve()) + .catch(Notification.exception); +}; + +/** + * Create new custom field + * + * @param {HTMLElement} element + * @param {String} component + * @param {String} area + * @param {Number} itemid + */ +const createNewField = (element, component, area, itemid) => { + const pendingPromise = new Pending('core_customfield/form:createNewField'); + + const returnFocus = element.closest(".action-menu").querySelector(".dropdown-toggle"); + const form = new ModalForm({ + formClass: "core_customfield\\field_config_form", + args: { + categoryid: element.getAttribute('data-categoryid'), + type: element.getAttribute('data-type'), + }, + modalConfig: { + title: getString('addingnewcustomfield', 'core_customfield', element.getAttribute('data-typename')), + }, + returnFocus, + }); + + form.addEventListener(form.events.FORM_SUBMITTED, () => { + const pendingCreatedPromise = new Pending('core_customfield/form:createdNewField'); + const promises = fetchMany([ + {methodname: 'core_customfield_reload_template', args: {component: component, area: area, itemid: itemid}} + ]); + + promises[0].then(response => Templates.render('core_customfield/list', response)) + .then((html, js) => Templates.replaceNode(jQuery('[data-region="list-page"]'), html, js)) + .then(() => pendingCreatedPromise.resolve()) + .catch(() => window.location.reload()); + }); + + form.show(); + + pendingPromise.resolve(); +}; + +/** + * Edit custom field + * + * @param {HTMLElement} element + * @param {String} component + * @param {String} area + * @param {Number} itemid + */ +const editField = (element, component, area, itemid) => { + const pendingPromise = new Pending('core_customfield/form:editField'); + + const form = new ModalForm({ + formClass: "core_customfield\\field_config_form", + args: { + id: element.getAttribute('data-id'), + }, + modalConfig: { + title: getString('editingfield', 'core_customfield', element.getAttribute('data-name')), + }, + returnFocus: element, + }); + + form.addEventListener(form.events.FORM_SUBMITTED, () => { + const pendingCreatedPromise = new Pending('core_customfield/form:createdNewField'); + const promises = fetchMany([ + {methodname: 'core_customfield_reload_template', args: {component: component, area: area, itemid: itemid}} + ]); + + promises[0].then(response => Templates.render('core_customfield/list', response)) + .then((html, js) => Templates.replaceNode(jQuery('[data-region="list-page"]'), html, js)) + .then(() => pendingCreatedPromise.resolve()) + .catch(() => window.location.reload()); + }); + + form.show(); + + pendingPromise.resolve(); +}; + +/** + * Fetch the category name from an inplace editable, given a child node of that field. + * + * @param {NodeElement} nodeElement + * @returns {String} + */ +const getCategoryNameFor = nodeElement => nodeElement + .closest('[data-category-id]') + .find('[data-inplaceeditable][data-itemtype=category][data-component=core_customfield]') + .attr('data-value'); + +const setupSortableLists = rootNode => { + // Sort category. + const sortCat = new SortableList( + '#customfield_catlist .categorieslist', + { + moveHandlerSelector: '.movecategory [data-drag-type=move]', + } + ); + sortCat.getElementName = nodeElement => Promise.resolve(getCategoryNameFor(nodeElement)); + + // Note: The sortable list currently uses jQuery events. + jQuery('[data-category-id]').on(SortableList.EVENTS.DROP, (evt, info) => { + if (info.positionChanged) { + const pendingPromise = new Pending('core_customfield/form:categoryid:on:sortablelist-drop'); + fetchMany([{ + methodname: 'core_customfield_move_category', + args: { + id: info.element.data('category-id'), + beforeid: info.targetNextElement.data('category-id') + } + + }])[0] + .then(pendingPromise.resolve) + .catch(Notification.exception); + } + evt.stopPropagation(); // Important for nested lists to prevent multiple targets. + }); + + // Sort fields. + var sort = new SortableList( + '#customfield_catlist .fieldslist tbody', + { + moveHandlerSelector: '.movefield [data-drag-type=move]', + } + ); + + sort.getDestinationName = (parentElement, afterElement) => { + if (!afterElement.length) { + return getString('totopofcategory', 'customfield', getCategoryNameFor(parentElement)); + } else if (afterElement.attr('data-field-name')) { + return getString('afterfield', 'customfield', afterElement.attr('data-field-name')); + } else { + return Promise.resolve(''); + } + }; + + jQuery('[data-field-name]').on(SortableList.EVENTS.DROP, (evt, info) => { + if (info.positionChanged) { + const pendingPromise = new Pending('core_customfield/form:fieldname:on:sortablelist-drop'); + fetchMany([{ + methodname: 'core_customfield_move_field', + args: { + id: info.element.data('field-id'), + beforeid: info.targetNextElement.data('field-id'), + categoryid: Number(info.targetList.closest('[data-category-id]').attr('data-category-id')) + }, + }])[0] + .then(pendingPromise.resolve) + .catch(Notification.exception); + } + evt.stopPropagation(); // Important for nested lists to prevent multiple targets. + }); + + jQuery('[data-field-name]').on(SortableList.EVENTS.DRAG, evt => { + var pendingPromise = new Pending('core_customfield/form:fieldname:on:sortablelist-drag'); + + evt.stopPropagation(); // Important for nested lists to prevent multiple targets. + + // Refreshing fields tables. + Templates.render('core_customfield/nofields', {}) + .then(html => { + rootNode.querySelectorAll('.categorieslist > *') + .forEach(category => { + const fields = category.querySelectorAll('.field:not(.sortable-list-is-dragged)'); + const noFields = category.querySelector('.nofields'); + + if (!fields.length && !noFields) { + category.querySelector('tbody').innerHTML = html; + } else if (fields.length && noFields) { + noFields.remove(); + } + }); + return; + }) + .then(pendingPromise.resolve) + .catch(Notification.exception); + }); + + jQuery('[data-category-id], [data-field-name]').on(SortableList.EVENTS.DRAGSTART, (evt, info) => { + setTimeout(() => { + jQuery('.sortable-list-is-dragged').width(info.element.width()); + }, 501); + }); +}; + +/** + * Initialise the custom fields manager. + */ +export const init = () => { + const rootNode = document.querySelector('#customfield_catlist'); + + const component = rootNode.dataset.component; + const area = rootNode.dataset.area; + const itemid = rootNode.dataset.itemid; + + rootNode.addEventListener('click', e => { + const roleHolder = e.target.closest('[data-role]'); + if (!roleHolder) { + return; + } + + if (roleHolder.dataset.role === 'deletefield') { + e.preventDefault(); + + confirmDelete(roleHolder.dataset.id, 'field', component, area, itemid); + return; + } + + if (roleHolder.dataset.role === 'deletecategory') { + e.preventDefault(); + + confirmDelete(roleHolder.dataset.id, 'category', component, area, itemid); + return; + } + + if (roleHolder.dataset.role === 'addnewcategory') { + e.preventDefault(); + createNewCategory(component, area, itemid); + + return; + } + + if (roleHolder.dataset.role === 'addfield') { + e.preventDefault(); + createNewField(roleHolder, component, area, itemid); + + return; + } + + if (roleHolder.dataset.role === 'editfield') { + e.preventDefault(); + editField(roleHolder, component, area, itemid); + + return; + } + }); + + setupSortableLists(rootNode, component, area, itemid); +}; diff --git a/classes/api.php b/classes/api.php new file mode 100644 index 0000000..6ebce70 --- /dev/null +++ b/classes/api.php @@ -0,0 +1,442 @@ +. + +/** + * Api customfield package + * + * @package core_customfield + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield; + +use core\output\inplace_editable; +use core_customfield\event\category_created; +use core_customfield\event\category_deleted; +use core_customfield\event\category_updated; +use core_customfield\event\field_created; +use core_customfield\event\field_deleted; +use core_customfield\event\field_updated; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class api + * + * @package core_customfield + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class api { + + /** + * For the given instance and list of fields fields retrieves data associated with them + * + * @param field_controller[] $fields list of fields indexed by field id + * @param int $instanceid + * @param bool $adddefaults + * @return data_controller[] array of data_controller objects indexed by fieldid. All fields are present, + * some data_controller objects may have 'id', some not + * If ($adddefaults): All fieldids are present, some data_controller objects may have 'id', some not. + * If (!$adddefaults): Only fieldids with data are present, all data_controller objects have 'id'. + */ + public static function get_instance_fields_data(array $fields, int $instanceid, bool $adddefaults = true) : array { + return self::get_instances_fields_data($fields, [$instanceid], $adddefaults)[$instanceid]; + } + + /** + * For given list of instances and fields retrieves data associated with them + * + * @param field_controller[] $fields list of fields indexed by field id + * @param int[] $instanceids + * @param bool $adddefaults + * @return data_controller[][] 2-dimension array, first index is instanceid, second index is fieldid. + * If ($adddefaults): All instanceids and all fieldids are present, some data_controller objects may have 'id', some not. + * If (!$adddefaults): All instanceids are present but only fieldids with data are present, all + * data_controller objects have 'id'. + */ + public static function get_instances_fields_data(array $fields, array $instanceids, bool $adddefaults = true) : array { + global $DB; + + // Create the results array where instances and fields order is the same as in the input arrays. + $result = array_fill_keys($instanceids, array_fill_keys(array_keys($fields), null)); + + if (empty($instanceids) || empty($fields)) { + return $result; + } + + // Retrieve all existing data. + list($sqlfields, $params) = $DB->get_in_or_equal(array_keys($fields), SQL_PARAMS_NAMED, 'fld'); + list($sqlinstances, $iparams) = $DB->get_in_or_equal($instanceids, SQL_PARAMS_NAMED, 'ins'); + $sql = "SELECT d.* + FROM {customfield_field} f + JOIN {customfield_data} d ON (f.id = d.fieldid AND d.instanceid {$sqlinstances}) + WHERE f.id {$sqlfields}"; + $fieldsdata = $DB->get_recordset_sql($sql, $params + $iparams); + foreach ($fieldsdata as $data) { + $result[$data->instanceid][$data->fieldid] = data_controller::create(0, $data, $fields[$data->fieldid]); + } + $fieldsdata->close(); + + if ($adddefaults) { + // Add default data where it was not retrieved. + foreach ($instanceids as $instanceid) { + foreach ($fields as $fieldid => $field) { + if ($result[$instanceid][$fieldid] === null) { + $result[$instanceid][$fieldid] = + data_controller::create(0, (object)['instanceid' => $instanceid], $field); + } + } + } + } else { + // Remove null-placeholders for data that was not retrieved. + foreach ($instanceids as $instanceid) { + $result[$instanceid] = array_filter($result[$instanceid]); + } + } + + return $result; + } + + /** + * Retrieve a list of all available custom field types + * + * @return array a list of the fieldtypes suitable to use in a select statement + */ + public static function get_available_field_types() { + $fieldtypes = array(); + + $plugins = \core\plugininfo\customfield::get_enabled_plugins(); + foreach ($plugins as $type => $unused) { + $fieldtypes[$type] = get_string('pluginname', 'customfield_' . $type); + } + asort($fieldtypes); + + return $fieldtypes; + } + + /** + * Updates or creates a field with data that came from a form + * + * @param field_controller $field + * @param \stdClass $formdata + */ + public static function save_field_configuration(field_controller $field, \stdClass $formdata) { + foreach ($formdata as $key => $value) { + if ($key === 'configdata' && is_array($formdata->configdata)) { + $field->set($key, json_encode($value)); + } else if ($key === 'id' || ($key === 'type' && $field->get('id'))) { + continue; + } else if (field::has_property($key)) { + $field->set($key, $value); + } + } + + $isnewfield = empty($field->get('id')); + + // Process files in description. + if (isset($formdata->description_editor)) { + if (!$field->get('id')) { + // We need 'id' field to store files used in description. + $field->save(); + } + + $data = (object) ['description_editor' => $formdata->description_editor]; + $textoptions = $field->get_handler()->get_description_text_options(); + $data = file_postupdate_standard_editor($data, 'description', $textoptions, $textoptions['context'], + 'core_customfield', 'description', $field->get('id')); + $field->set('description', $data->description); + $field->set('descriptionformat', $data->descriptionformat); + } + + // Save the field. + $field->save(); + + if ($isnewfield) { + // Move to the end of the category. + self::move_field($field, $field->get('categoryid')); + } + + if ($isnewfield) { + field_created::create_from_object($field)->trigger(); + } else { + field_updated::create_from_object($field)->trigger(); + } + } + + /** + * Change fields sort order, move field to another category + * + * @param field_controller $field field that needs to be moved + * @param int $categoryid category that needs to be moved + * @param int $beforeid id of the category this category needs to be moved before, 0 to move to the end + */ + public static function move_field(field_controller $field, int $categoryid, int $beforeid = 0) { + global $DB; + + if ($field->get('categoryid') != $categoryid) { + // Move field to another category. Validate that this category exists and belongs to the same component/area/itemid. + $category = $field->get_category(); + $DB->get_record(category::TABLE, [ + 'component' => $category->get('component'), + 'area' => $category->get('area'), + 'itemid' => $category->get('itemid'), + 'id' => $categoryid], 'id', MUST_EXIST); + $field->set('categoryid', $categoryid); + $field->save(); + field_updated::create_from_object($field)->trigger(); + } + + // Reorder fields in the target category. + $records = $DB->get_records(field::TABLE, ['categoryid' => $categoryid], 'sortorder, id', '*'); + + $id = $field->get('id'); + $fieldsids = array_values(array_diff(array_keys($records), [$id])); + $idx = $beforeid ? array_search($beforeid, $fieldsids) : false; + if ($idx === false) { + // Set as the last field. + $fieldsids = array_merge($fieldsids, [$id]); + } else { + // Set before field with id $beforeid. + $fieldsids = array_merge(array_slice($fieldsids, 0, $idx), [$id], array_slice($fieldsids, $idx)); + } + + foreach (array_values($fieldsids) as $idx => $fieldid) { + // Use persistent class to update the sortorder for each field that needs updating. + if ($records[$fieldid]->sortorder != $idx) { + $f = ($fieldid == $id) ? $field : new field(0, $records[$fieldid]); + $f->set('sortorder', $idx); + $f->save(); + } + } + } + + /** + * Delete a field + * + * @param field_controller $field + */ + public static function delete_field_configuration(field_controller $field) : bool { + $event = field_deleted::create_from_object($field); + get_file_storage()->delete_area_files($field->get_handler()->get_configuration_context()->id, 'core_customfield', + 'description', $field->get('id')); + $result = $field->delete(); + $event->trigger(); + return $result; + } + + /** + * Returns an object for inplace editable + * + * @param category_controller $category category that needs to be moved + * @param bool $editable + * @return inplace_editable + */ + public static function get_category_inplace_editable(category_controller $category, bool $editable = true) : inplace_editable { + return new inplace_editable('core_customfield', + 'category', + $category->get('id'), + $editable, + $category->get_formatted_name(), + $category->get('name'), + get_string('editcategoryname', 'core_customfield'), + get_string('newvaluefor', 'core_form', format_string($category->get('name'))) + ); + } + + /** + * Reorder categories, move given category before another category + * + * @param category_controller $category category that needs to be moved + * @param int $beforeid id of the category this category needs to be moved before, 0 to move to the end + */ + public static function move_category(category_controller $category, int $beforeid = 0) { + global $DB; + $records = $DB->get_records(category::TABLE, [ + 'component' => $category->get('component'), + 'area' => $category->get('area'), + 'itemid' => $category->get('itemid') + ], 'sortorder, id', '*'); + + $id = $category->get('id'); + $categoriesids = array_values(array_diff(array_keys($records), [$id])); + $idx = $beforeid ? array_search($beforeid, $categoriesids) : false; + if ($idx === false) { + // Set as the last category. + $categoriesids = array_merge($categoriesids, [$id]); + } else { + // Set before category with id $beforeid. + $categoriesids = array_merge(array_slice($categoriesids, 0, $idx), [$id], array_slice($categoriesids, $idx)); + } + + foreach (array_values($categoriesids) as $idx => $categoryid) { + // Use persistent class to update the sortorder for each category that needs updating. + if ($records[$categoryid]->sortorder != $idx) { + $c = ($categoryid == $id) ? $category : category_controller::create(0, $records[$categoryid]); + $c->set('sortorder', $idx); + $c->save(); + } + } + } + + /** + * Insert or update custom field category + * + * @param category_controller $category + */ + public static function save_category(category_controller $category) { + $isnewcategory = empty($category->get('id')); + + $category->save(); + + if ($isnewcategory) { + // Move to the end. + self::move_category($category); + category_created::create_from_object($category)->trigger(); + } else { + category_updated::create_from_object($category)->trigger(); + } + } + + /** + * Delete a custom field category + * + * @param category_controller $category + * @return bool + */ + public static function delete_category(category_controller $category) : bool { + $event = category_deleted::create_from_object($category); + + // Delete all fields. + foreach ($category->get_fields() as $field) { + self::delete_field_configuration($field); + } + + $result = $category->delete(); + $event->trigger(); + return $result; + } + + /** + * Returns a list of categories with their related fields. + * + * @param string $component + * @param string $area + * @param int $itemid + * @return category_controller[] + */ + public static function get_categories_with_fields(string $component, string $area, int $itemid) : array { + global $DB; + + $categories = []; + + $options = [ + 'component' => $component, + 'area' => $area, + 'itemid' => $itemid + ]; + + $plugins = \core\plugininfo\customfield::get_enabled_plugins(); + list($sqlfields, $params) = $DB->get_in_or_equal(array_keys($plugins), SQL_PARAMS_NAMED, 'param', true, null); + + $fields = 'f.*, ' . join(', ', array_map(function($field) { + return "c.$field AS category_$field"; + }, array_diff(array_keys(category::properties_definition()), ['usermodified', 'timemodified']))); + $sql = "SELECT $fields + FROM {customfield_category} c + LEFT JOIN {customfield_field} f ON c.id = f.categoryid AND f.type $sqlfields + WHERE c.component = :component AND c.area = :area AND c.itemid = :itemid + ORDER BY c.sortorder, f.sortorder"; + $fieldsdata = $DB->get_recordset_sql($sql, $options + $params); + + foreach ($fieldsdata as $data) { + if (!array_key_exists($data->category_id, $categories)) { + $categoryobj = new \stdClass(); + foreach ($data as $key => $value) { + if (preg_match('/^category_(.*)$/', $key, $matches)) { + $categoryobj->{$matches[1]} = $value; + } + } + $category = category_controller::create(0, $categoryobj); + $categories[$categoryobj->id] = $category; + } else { + $category = $categories[$data->categoryid]; + } + if ($data->id) { + $fieldobj = new \stdClass(); + foreach ($data as $key => $value) { + if (!preg_match('/^category_/', $key)) { + $fieldobj->{$key} = $value; + } + } + $field = field_controller::create(0, $fieldobj, $category); + } + } + $fieldsdata->close(); + + return $categories; + } + + /** + * Prepares the object to pass to field configuration form set_data() method + * + * @param field_controller $field + * @return \stdClass + */ + public static function prepare_field_for_config_form(field_controller $field) : \stdClass { + if ($field->get('id')) { + $formdata = $field->to_record(); + $formdata->configdata = $field->get('configdata'); + // Preprocess the description. + $textoptions = $field->get_handler()->get_description_text_options(); + file_prepare_standard_editor($formdata, 'description', $textoptions, $textoptions['context'], 'core_customfield', + 'description', $formdata->id); + } else { + $formdata = (object)['categoryid' => $field->get('categoryid'), 'type' => $field->get('type'), 'configdata' => []]; + } + // Allow field to do more preprocessing (usually for editor or filemanager elements). + $field->prepare_for_config_form($formdata); + return $formdata; + } + + /** + * Get a list of the course custom fields that support course grouping in + * block_myoverview + * @return array $shortname => $name + */ + public static function get_fields_supporting_course_grouping() { + global $DB; + $sql = " + SELECT f.* + FROM {customfield_field} f + JOIN {customfield_category} cat ON cat.id = f.categoryid + WHERE cat.component = 'core_course' AND cat.area = 'course' + ORDER BY f.name + "; + $ret = []; + $fields = $DB->get_records_sql($sql); + foreach ($fields as $field) { + $inst = field_controller::create(0, $field); + $isvisible = $inst->get_configdata_property('visibility') == \core_course\customfield\course_handler::VISIBLETOALL; + // Only visible fields to everybody supporting course grouping will be displayed. + if ($inst->supports_course_grouping() && $isvisible) { + $ret[$inst->get('shortname')] = $inst->get('name'); + } + } + return $ret; + } +} diff --git a/classes/category.php b/classes/category.php new file mode 100644 index 0000000..dd89f55 --- /dev/null +++ b/classes/category.php @@ -0,0 +1,87 @@ +. + +/** + * Customfield category persistent class + * + * @package core_customfield + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield; + +use core\persistent; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class category + * + * @package core_customfield + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class category extends persistent { + /** + * Database table. + */ + const TABLE = 'customfield_category'; + + /** + * Return the definition of the properties of this model. + * + * @return array + */ + protected static function define_properties() : array { + return array( + 'name' => [ + 'type' => PARAM_TEXT, + ], + 'description' => [ + 'type' => PARAM_RAW, + 'optional' => true, + 'default' => null, + 'null' => NULL_ALLOWED + ], + 'descriptionformat' => [ + 'type' => PARAM_INT, + 'default' => FORMAT_MOODLE, + 'optional' => true + ], + 'component' => [ + 'type' => PARAM_COMPONENT + ], + 'area' => [ + 'type' => PARAM_COMPONENT + ], + 'itemid' => [ + 'type' => PARAM_INT, + 'optional' => true, + 'default' => 0 + ], + 'contextid' => [ + 'type' => PARAM_INT, + 'optional' => false + ], + 'sortorder' => [ + 'type' => PARAM_INT, + 'optional' => true, + 'default' => -1 + ], + ); + } +} diff --git a/classes/category_controller.php b/classes/category_controller.php new file mode 100644 index 0000000..601b106 --- /dev/null +++ b/classes/category_controller.php @@ -0,0 +1,231 @@ +. + +/** + * Customfield catecory controller class + * + * @package core_customfield + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class category + * + * @package core_customfield + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class category_controller { + + /** + * Category persistent + * + * @var category + */ + protected $category; + + /** + * @var field_controller[] + */ + protected $fields = []; + + /** @var handler */ + protected $handler; + + /** + * category constructor. + * + * This class is not abstract, however the constructor was made protected to be consistent with + * field_controller and data_controller + * + * @param int $id + * @param \stdClass|null $record + */ + protected function __construct(int $id = 0, \stdClass $record = null) { + $this->category = new category($id, $record); + } + + /** + * Creates an instance of category_controller + * + * Either $id or $record or $handler need to be specified + * If handler is known pass it to constructor to avoid retrieving it later + * Component, area and itemid must not conflict with the ones in handler + * + * @param int $id + * @param \stdClass|null $record + * @param handler|null $handler + * @return category_controller + * @throws \moodle_exception + * @throws \coding_exception + */ + public static function create(int $id, \stdClass $record = null, handler $handler = null) : category_controller { + global $DB; + if ($id && $record) { + // This warning really should be in persistent as well. + debugging('Too many parameters, either id need to be specified or a record, but not both.', + DEBUG_DEVELOPER); + } + if ($id) { + if (!$record = $DB->get_record(category::TABLE, array('id' => $id), '*', IGNORE_MISSING)) { + throw new \moodle_exception('categorynotfound', 'core_customfield'); + } + } + if (empty($record->component)) { + if (!$handler) { + throw new \coding_exception('Not enough parameters to initialise category_controller - unknown component'); + } + $record->component = $handler->get_component(); + } + if (empty($record->area)) { + if (!$handler) { + throw new \coding_exception('Not enough parameters to initialise category_controller - unknown area'); + } + $record->area = $handler->get_area(); + } + if (!isset($record->itemid)) { + if (!$handler) { + throw new \coding_exception('Not enough parameters to initialise category_controller - unknown itemid'); + } + $record->itemid = $handler->get_itemid(); + } + $category = new self(0, $record); + if (!$category->get('contextid')) { + // If contextid was not present in the record we can find it out from the handler. + $handlernew = $handler ?? $category->get_handler(); + $category->set('contextid', $handlernew->get_configuration_context()->id); + } + if ($handler) { + $category->set_handler($handler); + } + return $category; + } + + /** + * Persistent getter parser. + * + * @param string $property + * @return mixed + */ + final public function get($property) { + return $this->category->get($property); + } + + /** + * Persistent setter parser. + * + * @param string $property + * @param mixed $value + */ + final public function set($property, $value) { + return $this->category->set($property, $value); + } + + /** + * Persistent delete parser. + * + * @return bool + */ + final public function delete() { + return $this->category->delete(); + } + + /** + * Persistent save parser. + * + * @return void + */ + final public function save() { + $this->category->save(); + } + + /** + * Return an array of field objects associated with this category. + * + * @return field_controller[] + */ + public function get_fields() { + return $this->fields; + } + + /** + * Adds a child field + * + * @param field_controller $field + */ + public function add_field(field_controller $field) { + $this->fields[$field->get('id')] = $field; + } + + /** + * Gets a handler, if not known retrieve it + * + * @return handler + */ + public function get_handler() : handler { + if ($this->handler === null) { + $this->handler = handler::get_handler($this->get('component'), $this->get('area'), $this->get('itemid')); + } + return $this->handler; + } + + /** + * Allows to set handler so we don't need to retrieve it later + * + * @param handler $handler + * @throws \coding_exception + */ + public function set_handler(handler $handler) { + // Make sure there are no conflicts. + if ($this->get('component') !== $handler->get_component()) { + throw new \coding_exception('Component of the handler does not match the one from the record'); + } + if ($this->get('area') !== $handler->get_area()) { + throw new \coding_exception('Area of the handler does not match the one from the record'); + } + if ($this->get('itemid') != $handler->get_itemid()) { + throw new \coding_exception('Itemid of the handler does not match the one from the record'); + } + if ($this->get('contextid') != $handler->get_configuration_context()->id) { + throw new \coding_exception('Context of the handler does not match the one from the record'); + } + $this->handler = $handler; + } + + /** + * Persistent to_record parser. + * + * @return \stdClass + */ + final public function to_record() { + return $this->category->to_record(); + } + + /** + * Returns the category name formatted according to configuration context. + * + * @return string + */ + public function get_formatted_name() : string { + $context = $this->get_handler()->get_configuration_context(); + return format_string($this->get('name'), true, ['context' => $context]); + } +} diff --git a/classes/data.php b/classes/data.php new file mode 100644 index 0000000..fc23a82 --- /dev/null +++ b/classes/data.php @@ -0,0 +1,107 @@ +. + +/** + * Data persistent class + * + * @package core_customfield + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield; + +use core\persistent; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class data + * + * @package core_customfield + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class data extends persistent { + + /** + * Database data. + */ + const TABLE = 'customfield_data'; + + /** + * Return the definition of the properties of this model. + * + * @return array + */ + protected static function define_properties() : array { + return array( + 'fieldid' => [ + 'type' => PARAM_INT, + 'optional' => false, + 'null' => NULL_NOT_ALLOWED + ], + 'instanceid' => [ + 'type' => PARAM_INT, + 'optional' => false, + 'null' => NULL_NOT_ALLOWED + ], + 'intvalue' => [ + 'type' => PARAM_INT, + 'optional' => true, + 'default' => null, + 'null' => NULL_ALLOWED + ], + 'decvalue' => [ + 'type' => PARAM_FLOAT, + 'optional' => true, + 'default' => null, + 'null' => NULL_ALLOWED + ], + 'charvalue' => [ + 'type' => PARAM_TEXT, + 'optional' => true, + 'default' => null, + 'null' => NULL_ALLOWED + ], + 'shortcharvalue' => [ + 'type' => PARAM_TEXT, + 'optional' => true, + 'default' => null, + 'null' => NULL_ALLOWED + ], + // Mandatory field. + 'value' => [ + 'type' => PARAM_RAW, + 'null' => NULL_NOT_ALLOWED, + 'default' => '' + ], + // Mandatory field. + 'valueformat' => [ + 'type' => PARAM_INT, + 'null' => NULL_NOT_ALLOWED, + 'default' => FORMAT_MOODLE, + 'optional' => true + ], + 'contextid' => [ + 'type' => PARAM_INT, + 'optional' => false, + 'null' => NULL_NOT_ALLOWED + ] + ); + } + +} diff --git a/classes/data_controller.php b/classes/data_controller.php index aabc2ff..bd19792 100644 --- a/classes/data_controller.php +++ b/classes/data_controller.php @@ -15,135 +15,289 @@ // along with Moodle. If not, see . /** - * Customfield multiselect Type + * Customfield component data controller abstract class * - * @package customfield_multiselect - * @copyright 2020 CALL Learning 2020 - Laurent David - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package core_customfield + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -namespace customfield_multiselect; +namespace core_customfield; -use core_customfield\data; +use core_customfield\output\field_data; defined('MOODLE_INTERNAL') || die; /** - * Class data + * Base class for custom fields data controllers + * + * This class is a wrapper around the persistent data class that allows to define + * how the element behaves in the instance edit forms. * - * @package customfield_multiselect - * @copyright 2018 Daniel Neis Araujo + * Custom field plugins must define a class + * \{pluginname}\data_controller extends \core_customfield\data_controller + * + * @package core_customfield + * @copyright 2018 Toni Barbera * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class data_controller extends \core_customfield\data_controller { +abstract class data_controller { + /** + * Data persistent + * + * @var data + */ + protected $data; /** - * Datafield value (here 'value') + * Field that this data belongs to. * - * @return string + * @var field_controller + */ + protected $field; + + /** + * data_controller constructor. + * + * @param int $id + * @param \stdClass|null $record */ - public function datafield(): string { - return 'value'; // There could be a discussion here if it could not be a char value, but for long list that could have - // been a limitation. + public function __construct(int $id, \stdClass $record) { + $this->data = new data($id, $record); } /** - * Get the default value for this field. The default value is a list of valid options. - * We just verify they exist before sending their index back. + * Creates an instance of data_controller + * + * Parameters $id, $record and $field can complement each other but not conflict. + * If $id is not specified, fieldid must be present either in $record or in $field. + * If $id is not specified, instanceid must be present in $record * - * @return string a list of comma separated index of matching options + * No DB queries are performed if both $record and $field are specified. + + * @param int $id + * @param \stdClass|null $record + * @param field_controller|null $field + * @return data_controller + * @throws \coding_exception + * @throws \moodle_exception */ - public function get_default_value() { - $defaultvalue = $this->get_field()->get_configdata_property('defaultvalue'); - $options = $this->get_field()->get_options(); - $defaultvaluesarray = []; - $values = explode(",", $defaultvalue); + public static function create(int $id, \stdClass $record = null, field_controller $field = null) : data_controller { + global $DB; + if ($id && $record) { + // This warning really should be in persistent as well. + debugging('Too many parameters, either id need to be specified or a record, but not both.', + DEBUG_DEVELOPER); + } + if ($id) { + $record = $DB->get_record(data::TABLE, array('id' => $id), '*', MUST_EXIST); + } else if (!$record) { + $record = new \stdClass(); + } - foreach ($values as $val) { - $index = $this->get_option_index($val, $options); - if ($index !== false) { - $defaultvaluesarray[] = intval($index); - } + if (!$field && empty($record->fieldid)) { + throw new \coding_exception('Not enough parameters to initialise data_controller - unknown field'); + } + if (!$field) { + $field = field_controller::create($record->fieldid); + } + if (empty($record->fieldid)) { + $record->fieldid = $field->get('id'); + } + if ($field->get('id') != $record->fieldid) { + throw new \coding_exception('Field id from the record does not match field from the parameter'); } - return implode(',', $defaultvaluesarray); + $type = $field->get('type'); + $customfieldtype = "\\customfield_{$type}\\data_controller"; + if (!class_exists($customfieldtype) || !is_subclass_of($customfieldtype, self::class)) { + throw new \moodle_exception('errorfieldtypenotfound', 'core_customfield', '', s($type)); + } + $datacontroller = new $customfieldtype(0, $record); + $datacontroller->field = $field; + return $datacontroller; } /** - * Get the option index in the array of options from the raw text value + * Returns the name of the field to be used on HTML forms. * - * @param mixed $rawvalue - * @param array $options - * @return false|int|string + * @return string */ - protected function get_option_index($rawvalue, $options) { - return array_search($rawvalue, $options); + public function get_form_element_name() : string { + return 'customfield_' . $this->get_field()->get('shortname'); } /** - * Define the form + * Persistent getter parser. * - * @param \MoodleQuickForm $mform - * @throws \coding_exception + * @param string $property + * @return mixed */ - public function instance_form_definition(\MoodleQuickForm $mform) { - $field = $this->get_field(); - $config = $field->get('configdata'); - $options = $field->get_options(); - $formattedoptions = []; - $context = $this->get_field()->get_handler()->get_configuration_context(); - foreach ($options as $key => $option) { - // Multilang formatting with filters. - $formattedoptions[$key] = format_string($option, true, ['context' => $context]); - } + final public function get($property) { + return $this->data->get($property); + } - $elementname = $this->get_form_element_name(); - $attributes = array('multiple' => true); - $mform->addElement('autocomplete', $elementname, - $this->get_field()->get_formatted_name(), - $formattedoptions, - $attributes); - - if (($defaultkey = array_search($config['defaultvalue'], $options)) !== false) { - $mform->setDefault($elementname, $defaultkey); - } - if ($field->get_configdata_property('required')) { - $mform->addRule($elementname, null, 'required', null, 'client'); - } + /** + * Persistent setter parser. + * + * @param string $property + * @param mixed $value + * @return data + */ + final public function set($property, $value) { + return $this->data->set($property, $value); } /** - * Prepares the custom field data related to the object to pass to mform->set_data() and adds them to it + * Return the name of the field in the db table {customfield_data} where the data is stored * - * This function must be called before calling $form->set_data($object); + * Must be one of the following: + * intvalue - can store integer values, this field is indexed + * decvalue - can store decimal values + * shortcharvalue - can store character values up to 255 characters long, this field is indexed + * charvalue - can store character values up to 1333 characters long, this field is not indexed but + * full text search is faster than on field 'value' + * value - can store character values of unlimited length ("text" field in the db) * - * @param \stdClass $instance the instance that has custom fields, if 'id' attribute is present the custom - * fields for this instance will be added, otherwise the default values will be added. + * @return string */ - public function instance_form_before_set_data(\stdClass $instance) { - $instance->{$this->get_form_element_name()} = $this->get_value(); + abstract public function datafield() : string; + + /** + * Delete data. Element can override it if related information needs to be deleted as well (such as files) + * + * @return bool + */ + public function delete() { + return $this->data->delete(); + } + + /** + * Persistent save parser. + * + * @return void + */ + public function save() { + $this->data->save(); + } + + /** + * Field associated with this data + * + * @return field_controller + */ + public function get_field() : field_controller { + return $this->field; } /** * Saves the data coming from form * * @param \stdClass $datanew data coming from the form - * @throws \coding_exception */ public function instance_form_save(\stdClass $datanew) { $elementname = $this->get_form_element_name(); if (!property_exists($datanew, $elementname)) { return; } - $value = implode(',', $datanew->$elementname); + $value = $datanew->$elementname; $this->data->set($this->datafield(), $value); $this->data->set('value', $value); $this->save(); } + /** + * Prepares the custom field data related to the object to pass to mform->set_data() and adds them to it + * + * This function must be called before calling $form->set_data($object); + * + * @param \stdClass $instance the instance that has custom fields, if 'id' attribute is present the custom + * fields for this instance will be added, otherwise the default values will be added. + */ + public function instance_form_before_set_data(\stdClass $instance) { + $instance->{$this->get_form_element_name()} = $this->get_value(); + } + + /** + * Checks if the value is empty + * + * @param mixed $value + * @return bool + */ + protected function is_empty($value) : bool { + if ($this->datafield() === 'value' || $this->datafield() === 'charvalue' || $this->datafield() === 'shortcharvalue') { + return '' . $value === ''; + } + return empty($value); + } + + /** + * Checks if the value is unique + * + * @param mixed $value + * @return bool + */ + protected function is_unique($value) : bool { + global $DB; + $datafield = $this->datafield(); + $where = "fieldid = ? AND {$datafield} = ?"; + $params = [$this->get_field()->get('id'), $value]; + if ($this->get('id')) { + $where .= ' AND id <> ?'; + $params[] = $this->get('id'); + } + return !$DB->record_exists_select('customfield_data', $where, $params); + } + + /** + * Called from instance edit form in validation() + * + * @param array $data + * @param array $files + * @return array array of errors + */ + public function instance_form_validation(array $data, array $files) : array { + $errors = []; + $elementname = $this->get_form_element_name(); + if ($this->get_field()->get_configdata_property('uniquevalues') == 1) { + $value = $data[$elementname]; + if (!$this->is_empty($value) && !$this->is_unique($value)) { + $errors[$elementname] = get_string('erroruniquevalues', 'core_customfield'); + } + } + return $errors; + } + + /** + * Called from instance edit form in definition_after_data() + * + * @param \MoodleQuickForm $mform + */ + public function instance_form_definition_after_data(\MoodleQuickForm $mform) { + + } + + /** + * Used by handlers to display data on various places. + * + * @return string + */ + public function display() : string { + global $PAGE; + $output = $PAGE->get_renderer('core_customfield'); + return $output->render(new field_data($this)); + } + + /** + * Returns the default value as it would be stored in the database (not in human-readable format). + * + * @return mixed + */ + public abstract function get_default_value(); + /** * Returns the value as it is stored in the database or default value if data record is not present * - * @return string comma separated list of items + * @return mixed */ public function get_value() { if (!$this->get('id')) { @@ -153,24 +307,27 @@ public function get_value() { } /** - * Set the value as it should be stored in the database + * Return the context of the field * - * @param array $value to be set and transformed into a comma separated string - * @return data + * @return \context */ - public function set_value($value) { - return $this->set($this->datafield(), implode(',', $value)); + public function get_context() : \context { + if ($this->get('contextid')) { + return \context::instance_by_id($this->get('contextid')); + } else if ($this->get('instanceid')) { + return $this->get_field()->get_handler()->get_instance_context($this->get('instanceid')); + } else { + // Context is not yet known (for example, entity is not yet created). + return \context_system::instance(); + } } /** - * Checks if the value is empty + * Add a field to the instance edit form. * - * @param mixed $value - * @return bool + * @param \MoodleQuickForm $mform */ - protected function is_empty($value): bool { - return trim($value) === ""; - } + public abstract function instance_form_definition(\MoodleQuickForm $mform); /** * Returns value in a human-readable format or default value if data record is not present @@ -180,25 +337,29 @@ protected function is_empty($value): bool { * @return mixed|null value or null if empty */ public function export_value() { - $values = $this->get_value(); // This is a string of comma separated list of indexes. + $value = $this->get_value(); - if ($this->is_empty($values)) { + if ($this->is_empty($value)) { return null; } - // Change into an array for parsing. - $valuesarray = explode(',', $values); - if (!$valuesarray) { - $valuesarray = []; - } - $commasepoptionvalues = ""; - $options = $this->get_field()->get_options(); - foreach ($valuesarray as $val) { - if (!empty($options[$val])) { - $commasepoptionvalues .= (empty($commasepoptionvalues) ? '' : ', ') . - format_string($options[$val], true, - ['context' => $this->get_field()->get_handler()->get_configuration_context()]); - } + + if ($this->datafield() === 'intvalue') { + return (int)$value; + } else if ($this->datafield() === 'decvalue') { + return (float)$value; + } else if ($this->datafield() === 'value') { + return format_text($value, $this->get('valueformat'), ['context' => $this->get_context()]); + } else { + return format_string($value, true, ['context' => $this->get_context()]); } - return $commasepoptionvalues; + } + + /** + * Persistent to_record parser. + * + * @return \stdClass + */ + final public function to_record() { + return $this->data->to_record(); } } diff --git a/classes/event/category_created.php b/classes/event/category_created.php new file mode 100644 index 0000000..5a83d29 --- /dev/null +++ b/classes/event/category_created.php @@ -0,0 +1,84 @@ +. + +/** + * Custom field category created event. + * + * @package core_customfield + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield\event; + +use core_customfield\category_controller; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Custom field category created event class. + * + * @package core_customfield + * @since Moodle 3.6 + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class category_created extends \core\event\base { + + /** + * Initialise the event data. + */ + protected function init() { + $this->data['objecttable'] = 'customfield_category'; + $this->data['crud'] = 'c'; + $this->data['edulevel'] = self::LEVEL_OTHER; + } + + /** + * Creates an instance from a category controller object + * + * @param category_controller $category + * @return category_created + */ + public static function create_from_object(category_controller $category) : category_created { + $eventparams = [ + 'objectid' => $category->get('id'), + 'context' => $category->get_handler()->get_configuration_context(), + 'other' => ['name' => $category->get('name')] + ]; + $event = self::create($eventparams); + $event->add_record_snapshot($event->objecttable, $category->to_record()); + return $event; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name() { + return get_string('eventcategorycreated', 'core_customfield'); + } + + /** + * Returns non-localised description of what happened. + * + * @return string + */ + public function get_description() { + return "The user with id '$this->userid' created the category with id '$this->objectid'."; + } +} diff --git a/classes/event/category_deleted.php b/classes/event/category_deleted.php new file mode 100644 index 0000000..1e08f25 --- /dev/null +++ b/classes/event/category_deleted.php @@ -0,0 +1,84 @@ +. + +/** + * Custom field category created event. + * + * @package core_customfield + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield\event; + +use core_customfield\category_controller; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Custom field category created event class. + * + * @package core_customfield + * @since Moodle 3.6 + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class category_deleted extends \core\event\base { + + /** + * Initialise the event data. + */ + protected function init() { + $this->data['objecttable'] = 'customfield_category'; + $this->data['crud'] = 'd'; + $this->data['edulevel'] = self::LEVEL_OTHER; + } + + /** + * Creates an instance from a category controller object + * + * @param category_controller $category + * @return category_deleted + */ + public static function create_from_object(category_controller $category) : category_deleted { + $eventparams = [ + 'objectid' => $category->get('id'), + 'context' => $category->get_handler()->get_configuration_context(), + 'other' => ['name' => $category->get('name')] + ]; + $event = self::create($eventparams); + $event->add_record_snapshot($event->objecttable, $category->to_record()); + return $event; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name() { + return get_string('eventcategorydeleted', 'core_customfield'); + } + + /** + * Returns non-localised description of what happened. + * + * @return string + */ + public function get_description() { + return "The user with id '$this->userid' deleted the category with id '$this->objectid'."; + } +} diff --git a/classes/event/category_updated.php b/classes/event/category_updated.php new file mode 100644 index 0000000..ba7bc2c --- /dev/null +++ b/classes/event/category_updated.php @@ -0,0 +1,84 @@ +. + +/** + * Custom field category updated event. + * + * @package core_customfield + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield\event; + +use core_customfield\category_controller; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Custom field category updated event class. + * + * @package core_customfield + * @since Moodle 3.6 + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class category_updated extends \core\event\base { + + /** + * Initialise the event data. + */ + protected function init() { + $this->data['objecttable'] = 'customfield_category'; + $this->data['crud'] = 'c'; + $this->data['edulevel'] = self::LEVEL_OTHER; + } + + /** + * Creates an instance from a category controller object + * + * @param category_controller $category + * @return category_updated + */ + public static function create_from_object(category_controller $category) : category_updated { + $eventparams = [ + 'objectid' => $category->get('id'), + 'context' => $category->get_handler()->get_configuration_context(), + 'other' => ['name' => $category->get('name')] + ]; + $event = self::create($eventparams); + $event->add_record_snapshot($event->objecttable, $category->to_record()); + return $event; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name() { + return get_string('eventcategoryupdated', 'core_customfield'); + } + + /** + * Returns non-localised description of what happened. + * + * @return string + */ + public function get_description() { + return "The user with id '$this->userid' updated the category with id '$this->objectid'."; + } +} diff --git a/classes/event/field_created.php b/classes/event/field_created.php new file mode 100644 index 0000000..20d4f55 --- /dev/null +++ b/classes/event/field_created.php @@ -0,0 +1,87 @@ +. + +/** + * Custom field created event. + * + * @package core_customfield + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield\event; + +use core_customfield\field_controller; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Custom field created event class. + * + * @package core_customfield + * @since Moodle 3.6 + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class field_created extends \core\event\base { + + /** + * Initialise the event data. + */ + protected function init() { + $this->data['objecttable'] = 'customfield_field'; + $this->data['crud'] = 'c'; + $this->data['edulevel'] = self::LEVEL_OTHER; + } + + /** + * Creates an instance from a field controller object + * + * @param field_controller $field + * @return field_created + */ + public static function create_from_object(field_controller $field) : field_created { + $eventparams = [ + 'objectid' => $field->get('id'), + 'context' => $field->get_handler()->get_configuration_context(), + 'other' => [ + 'shortname' => $field->get('shortname'), + 'name' => $field->get('name') + ] + ]; + $event = self::create($eventparams); + $event->add_record_snapshot($event->objecttable, $field->to_record()); + return $event; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name() { + return get_string('eventfieldcreated', 'core_customfield'); + } + + /** + * Returns non-localised description of what happened. + * + * @return string + */ + public function get_description() { + return "The user with id '$this->userid' created the field with id '$this->objectid'."; + } +} diff --git a/classes/event/field_deleted.php b/classes/event/field_deleted.php new file mode 100644 index 0000000..586705d --- /dev/null +++ b/classes/event/field_deleted.php @@ -0,0 +1,87 @@ +. + +/** + * Custom field updated event. + * + * @package core_customfield + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield\event; + +use core_customfield\field_controller; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Custom field updated event class. + * + * @package core_customfield + * @since Moodle 3.6 + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class field_deleted extends \core\event\base { + + /** + * Initialise the event data. + */ + protected function init() { + $this->data['objecttable'] = 'customfield_field'; + $this->data['crud'] = 'd'; + $this->data['edulevel'] = self::LEVEL_OTHER; + } + + /** + * Creates an instance from a field controller object + * + * @param field_controller $field + * @return field_deleted + */ + public static function create_from_object(field_controller $field) : field_deleted { + $eventparams = [ + 'objectid' => $field->get('id'), + 'context' => $field->get_handler()->get_configuration_context(), + 'other' => [ + 'shortname' => $field->get('shortname'), + 'name' => $field->get('name') + ] + ]; + $event = self::create($eventparams); + $event->add_record_snapshot($event->objecttable, $field->to_record()); + return $event; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name() { + return get_string('eventfielddeleted', 'core_customfield'); + } + + /** + * Returns non-localised description of what happened. + * + * @return string + */ + public function get_description() { + return "The user with id '$this->userid' deleted the field with id '$this->objectid'."; + } +} diff --git a/classes/event/field_updated.php b/classes/event/field_updated.php new file mode 100644 index 0000000..619a4a4 --- /dev/null +++ b/classes/event/field_updated.php @@ -0,0 +1,87 @@ +. + +/** + * Custom field updated event. + * + * @package core_customfield + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield\event; + +use core_customfield\field_controller; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Custom field updated event class. + * + * @package core_customfield + * @since Moodle 3.6 + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class field_updated extends \core\event\base { + + /** + * Initialise the event data. + */ + protected function init() { + $this->data['objecttable'] = 'customfield_field'; + $this->data['crud'] = 'c'; + $this->data['edulevel'] = self::LEVEL_OTHER; + } + + /** + * Creates an instance from a field controller object + * + * @param field_controller $field + * @return field_updated + */ + public static function create_from_object(field_controller $field) : field_updated { + $eventparams = [ + 'objectid' => $field->get('id'), + 'context' => $field->get_handler()->get_configuration_context(), + 'other' => [ + 'shortname' => $field->get('shortname'), + 'name' => $field->get('name') + ] + ]; + $event = self::create($eventparams); + $event->add_record_snapshot($event->objecttable, $field->to_record()); + return $event; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name() { + return get_string('eventfieldupdated', 'core_customfield'); + } + + /** + * Returns non-localised description of what happened. + * + * @return string + */ + public function get_description() { + return "The user with id '$this->userid' updated the field with id '$this->objectid'."; + } +} diff --git a/classes/field.php b/classes/field.php new file mode 100644 index 0000000..1f7b7e1 --- /dev/null +++ b/classes/field.php @@ -0,0 +1,97 @@ +. + +/** + * Field persistent class + * + * @package core_customfield + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield; + +use core\persistent; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class field + * + * @package core_customfield + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class field extends persistent { + + /** + * Database table. + */ + const TABLE = 'customfield_field'; + + /** + * Return the definition of the properties of this model. + * + * @return array + */ + protected static function define_properties() : array { + return array( + 'name' => [ + 'type' => PARAM_TEXT, + ], + 'shortname' => [ + 'type' => PARAM_TEXT, + ], + 'type' => [ + 'type' => PARAM_PLUGIN, + ], + 'description' => [ + 'type' => PARAM_RAW, + 'optional' => true, + 'default' => null, + 'null' => NULL_ALLOWED + ], + 'descriptionformat' => [ + 'type' => PARAM_INT, + 'default' => FORMAT_MOODLE, + 'optional' => true + ], + 'sortorder' => [ + 'type' => PARAM_INT, + 'optional' => true, + 'default' => -1, + ], + 'categoryid' => [ + 'type' => PARAM_INT + ], + 'configdata' => [ + 'type' => PARAM_RAW, + 'optional' => true, + 'default' => null, + 'null' => NULL_ALLOWED + ], + ); + } + + /** + * Get decoded configdata. + * + * @return array + */ + protected function get_configdata() : array { + return json_decode($this->raw_get('configdata'), true) ?? array(); + } +} diff --git a/classes/field_config_form.php b/classes/field_config_form.php new file mode 100644 index 0000000..3d5d8bf --- /dev/null +++ b/classes/field_config_form.php @@ -0,0 +1,214 @@ +. + +/** + * Customfield package + * + * @package core_customfield + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class field_config_form + * + * @package core_customfield + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class field_config_form extends \core_form\dynamic_form { + + /** @var field_controller */ + protected $field; + + /** + * Class definition + * + * @throws \coding_exception + */ + public function definition() { + $mform = $this->_form; + + $field = $this->get_field(); + $handler = $field->get_handler(); + + $mform->addElement('header', '_commonsettings', get_string('commonsettings', 'core_customfield')); + + $mform->addElement('text', 'name', get_string('fieldname', 'core_customfield'), 'size="50"'); + $mform->addRule('name', null, 'required', null, 'client'); + $mform->setType('name', PARAM_TEXT); + + // Accepted values for 'shortname' would follow [a-z0-9_] pattern, + // but we are accepting any PARAM_TEXT value here, + // and checking [a-zA-Z0-9_] pattern in validation() function to throw an error when needed. + $mform->addElement('text', 'shortname', get_string('fieldshortname', 'core_customfield'), 'size=20'); + $mform->addHelpButton('shortname', 'shortname', 'core_customfield'); + $mform->addRule('shortname', null, 'required', null, 'client'); + $mform->setType('shortname', PARAM_TEXT); + + $desceditoroptions = $handler->get_description_text_options(); + $mform->addElement('editor', 'description_editor', get_string('description', 'core_customfield'), null, $desceditoroptions); + $mform->addHelpButton('description_editor', 'description', 'core_customfield'); + + // If field is required. + $mform->addElement('selectyesno', 'configdata[required]', get_string('isfieldrequired', 'core_customfield')); + $mform->addHelpButton('configdata[required]', 'isfieldrequired', 'core_customfield'); + $mform->setType('configdata[required]', PARAM_BOOL); + + // If field data is unique. + $mform->addElement('selectyesno', 'configdata[uniquevalues]', get_string('isdataunique', 'core_customfield')); + $mform->addHelpButton('configdata[uniquevalues]', 'isdataunique', 'core_customfield'); + $mform->setType('configdata[uniquevalues]', PARAM_BOOL); + + // Field specific settings from field type. + $field->config_form_definition($mform); + + // Handler/component settings. + $handler->config_form_definition($mform); + + // We add hidden fields. + $mform->addElement('hidden', 'categoryid'); + $mform->setType('categoryid', PARAM_INT); + + $mform->addElement('hidden', 'type'); + $mform->setType('type', PARAM_COMPONENT); + + $mform->addElement('hidden', 'id'); + $mform->setType('id', PARAM_INT); + + // This form is only used inside modal dialogues and never needs action buttons. + } + + /** + * Field data validation + * + * @param array $data + * @param array $files + * @return array + */ + public function validation($data, $files = array()) { + global $DB; + + $errors = array(); + $field = $this->get_field(); + $handler = $field->get_handler(); + + // Check the shortname is specified and is unique for this component-area-itemid combination. + if (!preg_match('/^[a-z0-9_]+$/', $data['shortname'])) { + // Check allowed pattern (numbers, letters and underscore). + $errors['shortname'] = get_string('invalidshortnameerror', 'core_customfield'); + } else if ($DB->record_exists_sql('SELECT 1 FROM {customfield_field} f ' . + 'JOIN {customfield_category} c ON c.id = f.categoryid ' . + 'WHERE f.shortname = ? AND f.id <> ? AND c.component = ? AND c.area = ? AND c.itemid = ?', + [$data['shortname'], $data['id'], + $handler->get_component(), $handler->get_area(), $handler->get_itemid()])) { + $errors['shortname'] = get_string('formfieldcheckshortname', 'core_customfield'); + } + + $errors = array_merge($errors, $field->config_form_validation($data, $files)); + + return $errors; + } + + /** + * Get field + * + * @return field_controller + * @throws \moodle_exception + */ + protected function get_field(): field_controller { + if ($this->field === null) { + if (!empty($this->_ajaxformdata['id'])) { + $this->field = \core_customfield\field_controller::create((int)$this->_ajaxformdata['id']); + } else if (!empty($this->_ajaxformdata['categoryid']) && !empty($this->_ajaxformdata['type'])) { + $category = \core_customfield\category_controller::create((int)$this->_ajaxformdata['categoryid']); + $type = clean_param($this->_ajaxformdata['type'], PARAM_PLUGIN); + $this->field = \core_customfield\field_controller::create(0, (object)['type' => $type], $category); + } else { + throw new \moodle_exception('fieldnotfound', 'core_customfield'); + } + } + return $this->field; + } + + /** + * Check if current user has access to this form, otherwise throw exception + * + * Sometimes permission check may depend on the action and/or id of the entity. + * If necessary, form data is available in $this->_ajaxformdata + */ + protected function check_access_for_dynamic_submission(): void { + $field = $this->get_field(); + $handler = $field->get_handler(); + if (!$handler->can_configure()) { + print_error('nopermissionconfigure', 'core_customfield'); + } + } + + /** + * Load in existing data as form defaults + * + * Can be overridden to retrieve existing values from db by entity id and also + * to preprocess editor and filemanager elements + * + * Example: + * $this->set_data(get_entity($this->_ajaxformdata['id'])); + */ + public function set_data_for_dynamic_submission(): void { + $this->set_data(api::prepare_field_for_config_form($this->get_field())); + } + + /** + * Process the form submission + * + * This method can return scalar values or arrays that can be json-encoded, they will be passed to the caller JS. + * + * @return mixed + */ + public function process_dynamic_submission() { + $data = $this->get_data(); + $field = $this->get_field(); + $handler = $field->get_handler(); + $handler->save_field_configuration($field, $data); + return null; + } + + /** + * Form context + * @return \context + */ + protected function get_context_for_dynamic_submission(): \context { + return $this->get_field()->get_handler()->get_configuration_context(); + } + + /** + * Page url + * @return \moodle_url + */ + protected function get_page_url_for_dynamic_submission(): \moodle_url { + $field = $this->get_field(); + if ($field->get('id')) { + $params = ['action' => 'editfield', 'id' => $field->get('id')]; + } else { + $params = ['action' => 'addfield', 'categoryid' => $field->get('categoryid'), 'type' => $field->get('type')]; + } + return new \moodle_url($field->get_handler()->get_configuration_url(), $params); + } +} diff --git a/classes/field_controller.php b/classes/field_controller.php index 85034d1..5dea8aa 100644 --- a/classes/field_controller.php +++ b/classes/field_controller.php @@ -15,123 +15,269 @@ // along with Moodle. If not, see . /** - * Customfield multiselect Type + * Field controller abstract class * - * @package customfield_multiselect - * @copyright 2020 CALL Learning 2020 - Laurent David - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package core_customfield + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -namespace customfield_multiselect; +namespace core_customfield; defined('MOODLE_INTERNAL') || die; /** - * Class field + * Base class for custom fields controllers + * + * This class is a wrapper around the persistent field class that allows to define the field + * configuration * - * @package customfield_multiselect - * @copyright 2018 David Matamoros + * Custom field plugins must define a class + * \{pluginname}\field_controller extends \core_customfield\field_controller + * + * @package core_customfield + * @copyright 2018 Toni Barbera * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class field_controller extends \core_customfield\field_controller { +abstract class field_controller { + /** - * Customfield type + * Field persistent class + * + * @var field */ - const TYPE = 'multiselect'; + protected $field; /** - * Form defintion for multiselect + * Category of the field. * - * @param \MoodleQuickForm $mform - * @throws \coding_exception + * @var category_controller */ - public function config_form_definition(\MoodleQuickForm $mform) { - $mform->addElement('header', 'header_specificsettings', get_string('specificsettings', 'customfield_multiselect')); - $mform->setExpanded('header_specificsettings', true); + protected $category; - $mform->addElement('textarea', 'configdata[options]', get_string('menuoptions', 'customfield_multiselect')); - $mform->setType('configdata[options]', PARAM_TEXT); - - $mform->addElement('text', 'configdata[defaultvalue]', get_string('defaultvalue', 'customfield_multiselect')); - $mform->setType('configdata[defaultvalue]', PARAM_TEXT); + /** + * Constructor. + * + * @param int $id + * @param \stdClass|null $record + */ + public function __construct(int $id = 0, \stdClass $record = null) { + $this->field = new field($id, $record); } /** - * Returns the options available as an array. + * Creates an instance of field_controller * - * @return array + * Parameters $id, $record and $category can complement each other but not conflict. + * If $id is not specified, categoryid must be present either in $record or in $category. + * If $id is not specified, type must be present in $record + * + * No DB queries are performed if both $record and $category are specified. + * + * @param int $id + * @param \stdClass|null $record + * @param category_controller|null $category + * @return field_controller will return the instance of the class from the customfield element plugin + * @throws \coding_exception + * @throws \moodle_exception */ - public function get_options(): array { - if ($this->get_configdata_property('options')) { - $options = preg_split("/\s*\n\s*/", trim($this->get_configdata_property('options'))); - } else { - $options = array(); + public static function create(int $id, \stdClass $record = null, category_controller $category = null) : field_controller { + global $DB; + if ($id && $record) { + // This warning really should be in persistent as well. + debugging('Too many parameters, either id need to be specified or a record, but not both.', + DEBUG_DEVELOPER); + } + if ($id) { + if (!$record = $DB->get_record(field::TABLE, array('id' => $id), '*', IGNORE_MISSING)) { + throw new \moodle_exception('fieldnotfound', 'core_customfield'); + } + } + + if (empty($record->categoryid)) { + if (!$category) { + throw new \coding_exception('Not enough parameters to initialise field_controller - unknown category'); + } else { + $record->categoryid = $category->get('id'); + } + } + if (empty($record->type)) { + throw new \coding_exception('Not enough parameters to initialise field_controller - unknown field type'); + } + + $type = $record->type; + if (!$category) { + $category = category_controller::create($record->categoryid); + } + if ($category->get('id') != $record->categoryid) { + throw new \coding_exception('Category of the field does not match category from the parameter'); } - return $options; + + $customfieldtype = "\\customfield_{$type}\\field_controller"; + if (!class_exists($customfieldtype) || !is_subclass_of($customfieldtype, self::class)) { + throw new \moodle_exception('errorfieldtypenotfound', 'core_customfield', '', s($type)); + } + $fieldcontroller = new $customfieldtype(0, $record); + $fieldcontroller->category = $category; + $category->add_field($fieldcontroller); + return $fieldcontroller; } /** - * Returns the options available as an array. - * Method compatible with select type of customfield. + * Perform pre-processing of field values, for example those that originate from an external source (e.g. upload course tool) * - * @param \core_customfield\field_controller $field - * @return array + * Override in plugin classes as necessary + * + * @param string $value + * @return mixed */ - public static function get_options_array(\core_customfield\field_controller $field): array { - return $field->get_options(); + public function parse_value(string $value) { + return $value; } /** - * Validate the data from the config form. - * Sub classes must reimplement it. + * Validate the data on the field configuration form + * + * Plugins can override it * * @param array $data from the add/edit profile field form * @param array $files * @return array associative array of error messages - * @throws \coding_exception */ - public function config_form_validation(array $data, $files = array()): array { - $options = preg_split("/\s*\n\s*/", trim($data['configdata']['options'])); - $errors = []; - if (!$options || count($options) < 2) { - $errors['configdata[options]'] = get_string('errornotenoughoptions', 'customfield_multiselect'); - } else if (!empty($data['configdata']['defaultvalue'])) { - $defaultvalue = $data['configdata']['defaultvalue']; - foreach (explode(',', $defaultvalue) as $val) { - $defaultkey = array_search($val, $options); - if ($defaultkey === false) { - $errors['configdata[defaultvalue]'] = get_string('errordefaultvaluenotinlist', - 'customfield_multiselect', $val); - break; - } - } - } - return $errors; + public function config_form_validation(array $data, $files = array()) : array { + return array(); + } + + + /** + * Persistent getter parser. + * + * @param string $property + * @return mixed + */ + final public function get(string $property) { + return $this->field->get($property); } /** - * Separator between different option when parsing. + * Persistent setter parser. + * + * @param string $property + * @param mixed $value + * @return field */ - const PARSE_SEPARATOR = '|'; + final public function set($property, $value) { + return $this->field->set($property, $value); + } + /** - * Locate the values set in the list (comma separated list), and return the corresponding - * indexed list. + * Delete a field and all associated data * - * @param string $value - * @return $value + * Plugins may override it if it is necessary to delete related data (such as files) + * + * Not that the delete() method from data_controller is not called here. + * + * @return bool */ - public function parse_value(string $value) { - $options = $this->get_options(); - $values = array_map(function($val) { - return trim(strtolower($val)); - }, explode(self::PARSE_SEPARATOR, $value)); - $indexvalues = []; - foreach ($options as $index => $value) { - if (in_array(trim(strtolower($value)), $values)) { - $indexvalues[] = $index; - } + public function delete() : bool { + global $DB; + $DB->delete_records('customfield_data', ['fieldid' => $this->get('id')]); + return $this->field->delete(); + } + + /** + * Save or update the persistent class to database. + * + * @return void + */ + public function save() { + $this->field->save(); + } + + /** + * Persistent to_record parser. + * + * @return \stdClass + */ + final public function to_record() { + return $this->field->to_record(); + } + + /** + * Get the category associated with this field + * + * @return category_controller + */ + public final function get_category() : category_controller { + return $this->category; + } + + /** + * Get configdata property. + * + * @param string $property name of the property + * @return mixed + */ + public function get_configdata_property(string $property) { + $configdata = $this->field->get('configdata'); + if (!isset($configdata[$property])) { + return null; } - sort($indexvalues); - return implode(',', $indexvalues); + return $configdata[$property]; + } + + /** + * Returns a handler for this field + * + * @return handler + */ + public final function get_handler() : handler { + return $this->get_category()->get_handler(); + } + + /** + * Prepare the field data to set in the configuration form + * + * Plugin can override if some preprocessing required for editor or filemanager fields + * + * @param \stdClass $formdata + */ + public function prepare_for_config_form(\stdClass $formdata) { + } + + /** + * Add specific settings to the field configuration form, for example "default value" + * + * @param \MoodleQuickForm $mform + */ + public abstract function config_form_definition(\MoodleQuickForm $mform); + + /** + * Returns the field name formatted according to configuration context. + * + * @return string + */ + public function get_formatted_name() : string { + $context = $this->get_handler()->get_configuration_context(); + return format_string($this->get('name'), true, ['context' => $context]); + } + + /** + * Does this custom field type support being used as part of the block_myoverview + * custom field grouping? + * @return bool + */ + public function supports_course_grouping(): bool { + return false; + } + + /** + * If this field supports course filtering, then this function needs overriding to + * return the formatted values for this. + * @param array $values the used values that need grouping + * @return array + */ + public function course_grouping_format_values($values): array { + return []; } } diff --git a/classes/handler.php b/classes/handler.php new file mode 100644 index 0000000..650be9d --- /dev/null +++ b/classes/handler.php @@ -0,0 +1,789 @@ +. + +/** + * The abstract custom fields handler + * + * @package core_customfield + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield; + +use core_customfield\output\field_data; +use stdClass; + +defined('MOODLE_INTERNAL') || die; + +/** + * Base class for custom fields handlers + * + * This handler provides callbacks for field configuration form and also allows to add the fields to the instance editing form + * + * Every plugin that wants to use custom fields must define a handler class: + * \customfield\_handler extends \core_customfield\handler + * + * To initiate a class use an appropriate static method: + * - ::create - to create an instance of a known handler + * - \core_customfield\handler::get_handler - to create an instance of a handler for given component/area/itemid + * + * Also handler is automatically created when the following methods are called: + * - \core_customfield\api::get_field($fieldid) + * - \core_customfield\api::get_category($categoryid) + * + * @package core_customfield + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class handler { + + /** + * The component this handler handles + * + * @var string $component + */ + private $component; + + /** + * The area within the component + * + * @var string $area + */ + private $area; + + /** + * The id of the item within the area and component + + * @var int $itemid + */ + private $itemid; + + /** + * @var category_controller[] + */ + protected $categories = null; + + /** + * Handler constructor. + * + * @param int $itemid + */ + protected final function __construct(int $itemid = 0) { + if (!preg_match('|^(\w+_[\w_]+)\\\\customfield\\\\([\w_]+)_handler$|', static::class, $matches)) { + throw new \coding_exception('Handler class name must have format: \\customfield\\_handler'); + } + $this->component = $matches[1]; + $this->area = $matches[2]; + $this->itemid = $itemid; + } + + /** + * Returns an instance of the handler + * + * Some areas may choose to use singleton/caching here + * + * @param int $itemid + * @return handler + */ + public static function create(int $itemid = 0) : handler { + return new static($itemid); + } + + /** + * Returns an instance of handler by component/area/itemid + * + * @param string $component component name of full frankenstyle plugin name + * @param string $area name of the area (each component/plugin may define handlers for multiple areas) + * @param int $itemid item id if the area uses them (usually not used) + * @return handler + */ + public static function get_handler(string $component, string $area, int $itemid = 0) : handler { + $classname = $component . '\\customfield\\' . $area . '_handler'; + if (class_exists($classname) && is_subclass_of($classname, self::class)) { + return $classname::create($itemid); + } + $a = ['component' => s($component), 'area' => s($area)]; + throw new \moodle_exception('unknownhandler', 'core_customfield', (object)$a); + } + + /** + * Get component + * + * @return string + */ + public function get_component() : string { + return $this->component; + } + + /** + * Get area + * + * @return string + */ + public function get_area() : string { + return $this->area; + } + + /** + * Context that should be used for new categories created by this handler + * + * @return \context + */ + abstract public function get_configuration_context() : \context; + + /** + * URL for configuration of the fields on this handler. + * + * @return \moodle_url + */ + abstract public function get_configuration_url() : \moodle_url; + + /** + * Context that should be used for data stored for the given record + * + * @param int $instanceid id of the instance or 0 if the instance is being created + * @return \context + */ + abstract public function get_instance_context(int $instanceid = 0) : \context; + + /** + * Get itemid + * + * @return int|null + */ + public function get_itemid() : int { + return $this->itemid; + } + + /** + * Uses categories + * + * @return bool + */ + public function uses_categories() : bool { + return true; + } + + /** + * Generates a name for the new category + * + * @param int $suffix + * @return string + */ + protected function generate_category_name($suffix = 0) : string { + if ($suffix) { + return get_string('otherfieldsn', 'core_customfield', $suffix); + } else { + return get_string('otherfields', 'core_customfield'); + } + } + + /** + * Creates a new category and inserts it to the database + * + * @param string $name name of the category, null to generate automatically + * @return int id of the new category + */ + public function create_category(string $name = null) : int { + global $DB; + $params = ['component' => $this->get_component(), 'area' => $this->get_area(), 'itemid' => $this->get_itemid()]; + + if (empty($name)) { + for ($suffix = 0; $suffix < 100; $suffix++) { + $name = $this->generate_category_name($suffix); + if (!$DB->record_exists(category::TABLE, $params + ['name' => $name])) { + break; + } + } + } + + $category = category_controller::create(0, (object)['name' => $name], $this); + api::save_category($category); + $this->clear_configuration_cache(); + return $category->get('id'); + } + + /** + * Validate that the given category belongs to this handler + * + * @param category_controller $category + * @return category_controller + * @throws \moodle_exception + */ + protected function validate_category(category_controller $category) : category_controller { + $categories = $this->get_categories_with_fields(); + if (!array_key_exists($category->get('id'), $categories)) { + throw new \moodle_exception('categorynotfound', 'core_customfield'); + } + return $categories[$category->get('id')]; + } + + /** + * Validate that the given field belongs to this handler + * + * @param field_controller $field + * @return field_controller + * @throws \moodle_exception + */ + protected function validate_field(field_controller $field) : field_controller { + if (!array_key_exists($field->get('categoryid'), $this->get_categories_with_fields())) { + throw new \moodle_exception('fieldnotfound', 'core_customfield'); + } + $category = $this->get_categories_with_fields()[$field->get('categoryid')]; + if (!array_key_exists($field->get('id'), $category->get_fields())) { + throw new \moodle_exception('fieldnotfound', 'core_customfield'); + } + return $category->get_fields()[$field->get('id')]; + } + + /** + * Change name for a field category + * + * @param category_controller $category + * @param string $name + */ + public function rename_category(category_controller $category, string $name) { + $this->validate_category($category); + $category->set('name', $name); + api::save_category($category); + $this->clear_configuration_cache(); + } + + /** + * Change sort order of the categories + * + * @param category_controller $category category that needs to be moved + * @param int $beforeid id of the category this category needs to be moved before, 0 to move to the end + */ + public function move_category(category_controller $category, int $beforeid = 0) { + $category = $this->validate_category($category); + api::move_category($category, $beforeid); + $this->clear_configuration_cache(); + } + + /** + * Permanently delete category, all fields in it and all associated data + * + * @param category_controller $category + * @return bool + */ + public function delete_category(category_controller $category) : bool { + $category = $this->validate_category($category); + $result = api::delete_category($category); + $this->clear_configuration_cache(); + return $result; + } + + /** + * Deletes all data and all fields and categories defined in this handler + */ + public function delete_all() { + $categories = $this->get_categories_with_fields(); + foreach ($categories as $category) { + api::delete_category($category); + } + $this->clear_configuration_cache(); + } + + /** + * Permanently delete a custom field configuration and all associated data + * + * @param field_controller $field + * @return bool + */ + public function delete_field_configuration(field_controller $field) : bool { + $field = $this->validate_field($field); + $result = api::delete_field_configuration($field); + $this->clear_configuration_cache(); + return $result; + } + + /** + * Change fields sort order, move field to another category + * + * @param field_controller $field field that needs to be moved + * @param int $categoryid category that needs to be moved + * @param int $beforeid id of the category this category needs to be moved before, 0 to move to the end + */ + public function move_field(field_controller $field, int $categoryid, int $beforeid = 0) { + $field = $this->validate_field($field); + api::move_field($field, $categoryid, $beforeid); + $this->clear_configuration_cache(); + } + + /** + * The current user can configure custom fields on this component. + * + * @return bool + */ + abstract public function can_configure() : bool; + + /** + * The current user can edit given custom fields on the given instance + * + * Called to filter list of fields displayed on the instance edit form + * + * Capability to edit/create instance is checked separately + * + * @param field_controller $field + * @param int $instanceid id of the instance or 0 if the instance is being created + * @return bool + */ + abstract public function can_edit(field_controller $field, int $instanceid = 0) : bool; + + /** + * The current user can view the value of the custom field for a given custom field and instance + * + * Called to filter list of fields returned by methods get_instance_data(), get_instances_data(), + * export_instance_data(), export_instance_data_object() + * + * Access to the instance itself is checked by handler before calling these methods + * + * @param field_controller $field + * @param int $instanceid + * @return bool + */ + abstract public function can_view(field_controller $field, int $instanceid) : bool; + + /** + * Returns the custom field values for an individual instance + * + * The caller must check access to the instance itself before invoking this method + * + * The result is an array of data_controller objects + * + * @param int $instanceid + * @param bool $returnall return data for all fields (by default only visible fields) + * @return data_controller[] array of data_controller objects indexed by fieldid. All fields are present, + * some data_controller objects may have 'id', some not + * In the last case data_controller::get_value() and export_value() functions will return default values. + */ + public function get_instance_data(int $instanceid, bool $returnall = false) : array { + $fields = $returnall ? $this->get_fields() : $this->get_visible_fields($instanceid); + return api::get_instance_fields_data($fields, $instanceid); + } + + /** + * Returns the custom fields values for multiple instances + * + * The caller must check access to the instance itself before invoking this method + * + * The result is an array of data_controller objects + * + * @param int[] $instanceids + * @param bool $returnall return data for all fields (by default only visible fields) + * @return data_controller[][] 2-dimension array, first index is instanceid, second index is fieldid. + * All instanceids and all fieldids are present, some data_controller objects may have 'id', some not. + * In the last case data_controller::get_value() and export_value() functions will return default values. + */ + public function get_instances_data(array $instanceids, bool $returnall = false) : array { + $result = api::get_instances_fields_data($this->get_fields(), $instanceids); + + if (!$returnall) { + // Filter only by visible fields (list of visible fields may be different for each instance). + $handler = $this; + foreach ($instanceids as $instanceid) { + $result[$instanceid] = array_filter($result[$instanceid], function(data_controller $d) use ($handler) { + return $handler->can_view($d->get_field(), $d->get('instanceid')); + }); + } + } + return $result; + } + + /** + * Returns the custom field values for an individual instance ready to be displayed + * + * The caller must check access to the instance itself before invoking this method + * + * The result is an array of \core_customfield\output\field_data objects + * + * @param int $instanceid + * @param bool $returnall + * @return \core_customfield\output\field_data[] + */ + public function export_instance_data(int $instanceid, bool $returnall = false) : array { + return array_map(function($d) { + return new field_data($d); + }, $this->get_instance_data($instanceid, $returnall)); + } + + /** + * Returns the custom field values for an individual instance ready to be displayed + * + * The caller must check access to the instance itself before invoking this method + * + * The result is a class where properties are fields short names and the values their export values for this instance + * + * @param int $instanceid + * @param bool $returnall + * @return stdClass + */ + public function export_instance_data_object(int $instanceid, bool $returnall = false) : stdClass { + $rv = new stdClass(); + foreach ($this->export_instance_data($instanceid, $returnall) as $d) { + $rv->{$d->get_shortname()} = $d->get_value(); + } + return $rv; + } + + /** + * Display visible custom fields. + * This is a sample implementation that can be overridden in each handler. + * + * @param data_controller[] $fieldsdata + * @return string + */ + public function display_custom_fields_data(array $fieldsdata) : string { + global $PAGE; + $output = $PAGE->get_renderer('core_customfield'); + $content = ''; + foreach ($fieldsdata as $data) { + $fd = new field_data($data); + $content .= $output->render($fd); + } + + return $content; + } + + /** + * Returns array of categories, each of them contains a list of fields definitions. + * + * @return category_controller[] + */ + public function get_categories_with_fields() : array { + if ($this->categories === null) { + $this->categories = api::get_categories_with_fields($this->get_component(), $this->get_area(), $this->get_itemid()); + } + $handler = $this; + array_walk($this->categories, function(category_controller $c) use ($handler) { + $c->set_handler($handler); + }); + return $this->categories; + } + + /** + * Clears a list of categories with corresponding fields definitions. + */ + protected function clear_configuration_cache() { + $this->categories = null; + } + + /** + * Checks if current user can backup a given field + * + * Capability to backup the instance does not need to be checked here + * + * @param field_controller $field + * @param int $instanceid + * @return bool + */ + protected function can_backup(field_controller $field, int $instanceid) : bool { + return $this->can_view($field, $instanceid) || $this->can_edit($field, $instanceid); + } + + /** + * Get raw data associated with all fields current user can view or edit + * + * @param int $instanceid + * @return array + */ + public function get_instance_data_for_backup(int $instanceid) : array { + $finalfields = []; + $data = $this->get_instance_data($instanceid, true); + foreach ($data as $d) { + if ($d->get('id') && $this->can_backup($d->get_field(), $instanceid)) { + $finalfields[] = [ + 'id' => $d->get('id'), + 'shortname' => $d->get_field()->get('shortname'), + 'type' => $d->get_field()->get('type'), + 'value' => $d->get_value(), + 'valueformat' => $d->get('valueformat')]; + } + } + return $finalfields; + } + + /** + * Form data definition callback. + * + * This method is called from moodleform::definition_after_data and allows to tweak + * mform with some data coming directly from the field plugin data controller. + * + * @param \MoodleQuickForm $mform + * @param int $instanceid + */ + public function instance_form_definition_after_data(\MoodleQuickForm $mform, int $instanceid = 0) { + $editablefields = $this->get_editable_fields($instanceid); + $fields = api::get_instance_fields_data($editablefields, $instanceid); + + foreach ($fields as $formfield) { + $formfield->instance_form_definition_after_data($mform); + } + } + + /** + * Prepares the custom fields data related to the instance to pass to mform->set_data() + * + * Example: + * $instance = $DB->get_record(...); + * // .... prepare editor, filemanager, add tags, etc. + * $handler->instance_form_before_set_data($instance); + * $form->set_data($instance); + * + * @param stdClass $instance the instance that has custom fields, if 'id' attribute is present the custom + * fields for this instance will be added, otherwise the default values will be added. + */ + public function instance_form_before_set_data(stdClass $instance) { + $instanceid = !empty($instance->id) ? $instance->id : 0; + $fields = api::get_instance_fields_data($this->get_editable_fields($instanceid), $instanceid); + + foreach ($fields as $formfield) { + $formfield->instance_form_before_set_data($instance); + } + } + + /** + * Saves the given data for custom fields, must be called after the instance is saved and id is present + * + * Example: + * if ($data = $form->get_data()) { + * // ... save main instance, set $data->id if instance was created. + * $handler->instance_form_save($data); + * redirect(...); + * } + * + * @param stdClass $instance data received from a form + * @param bool $isnewinstance if this is call is made during instance creation + */ + public function instance_form_save(stdClass $instance, bool $isnewinstance = false) { + if (empty($instance->id)) { + throw new \coding_exception('Caller must ensure that id is already set in data before calling this method'); + } + if (!preg_grep('/^customfield_/', array_keys((array)$instance))) { + // For performance. + return; + } + $editablefields = $this->get_editable_fields($isnewinstance ? 0 : $instance->id); + $fields = api::get_instance_fields_data($editablefields, $instance->id); + foreach ($fields as $data) { + if (!$data->get('id')) { + $data->set('contextid', $this->get_instance_context($instance->id)->id); + } + $data->instance_form_save($instance); + } + } + + /** + * Validates the given data for custom fields, used in moodleform validation() function + * + * Example: + * public function validation($data, $files) { + * $errors = []; + * // .... check other fields. + * $errors = array_merge($errors, $handler->instance_form_validation($data, $files)); + * return $errors; + * } + * + * @param array $data + * @param array $files + * @return array validation errors + */ + public function instance_form_validation(array $data, array $files) { + $instanceid = empty($data['id']) ? 0 : $data['id']; + $editablefields = $this->get_editable_fields($instanceid); + $fields = api::get_instance_fields_data($editablefields, $instanceid); + $errors = []; + foreach ($fields as $formfield) { + $errors += $formfield->instance_form_validation($data, $files); + } + return $errors; + } + + /** + * Adds custom fields to instance editing form + * + * Example: + * public function definition() { + * // ... normal instance definition, including hidden 'id' field. + * $handler->instance_form_definition($this->_form, $instanceid); + * $this->add_action_buttons(); + * } + * + * @param \MoodleQuickForm $mform + * @param int $instanceid id of the instance, can be null when instance is being created + * @param string $headerlangidentifier If specified, a lang string will be used for field category headings + * @param string $headerlangcomponent + */ + public function instance_form_definition(\MoodleQuickForm $mform, int $instanceid = 0, + ?string $headerlangidentifier = null, ?string $headerlangcomponent = null) { + + $editablefields = $this->get_editable_fields($instanceid); + $fieldswithdata = api::get_instance_fields_data($editablefields, $instanceid); + $lastcategoryid = null; + foreach ($fieldswithdata as $data) { + $categoryid = $data->get_field()->get_category()->get('id'); + if ($categoryid != $lastcategoryid) { + $categoryname = format_string($data->get_field()->get_category()->get('name')); + + // Load category header lang string if specified. + if (!empty($headerlangidentifier)) { + $categoryname = get_string($headerlangidentifier, $headerlangcomponent, $categoryname); + } + + $mform->addElement('header', 'category_' . $categoryid, $categoryname); + $lastcategoryid = $categoryid; + } + $data->instance_form_definition($mform); + $field = $data->get_field()->to_record(); + if (strlen($field->description)) { + // Add field description. + $context = $this->get_configuration_context(); + $value = file_rewrite_pluginfile_urls($field->description, 'pluginfile.php', + $context->id, 'core_customfield', 'description', $field->id); + $value = format_text($value, $field->descriptionformat, ['context' => $context]); + $mform->addElement('static', 'customfield_' . $field->shortname . '_static', '', $value); + } + } + } + + /** + * Get field types array + * + * @return array + */ + public function get_available_field_types() :array { + return api::get_available_field_types(); + } + + /** + * Options for processing embedded files in the field description. + * + * Handlers may want to extend it to disable files support and/or specify 'noclean'=>true + * Context is not necessary here + * + * @return array + */ + public function get_description_text_options() : array { + global $CFG; + require_once($CFG->libdir.'/formslib.php'); + return [ + 'maxfiles' => EDITOR_UNLIMITED_FILES, + 'maxbytes' => $CFG->maxbytes, + 'context' => $this->get_configuration_context() + ]; + } + + /** + * Save the field configuration with the data from the form + * + * @param field_controller $field + * @param stdClass $data data from the form + */ + public function save_field_configuration(field_controller $field, stdClass $data) { + if ($field->get('id')) { + $field = $this->validate_field($field); + } else { + $this->validate_category($field->get_category()); + } + api::save_field_configuration($field, $data); + $this->clear_configuration_cache(); + } + + /** + * Creates or updates custom field data for a instanceid from backup data. + * + * The handlers have to override it if they support backup + * + * @param \restore_task $task + * @param array $data + */ + public function restore_instance_data_from_backup(\restore_task $task, array $data) { + throw new \coding_exception('Must be implemented in the handler'); + } + + /** + * Returns list of fields defined for this instance as an array (not groupped by categories) + * + * Fields are sorted in the same order they would appear on the instance edit form + * + * Note that this function returns all fields in all categories regardless of whether the current user + * can view or edit data associated with them + * + * @return field_controller[] + */ + public function get_fields() : array { + $categories = $this->get_categories_with_fields(); + $fields = []; + foreach ($categories as $category) { + foreach ($category->get_fields() as $field) { + $fields[$field->get('id')] = $field; + } + } + return $fields; + } + + /** + * Get visible fields + * + * @param int $instanceid + * @return field_controller[] + */ + protected function get_visible_fields(int $instanceid) : array { + $handler = $this; + return array_filter($this->get_fields(), + function($field) use($handler, $instanceid) { + return $handler->can_view($field, $instanceid); + } + ); + } + + /** + * Get editable fields + * + * @param int $instanceid + * @return field_controller[] + */ + public function get_editable_fields(int $instanceid) : array { + $handler = $this; + return array_filter($this->get_fields(), + function($field) use($handler, $instanceid) { + return $handler->can_edit($field, $instanceid); + } + ); + } + + /** + * Allows to add custom controls to the field configuration form that will be saved in configdata + * + * @param \MoodleQuickForm $mform + */ + public function config_form_definition(\MoodleQuickForm $mform) { + } + + /** + * Deletes all data related to all fields of an instance. + * + * @param int $instanceid + */ + public function delete_instance(int $instanceid) { + $fielddata = api::get_instance_fields_data($this->get_fields(), $instanceid, false); + foreach ($fielddata as $data) { + $data->delete(); + } + } +} diff --git a/classes/output/field_data.php b/classes/output/field_data.php new file mode 100644 index 0000000..76f261d --- /dev/null +++ b/classes/output/field_data.php @@ -0,0 +1,114 @@ +. + +/** + * core_customfield field value renderable. + * + * @package core_customfield + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield\output; + +use core_customfield\data_controller; + +defined('MOODLE_INTERNAL') || die; + +/** + * core_customfield field value renderable class. + * + * @package core_customfield + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class field_data implements \renderable, \templatable { + + /** @var \core_customfield\data_controller */ + protected $data; + + /** + * Renderable constructor. + * + * @param \core_customfield\data_controller $data + */ + public function __construct(\core_customfield\data_controller $data) { + $this->data = $data; + } + + /** + * Returns the data value formatted for the output + * + * @return mixed|null + */ + public function get_value() { + return $this->data->export_value(); + } + + /** + * Returns the field type (checkbox, date, text, ...) + * + * @return string + */ + public function get_type() : string { + return $this->data->get_field()->get('type'); + } + + /** + * Returns the field short name + * + * @return string + */ + public function get_shortname() : string { + return $this->data->get_field()->get('shortname'); + } + + /** + * Returns the field name formatted for the output + * + * @return string + */ + public function get_name() : string { + return $this->data->get_field()->get_formatted_name(); + } + + /** + * Returns the data controller used to create this object if additional attributes are needed + * + * @return data_controller + */ + public function get_data_controller() : data_controller { + return $this->data; + } + + /** + * Export data for using as template context. + * + * @param \renderer_base $output + * @return \stdClass + */ + public function export_for_template(\renderer_base $output) { + $value = $this->get_value(); + return (object)[ + 'value' => $value, + 'type' => $this->get_type(), + 'shortname' => $this->get_shortname(), + 'name' => $this->get_name(), + 'hasvalue' => ($value !== null), + 'instanceid' => $this->data->get('instanceid') + ]; + } +} diff --git a/classes/output/management.php b/classes/output/management.php new file mode 100644 index 0000000..5bbec0a --- /dev/null +++ b/classes/output/management.php @@ -0,0 +1,126 @@ +. + +/** + * Customfield component output. + * + * @package core_customfield + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield\output; + +use core_customfield\api; +use core_customfield\handler; +use renderable; +use templatable; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class management + * + * @package core_customfield + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class management implements renderable, templatable { + + /** + * @var handler + */ + protected $handler; + /** + * @var + */ + protected $categoryid; + + /** + * management constructor. + * + * @param \core_customfield\handler $handler + */ + public function __construct(\core_customfield\handler $handler) { + $this->handler = $handler; + } + + /** + * Export for template + * + * @param \renderer_base $output + * @return array|object|\stdClass + */ + public function export_for_template(\renderer_base $output) { + $data = new \stdClass(); + + $fieldtypes = $this->handler->get_available_field_types(); + + $data->component = $this->handler->get_component(); + $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(); + + $categoriesarray = array(); + + foreach ($categories as $category) { + + $categoryarray = array(); + $categoryarray['id'] = $category->get('id'); + $categoryarray['nameeditable'] = $output->render(api::get_category_inplace_editable($category, true)); + $categoryarray['movetitle'] = get_string('movecategory', 'core_customfield', + $category->get_formatted_name()); + + $categoryarray['fields'] = array(); + + foreach ($category->get_fields() as $field) { + + $fieldname = $field->get_formatted_name(); + $fieldarray['type'] = $fieldtypes[$field->get('type')]; + $fieldarray['id'] = $field->get('id'); + $fieldarray['name'] = $fieldname; + $fieldarray['shortname'] = $field->get('shortname'); + $fieldarray['movetitle'] = get_string('movefield', 'core_customfield', $fieldname); + + $categoryarray['fields'][] = $fieldarray; + } + + $menu = new \action_menu(); + $menu->set_menu_trigger(get_string('createnewcustomfield', 'core_customfield')); + + foreach ($fieldtypes as $type => $fieldname) { + $action = new \action_menu_link_secondary(new \moodle_url('#'), null, $fieldname, + ['data-role' => 'addfield', 'data-categoryid' => $category->get('id'), 'data-type' => $type, + 'data-typename' => $fieldname]); + $menu->add($action); + } + $menu->attributes['class'] .= ' float-left mr-1'; + + $categoryarray['addfieldmenu'] = $output->render($menu); + + $categoriesarray[] = $categoryarray; + } + + $data->categories = $categoriesarray; + + if (empty($data->categories)) { + $data->nocategories = get_string('nocategories', 'core_customfield'); + } + + return $data; + } +} diff --git a/classes/output/renderer.php b/classes/output/renderer.php new file mode 100644 index 0000000..2fa455d --- /dev/null +++ b/classes/output/renderer.php @@ -0,0 +1,62 @@ +. + +/** + * Renderer. + * + * @package core_customfield + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield\output; + +defined('MOODLE_INTERNAL') || die(); + +use plugin_renderer_base; + +/** + * Renderer class. + * + * @package core_customfield + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class renderer extends plugin_renderer_base { + + /** + * Render custom field management interface. + * + * @param \core_customfield\output\management $list + * @return string HTML + */ + protected function render_management(\core_customfield\output\management $list) { + $context = $list->export_for_template($this); + + return $this->render_from_template('core_customfield/list', $context); + } + + /** + * Render single custom field value + * + * @param \core_customfield\output\field_data $field + * @return string HTML + */ + protected function render_field_data(\core_customfield\output\field_data $field) { + $context = $field->export_for_template($this); + return $this->render_from_template('core_customfield/field_data', $context); + } +} \ No newline at end of file diff --git a/classes/privacy/customfield_provider.php b/classes/privacy/customfield_provider.php new file mode 100644 index 0000000..8d05a5b --- /dev/null +++ b/classes/privacy/customfield_provider.php @@ -0,0 +1,84 @@ +. + +/** + * Contains interface customfield_provider + * + * @package core_customfield + * @copyright 2018 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_customfield\privacy; + +use core_customfield\data_controller; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Interface customfield_provider, all customfield plugins need to implement it + * + * @package core_customfield + * @copyright 2018 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface customfield_provider extends + \core_privacy\local\request\plugin\subplugin_provider, + + // The customfield plugins do not need to do anything themselves for the shared_userlist. + // This is all handled by the component core_customfield. + \core_privacy\local\request\shared_userlist_provider + { + + /** + * Preprocesses data object that is going to be exported + * + * Minimum implementation: + * writer::with_context($data->get_context())->export_data($subcontext, $exportdata); + * + * @param data_controller $data + * @param \stdClass $exportdata generated object to be exported + * @param array $subcontext subcontext to use when exporting + * @return mixed + */ + public static function export_customfield_data(data_controller $data, \stdClass $exportdata, array $subcontext); + + /** + * Allows plugins to delete everything they store related to the data (usually files) + * + * If plugin does not store any related files or other information, implement as an empty function + * + * @param string $dataidstest select query for data id (note that it may also return data for other field types) + * @param array $params named parameters for the select query + * @param array $contextids list of affected data contexts + * @return mixed + */ + public static function before_delete_data(string $dataidstest, array $params, array $contextids); + + /** + * Allows plugins to delete everything they store related to the field configuration (usually files) + * + * The implementation should not delete data or anything related to the data, since "before_delete_data" is + * invoked separately. + * + * If plugin does not store any related files or other information, implement as an empty function + * + * @param string $fieldidstest select query for field id (note that it may also return fields of other types) + * @param array $params named parameters for the select query + * @param int[] $contextids list of affected configuration contexts + */ + public static function before_delete_fields(string $fieldidstest, array $params, array $contextids); +} diff --git a/classes/privacy/provider.php b/classes/privacy/provider.php index 36626d3..cf7d2fe 100644 --- a/classes/privacy/provider.php +++ b/classes/privacy/provider.php @@ -13,72 +13,480 @@ // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . + /** - * Privacy provider multiselect Type + * Customfield component provider class * - * @package customfield_multiselect - * @copyright 2020 CALL Learning 2020 - Laurent David - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package core_customfield + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -namespace customfield_multiselect\privacy; -use core_customfield\data_controller; -use core_customfield\privacy\customfield_provider; -use core_privacy\local\request\writer; +namespace core_customfield\privacy; defined('MOODLE_INTERNAL') || die(); +use core_customfield\data_controller; +use core_customfield\handler; +use core_privacy\local\metadata\collection; +use core_privacy\local\request\approved_contextlist; +use core_privacy\local\request\contextlist; +use core_privacy\local\request\writer; +use core_privacy\manager; +use Horde\Socket\Client\Exception; + /** - * Privacy Subsystem for customfield_multiselect implementing null_provider. + * Class provider + * + * Customfields API does not directly store userid and does not perform any export or delete functionality by itself + * + * However this class defines several functions that can be utilized by components that use customfields API to + * export/delete user data. * - * @package customfield_multiselect - * @copyright 2020 CALL Learning 2020 - Laurent David - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package core_customfield + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class provider implements \core_privacy\local\metadata\null_provider, customfield_provider { +class provider implements + // Customfield store data. + \core_privacy\local\metadata\provider, + + // The customfield subsystem stores data on behalf of other components. + \core_privacy\local\request\subsystem\plugin_provider, + \core_privacy\local\request\shared_userlist_provider { /** - * Get the language string identifier with the component's language - * file to explain why this plugin stores no data. + * Return the fields which contain personal data. * - * @return string + * @param collection $collection a reference to the collection to use to store the metadata. + * @return collection the updated collection of metadata items. */ - public static function get_reason() : string { - return 'privacy:metadata'; + public static function get_metadata(collection $collection) : collection { + $collection->add_database_table( + 'customfield_data', + [ + 'fieldid' => 'privacy:metadata:customfield_data:fieldid', + 'instanceid' => 'privacy:metadata:customfield_data:instanceid', + 'intvalue' => 'privacy:metadata:customfield_data:intvalue', + 'decvalue' => 'privacy:metadata:customfield_data:decvalue', + 'shortcharvalue' => 'privacy:metadata:customfield_data:shortcharvalue', + 'charvalue' => 'privacy:metadata:customfield_data:charvalue', + 'value' => 'privacy:metadata:customfield_data:value', + 'valueformat' => 'privacy:metadata:customfield_data:valueformat', + 'timecreated' => 'privacy:metadata:customfield_data:timecreated', + 'timemodified' => 'privacy:metadata:customfield_data:timemodified', + 'contextid' => 'privacy:metadata:customfield_data:contextid', + ], + 'privacy:metadata:customfield_data' + ); + + // Link to subplugins. + $collection->add_plugintype_link('customfield', [], 'privacy:metadata:customfieldpluginsummary'); + + $collection->link_subsystem('core_files', 'privacy:metadata:filepurpose'); + + return $collection; } /** - * Preprocesses data object that is going to be exported + * Returns contexts that have customfields data * - * @param data_controller $data - * @param \stdClass $exportdata - * @param array $subcontext + * To be used in implementations of core_user_data_provider::get_contexts_for_userid + * Caller needs to transfer the $userid to the select subqueries for + * customfield_category->itemid and/or customfield_data->instanceid + * + * @param string $component + * @param string $area + * @param string $itemidstest subquery for selecting customfield_category->itemid + * @param string $instanceidstest subquery for selecting customfield_data->instanceid + * @param array $params array of named parameters + * @return contextlist */ - public static function export_customfield_data(data_controller $data, \stdClass $exportdata, array $subcontext) { - $context = $data->get_context(); - $exportdata->value = $data->export_value(); - writer::with_context($context) - ->export_data($subcontext, $exportdata); + public static function get_customfields_data_contexts(string $component, string $area, + string $itemidstest = 'IS NOT NULL', string $instanceidstest = 'IS NOT NULL', array $params = []) : contextlist { + + $sql = "SELECT d.contextid FROM {customfield_category} c + JOIN {customfield_field} f ON f.categoryid = c.id + JOIN {customfield_data} d ON d.fieldid = f.id AND d.instanceid $instanceidstest + WHERE c.component = :cfcomponent AND c.area = :cfarea AND c.itemid $itemidstest"; + + $contextlist = new contextlist(); + $contextlist->add_from_sql($sql, self::get_params($component, $area, $params)); + + return $contextlist; } /** - * Allows plugins to delete everything they store related to the data (usually files) + * Returns contexts that have customfields configuration (categories and fields) * - * @param string $dataidstest + * To be used in implementations of core_user_data_provider::get_contexts_for_userid in cases when user is + * an owner of the fields configuration + * Caller needs to transfer the $userid to the select subquery for customfield_category->itemid + * + * @param string $component + * @param string $area + * @param string $itemidstest subquery for selecting customfield_category->itemid + * @param array $params array of named parameters for itemidstest subquery + * @return contextlist + */ + public static function get_customfields_configuration_contexts(string $component, string $area, + string $itemidstest = 'IS NOT NULL', array $params = []) : contextlist { + + $sql = "SELECT c.contextid FROM {customfield_category} c + WHERE c.component = :cfcomponent AND c.area = :cfarea AND c.itemid $itemidstest"; + $params['component'] = $component; + $params['area'] = $area; + + $contextlist = new contextlist(); + $contextlist->add_from_sql($sql, self::get_params($component, $area, $params)); + + return $contextlist; + + } + + /** + * Exports customfields data + * + * To be used in implementations of core_user_data_provider::export_user_data + * Caller needs to transfer the $userid to the select subqueries for + * customfield_category->itemid and/or customfield_data->instanceid + * + * @param approved_contextlist $contextlist + * @param string $component + * @param string $area + * @param string $itemidstest subquery for selecting customfield_category->itemid + * @param string $instanceidstest subquery for selecting customfield_data->instanceid + * @param array $params array of named parameters for itemidstest and instanceidstest subqueries + * @param array $subcontext subcontext to use in context_writer::export_data, if null (default) the + * "Custom fields data" will be used; + * the data id will be appended to the subcontext array. + */ + public static function export_customfields_data(approved_contextlist $contextlist, string $component, string $area, + string $itemidstest = 'IS NOT NULL', string $instanceidstest = 'IS NOT NULL', array $params = [], + array $subcontext = null) { + global $DB; + + // This query is very similar to api::get_instances_fields_data() but also works for multiple itemids + // and has a context filter. + list($contextidstest, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED, 'cfctx'); + $sql = "SELECT d.*, f.type AS fieldtype, f.name as fieldname, f.shortname as fieldshortname, c.itemid + FROM {customfield_category} c + JOIN {customfield_field} f ON f.categoryid = c.id + JOIN {customfield_data} d ON d.fieldid = f.id AND d.instanceid $instanceidstest AND d.contextid $contextidstest + WHERE c.component = :cfcomponent AND c.area = :cfarea AND c.itemid $itemidstest + ORDER BY c.itemid, c.sortorder, f.sortorder"; + $params = self::get_params($component, $area, $params) + $contextparams; + $records = $DB->get_recordset_sql($sql, $params); + + if ($subcontext === null) { + $subcontext = [get_string('customfielddata', 'core_customfield')]; + } + + /** @var handler $handler */ + $handler = null; + $fields = null; + foreach ($records as $record) { + if (!$handler || $handler->get_itemid() != $record->itemid) { + $handler = handler::get_handler($component, $area, $record->itemid); + $fields = $handler->get_fields(); + } + $field = (object)['type' => $record->fieldtype, 'shortname' => $record->fieldshortname, 'name' => $record->fieldname]; + unset($record->itemid, $record->fieldtype, $record->fieldshortname, $record->fieldname); + try { + $field = array_key_exists($record->fieldid, $fields) ? $fields[$record->fieldid] : null; + $data = data_controller::create(0, $record, $field); + self::export_customfield_data($data, array_merge($subcontext, [$record->id])); + } catch (Exception $e) { + // We store some data that we can not initialise controller for. We still need to export it. + self::export_customfield_data_unknown($record, $field, array_merge($subcontext, [$record->id])); + } + } + $records->close(); + } + + /** + * Deletes customfields data + * + * To be used in implementations of core_user_data_provider::delete_data_for_user + * Caller needs to transfer the $userid to the select subqueries for + * customfield_category->itemid and/or customfield_data->instanceid + * + * @param approved_contextlist $contextlist + * @param string $component + * @param string $area + * @param string $itemidstest subquery for selecting customfield_category->itemid + * @param string $instanceidstest subquery for selecting customfield_data->instanceid + * @param array $params array of named parameters for itemidstest and instanceidstest subqueries + */ + public static function delete_customfields_data(approved_contextlist $contextlist, string $component, string $area, + string $itemidstest = 'IS NOT NULL', string $instanceidstest = 'IS NOT NULL', array $params = []) { + global $DB; + + list($contextidstest, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED, 'cfctx'); + $sql = "SELECT d.id + FROM {customfield_category} c + JOIN {customfield_field} f ON f.categoryid = c.id + JOIN {customfield_data} d ON d.fieldid = f.id AND d.instanceid $instanceidstest AND d.contextid $contextidstest + WHERE c.component = :cfcomponent AND c.area = :cfarea AND c.itemid $itemidstest"; + $params = self::get_params($component, $area, $params) + $contextparams; + + self::before_delete_data('IN (' . $sql . ') ', $params); + + $DB->execute("DELETE FROM {customfield_data} + WHERE instanceid $instanceidstest + AND contextid $contextidstest + AND fieldid IN (SELECT f.id + FROM {customfield_category} c + JOIN {customfield_field} f ON f.categoryid = c.id + WHERE c.component = :cfcomponent AND c.area = :cfarea AND c.itemid $itemidstest)", $params); + } + + /** + * Deletes customfields configuration (categories and fields) and all relevant data + * + * To be used in implementations of core_user_data_provider::delete_data_for_user in cases when user is + * an owner of the fields configuration and it is considered user information (quite unlikely situtation but we never + * know what customfields API can be used for) + * + * Caller needs to transfer the $userid to the select subquery for customfield_category->itemid + * + * @param approved_contextlist $contextlist + * @param string $component + * @param string $area + * @param string $itemidstest subquery for selecting customfield_category->itemid + * @param array $params array of named parameters for itemidstest subquery + */ + public static function delete_customfields_configuration(approved_contextlist $contextlist, string $component, string $area, + string $itemidstest = 'IS NOT NULL', array $params = []) { + global $DB; + + list($contextidstest, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED, 'cfctx'); + $params = self::get_params($component, $area, $params) + $contextparams; + + $categoriesids = $DB->get_fieldset_sql("SELECT c.id + FROM {customfield_category} c + WHERE c.component = :cfcomponent AND c.area = :cfarea AND c.itemid $itemidstest AND c.contextid $contextidstest", + $params); + + self::delete_categories($contextlist->get_contextids(), $categoriesids); + } + + /** + * Deletes all customfields configuration (categories and fields) and all relevant data for the given category context + * + * To be used in implementations of core_user_data_provider::delete_data_for_all_users_in_context + * + * @param string $component + * @param string $area + * @param \context $context + */ + public static function delete_customfields_configuration_for_context(string $component, string $area, \context $context) { + global $DB; + $categoriesids = $DB->get_fieldset_sql("SELECT c.id + FROM {customfield_category} c + JOIN {context} ctx ON ctx.id = c.contextid AND ctx.path LIKE :ctxpath + WHERE c.component = :cfcomponent AND c.area = :cfarea", + self::get_params($component, $area, ['ctxpath' => $context->path])); + + self::delete_categories([$context->id], $categoriesids); + } + + /** + * Deletes all customfields data for the given context + * + * To be used in implementations of core_user_data_provider::delete_data_for_all_users_in_context + * + * @param string $component + * @param string $area + * @param \context $context + */ + public static function delete_customfields_data_for_context(string $component, string $area, \context $context) { + global $DB; + + $sql = "SELECT d.id + FROM {customfield_category} c + JOIN {customfield_field} f ON f.categoryid = c.id + JOIN {customfield_data} d ON d.fieldid = f.id + JOIN {context} ctx ON ctx.id = d.contextid AND ctx.path LIKE :ctxpath + WHERE c.component = :cfcomponent AND c.area = :cfarea"; + $params = self::get_params($component, $area, ['ctxpath' => $context->path . '%']); + + self::before_delete_data('IN (' . $sql . ') ', $params); + + $DB->execute("DELETE FROM {customfield_data} + WHERE fieldid IN (SELECT f.id + FROM {customfield_category} c + JOIN {customfield_field} f ON f.categoryid = c.id + WHERE c.component = :cfcomponent AND c.area = :cfarea) + AND contextid IN (SELECT id FROM {context} WHERE path LIKE :ctxpath)", + $params); + } + + /** + * Checks that $params is an associative array and adds parameters for component and area + * + * @param string $component + * @param string $area * @param array $params + * @return array + * @throws \coding_exception + */ + protected static function get_params(string $component, string $area, array $params) : array { + if (!empty($params) && (array_keys($params) === range(0, count($params) - 1))) { + // Argument $params is not an associative array. + throw new \coding_exception('Argument $params must be an associative array!'); + } + return $params + ['cfcomponent' => $component, 'cfarea' => $area]; + } + + /** + * Delete custom fields categories configurations, all their fields and data + * * @param array $contextids - * @return mixed|void + * @param array $categoriesids */ - public static function before_delete_data(string $dataidstest, array $params, array $contextids) { + protected static function delete_categories(array $contextids, array $categoriesids) { + global $DB; + + if (!$categoriesids) { + return; + } + + list($categoryidstest, $catparams) = $DB->get_in_or_equal($categoriesids, SQL_PARAMS_NAMED, 'cfcat'); + $datasql = "SELECT d.id FROM {customfield_data} d JOIN {customfield_field} f ON f.id = d.fieldid " . + "WHERE f.categoryid $categoryidstest"; + $fieldsql = "SELECT f.id AS fieldid FROM {customfield_field} f WHERE f.categoryid $categoryidstest"; + + self::before_delete_data("IN ($datasql)", $catparams); + self::before_delete_fields($categoryidstest, $catparams); + + $DB->execute('DELETE FROM {customfield_data} WHERE fieldid IN (' . $fieldsql . ')', $catparams); + $DB->execute("DELETE FROM {customfield_field} WHERE categoryid $categoryidstest", $catparams); + $DB->execute("DELETE FROM {customfield_category} WHERE id $categoryidstest", $catparams); + } /** - * Allows plugins to delete everything they store related to the field configuration (usually files) + * Executes callbacks from the customfield plugins to delete anything related to the data records (usually files) * - * @param string $fieldidstest + * @param string $dataidstest * @param array $params - * @param array $contextids */ - public static function before_delete_fields(string $fieldidstest, array $params, array $contextids) { + protected static function before_delete_data(string $dataidstest, array $params) { + global $DB; + // Find all field types and all contexts for each field type. + $records = $DB->get_recordset_sql("SELECT ff.type, dd.contextid + FROM {customfield_data} dd + JOIN {customfield_field} ff ON ff.id = dd.fieldid + WHERE dd.id $dataidstest + GROUP BY ff.type, dd.contextid", + $params); + + $fieldtypes = []; + foreach ($records as $record) { + $fieldtypes += [$record->type => []]; + $fieldtypes[$record->type][] = $record->contextid; + } + $records->close(); + + // Call plugin callbacks to delete data customfield_provider::before_delete_data(). + foreach ($fieldtypes as $fieldtype => $contextids) { + $classname = manager::get_provider_classname_for_component('customfield_' . $fieldtype); + if (class_exists($classname) && is_subclass_of($classname, customfield_provider::class)) { + component_class_callback($classname, 'before_delete_data', [$dataidstest, $params, $contextids]); + } + } + } + + /** + * Executes callbacks from the plugins to delete anything related to the fields (usually files) + * + * Also deletes description files + * + * @param string $categoryidstest + * @param array $params + */ + protected static function before_delete_fields(string $categoryidstest, array $params) { + global $DB; + // Find all field types and contexts. + $fieldsql = "SELECT f.id AS fieldid FROM {customfield_field} f WHERE f.categoryid $categoryidstest"; + $records = $DB->get_recordset_sql("SELECT f.type, c.contextid + FROM {customfield_field} f + JOIN {customfield_category} c ON c.id = f.categoryid + WHERE c.id $categoryidstest", + $params); + + $contexts = []; + $fieldtypes = []; + foreach ($records as $record) { + $contexts[$record->contextid] = $record->contextid; + $fieldtypes += [$record->type => []]; + $fieldtypes[$record->type][] = $record->contextid; + } + $records->close(); + + // Delete description files. + foreach ($contexts as $contextid) { + get_file_storage()->delete_area_files_select($contextid, 'core_customfield', 'description', + " IN ($fieldsql) ", $params); + } + + // Call plugin callbacks to delete fields customfield_provider::before_delete_fields(). + foreach ($fieldtypes as $type => $contextids) { + $classname = manager::get_provider_classname_for_component('customfield_' . $type); + if (class_exists($classname) && is_subclass_of($classname, customfield_provider::class)) { + component_class_callback($classname, 'before_delete_fields', + [" IN ($fieldsql) ", $params, $contextids]); + } + } + $records->close(); + } + + /** + * Exports one instance of custom field data + * + * @param data_controller $data + * @param array $subcontext subcontext to pass to content_writer::export_data + */ + public static function export_customfield_data(data_controller $data, array $subcontext) { + $context = $data->get_context(); + + $exportdata = $data->to_record(); + $exportdata->fieldtype = $data->get_field()->get('type'); + $exportdata->fieldshortname = $data->get_field()->get('shortname'); + $exportdata->fieldname = $data->get_field()->get_formatted_name(); + $exportdata->timecreated = \core_privacy\local\request\transform::datetime($exportdata->timecreated); + $exportdata->timemodified = \core_privacy\local\request\transform::datetime($exportdata->timemodified); + unset($exportdata->contextid); + // Use the "export_value" by default for the 'value' attribute, however the plugins may override it in their callback. + $exportdata->value = $data->export_value(); + + $classname = manager::get_provider_classname_for_component('customfield_' . $data->get_field()->get('type')); + if (class_exists($classname) && is_subclass_of($classname, customfield_provider::class)) { + component_class_callback($classname, 'export_customfield_data', [$data, $exportdata, $subcontext]); + } else { + // Custom field plugin does not implement customfield_provider, just export default value. + writer::with_context($context)->export_data($subcontext, $exportdata); + } + } + + /** + * Export data record of unknown type when we were not able to create instance of data_controller + * + * @param \stdClass $record record from db table {customfield_data} + * @param \stdClass $field field record with at least fields type, shortname, name + * @param array $subcontext + */ + protected static function export_customfield_data_unknown(\stdClass $record, \stdClass $field, array $subcontext) { + $context = \context::instance_by_id($record->contextid); + + $record->fieldtype = $field->type; + $record->fieldshortname = $field->shortname; + $record->fieldname = format_string($field->name); + $record->timecreated = \core_privacy\local\request\transform::datetime($record->timecreated); + $record->timemodified = \core_privacy\local\request\transform::datetime($record->timemodified); + unset($record->contextid); + $record->value = format_text($record->value, $record->valueformat, ['context' => $context]); + writer::with_context($context)->export_data($subcontext, $record); } } diff --git a/externallib.php b/externallib.php new file mode 100644 index 0000000..1f26bd8 --- /dev/null +++ b/externallib.php @@ -0,0 +1,295 @@ +. + +/** + * External interface library for customfields component + * + * @package core_customfield + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die; + +require_once($CFG->libdir . "/externallib.php"); + +/** + * Class core_customfield_external + * + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_customfield_external extends external_api { + + /** + * Parameters for delete_field + * + * @return external_function_parameters + */ + public static function delete_field_parameters() { + return new external_function_parameters( + array('id' => new external_value(PARAM_INT, 'Custom field ID to delete', VALUE_REQUIRED)) + ); + } + + /** + * Delete custom field function + * + * @param int $id + */ + public static function delete_field($id) { + $params = self::validate_parameters(self::delete_field_parameters(), ['id' => $id]); + + $record = \core_customfield\field_controller::create($params['id']); + $handler = $record->get_handler(); + if (!$handler->can_configure()) { + throw new moodle_exception('nopermissionconfigure', 'core_customfield'); + } + $handler->delete_field_configuration($record); + } + + /** + * Return for delete_field + */ + public static function delete_field_returns() { + } + + /** + * Parameters for reload template function + * + * @return external_function_parameters + */ + public static function reload_template_parameters() { + return new external_function_parameters( + array( + 'component' => new external_value(PARAM_COMPONENT, 'component', VALUE_REQUIRED), + 'area' => new external_value(PARAM_ALPHANUMEXT, 'area', VALUE_REQUIRED), + 'itemid' => new external_value(PARAM_INT, 'itemid', VALUE_REQUIRED) + ) + ); + } + + /** + * Reload template function + * + * @param string $component + * @param string $area + * @param int $itemid + * @return array|object|stdClass + */ + public static function reload_template($component, $area, $itemid) { + global $PAGE; + + $params = self::validate_parameters(self::reload_template_parameters(), + ['component' => $component, 'area' => $area, 'itemid' => $itemid]); + + $PAGE->set_context(context_system::instance()); + $handler = \core_customfield\handler::get_handler($params['component'], $params['area'], $params['itemid']); + self::validate_context($handler->get_configuration_context()); + if (!$handler->can_configure()) { + throw new moodle_exception('nopermissionconfigure', 'core_customfield'); + } + $output = $PAGE->get_renderer('core_customfield'); + $outputpage = new \core_customfield\output\management($handler); + return $outputpage->export_for_template($output); + } + + /** + * Ajax returns on reload template. + * + * @return external_single_structure + */ + public static function reload_template_returns() { + return new external_single_structure( + array( + 'component' => new external_value(PARAM_COMPONENT, 'component'), + 'area' => new external_value(PARAM_ALPHANUMEXT, 'area'), + 'itemid' => new external_value(PARAM_INT, 'itemid'), + 'usescategories' => new external_value(PARAM_BOOL, 'view has categories'), + 'categories' => new external_multiple_structure( + new external_single_structure( + array( + 'id' => new external_value(PARAM_INT, 'id'), + 'nameeditable' => new external_value(PARAM_RAW, 'inplace editable name'), + 'addfieldmenu' => new external_value(PARAM_RAW, 'addfieldmenu'), + 'fields' => new external_multiple_structure( + new external_single_structure( + array( + 'name' => new external_value(PARAM_NOTAGS, 'name'), + 'shortname' => new external_value(PARAM_NOTAGS, 'shortname'), + 'type' => new external_value(PARAM_NOTAGS, 'type'), + 'id' => new external_value(PARAM_INT, 'id'), + ) + ) + , '', VALUE_OPTIONAL), + ) + ) + ), + ) + ); + } + + /** + * Parameters for delete category + * + * @return external_function_parameters + */ + public static function delete_category_parameters() { + return new external_function_parameters( + array('id' => new external_value(PARAM_INT, 'category ID to delete', VALUE_REQUIRED)) + ); + } + + /** + * Delete category function + * + * @param int $id + */ + public static function delete_category($id) { + $category = core_customfield\category_controller::create($id); + $handler = $category->get_handler(); + self::validate_context($handler->get_configuration_context()); + if (!$handler->can_configure()) { + throw new moodle_exception('nopermissionconfigure', 'core_customfield'); + } + $handler->delete_category($category); + } + + /** + * Return for delete category + */ + public static function delete_category_returns() { + } + + + /** + * Parameters for create category + * + * @return external_function_parameters + */ + public static function create_category_parameters() { + return new external_function_parameters( + array( + 'component' => new external_value(PARAM_COMPONENT, 'component', VALUE_REQUIRED), + 'area' => new external_value(PARAM_ALPHANUMEXT, 'area', VALUE_REQUIRED), + 'itemid' => new external_value(PARAM_INT, 'itemid', VALUE_REQUIRED) + ) + ); + } + + /** + * Create category function + * + * @param string $component + * @param string $area + * @param int $itemid + * @return mixed + */ + public static function create_category($component, $area, $itemid) { + $params = self::validate_parameters(self::create_category_parameters(), + ['component' => $component, 'area' => $area, 'itemid' => $itemid]); + + $handler = \core_customfield\handler::get_handler($params['component'], $params['area'], $params['itemid']); + self::validate_context($handler->get_configuration_context()); + if (!$handler->can_configure()) { + throw new moodle_exception('nopermissionconfigure', 'core_customfield'); + } + return $handler->create_category(); + } + + /** + * Return for create category + */ + public static function create_category_returns() { + return new external_value(PARAM_INT, 'Id of the category'); + } + + /** + * Parameters for move field. + * + * @return external_function_parameters + */ + public static function move_field_parameters() { + return new external_function_parameters( + ['id' => new external_value(PARAM_INT, 'Id of the field to move', VALUE_REQUIRED), + 'categoryid' => new external_value(PARAM_INT, 'New parent category id', VALUE_REQUIRED), + 'beforeid' => new external_value(PARAM_INT, 'Id of the field before which it needs to be moved', + VALUE_DEFAULT, 0)] + ); + } + + /** + * Move/reorder field. Move a field to another category and/or change sortorder of fields + * + * @param int $id field id + * @param int $categoryid + * @param int $beforeid + */ + public static function move_field($id, $categoryid, $beforeid) { + $params = self::validate_parameters(self::move_field_parameters(), + ['id' => $id, 'categoryid' => $categoryid, 'beforeid' => $beforeid]); + $field = \core_customfield\field_controller::create($params['id']); + $handler = $field->get_handler(); + self::validate_context($handler->get_configuration_context()); + if (!$handler->can_configure()) { + throw new moodle_exception('nopermissionconfigure', 'core_customfield'); + } + $handler->move_field($field, $params['categoryid'], $params['beforeid']); + } + + /** + * Return for move field + */ + public static function move_field_returns() { + } + + /** + * Return for move category + * + * @return external_function_parameters + */ + public static function move_category_parameters() { + return new external_function_parameters( + ['id' => new external_value(PARAM_INT, 'Category ID to move', VALUE_REQUIRED), + 'beforeid' => new external_value(PARAM_INT, 'Id of the category before which it needs to be moved', + VALUE_DEFAULT, 0)] + ); + } + + /** + * Reorder categories. Move category to the new position + * + * @param int $id category id + * @param int $beforeid + */ + public static function move_category(int $id, int $beforeid) { + $params = self::validate_parameters(self::move_category_parameters(), + ['id' => $id, 'beforeid' => $beforeid]); + $category = core_customfield\category_controller::create($id); + $handler = $category->get_handler(); + self::validate_context($handler->get_configuration_context()); + if (!$handler->can_configure()) { + throw new moodle_exception('nopermissionconfigure', 'core_customfield'); + } + $handler->move_category($category, $params['beforeid']); + } + + /** + * Return for move category + */ + public static function move_category_returns() { + } +} diff --git a/field/checkbox/classes/data_controller.php b/field/checkbox/classes/data_controller.php new file mode 100644 index 0000000..4d080d7 --- /dev/null +++ b/field/checkbox/classes/data_controller.php @@ -0,0 +1,87 @@ +. + +/** + * Customfield Checkbox plugin + * + * @package customfield_checkbox + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace customfield_checkbox; + +use core_customfield\api; +use core_customfield\output\field_data; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class data + * + * @package customfield_checkbox + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class data_controller extends \core_customfield\data_controller { + + /** + * Return the name of the field where the information is stored + * @return string + */ + public function datafield() : string { + return 'intvalue'; + } + + /** + * Add fields for editing a checkbox field. + * + * @param \MoodleQuickForm $mform + */ + public function instance_form_definition(\MoodleQuickForm $mform) { + $field = $this->get_field(); + $config = $field->get('configdata'); + $elementname = $this->get_form_element_name(); + // If checkbox is required (i.e. "agree to terms") then use 'checkbox' form element. + // The advcheckbox element cannot be used for required fields because advcheckbox elements always provide a value. + $isrequired = $field->get_configdata_property('required'); + $mform->addElement($isrequired ? 'checkbox' : 'advcheckbox', $elementname, $this->get_field()->get_formatted_name()); + $mform->setDefault($elementname, $config['checkbydefault']); + $mform->setType($elementname, PARAM_BOOL); + if ($isrequired) { + $mform->addRule($elementname, null, 'required', null, 'client'); + } + } + + /** + * Returns the default value as it would be stored in the database (not in human-readable format). + * + * @return mixed + */ + public function get_default_value() { + return $this->get_field()->get_configdata_property('checkbydefault') ? 1 : 0; + } + + /** + * Returns value in a human-readable format + * + * @return mixed|null value or null if empty + */ + public function export_value() { + $value = $this->get_value(); + return $value ? get_string('yes') : get_string('no'); + } +} diff --git a/field/checkbox/classes/field_controller.php b/field/checkbox/classes/field_controller.php new file mode 100644 index 0000000..6e4f647 --- /dev/null +++ b/field/checkbox/classes/field_controller.php @@ -0,0 +1,94 @@ +. + +/** + * Customfields checkbox plugin + * + * @package customfield_checkbox + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace customfield_checkbox; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class field + * + * @package customfield_checkbox + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class field_controller extends \core_customfield\field_controller { + /** + * Plugin type + */ + const TYPE = 'checkbox'; + + /** + * Add fields for editing a checkbox field. + * + * @param \MoodleQuickForm $mform + */ + public function config_form_definition(\MoodleQuickForm $mform) { + $mform->addElement('header', 'header_specificsettings', get_string('specificsettings', 'customfield_checkbox')); + $mform->setExpanded('header_specificsettings', true); + + $mform->addElement('selectyesno', 'configdata[checkbydefault]', get_string('checkedbydefault', 'customfield_checkbox')); + $mform->setType('configdata[checkbydefault]', PARAM_BOOL); + } + + /** + * Validate the data on the field configuration form + * + * @param array $data from the add/edit profile field form + * @param array $files + * @return array associative array of error messages + */ + public function config_form_validation(array $data, $files = array()) : array { + $errors = parent::config_form_validation($data, $files); + + if ($data['configdata']['uniquevalues']) { + $errors['configdata[uniquevalues]'] = get_string('errorconfigunique', 'customfield_checkbox'); + } + + return $errors; + } + + /** + * Does this custom field type support being used as part of the block_myoverview + * custom field grouping? + * @return bool + */ + public function supports_course_grouping(): bool { + return true; + } + + /** + * If this field supports course grouping, then this function needs overriding to + * return the formatted values for this. + * @param array $values the used values that need formatting + * @return array + */ + public function course_grouping_format_values($values): array { + $name = $this->get_formatted_name(); + return [ + 1 => $name.': '.get_string('yes'), + BLOCK_MYOVERVIEW_CUSTOMFIELD_EMPTY => $name.': '.get_string('no'), + ]; + } +} diff --git a/field/checkbox/classes/privacy/provider.php b/field/checkbox/classes/privacy/provider.php new file mode 100644 index 0000000..f892009 --- /dev/null +++ b/field/checkbox/classes/privacy/provider.php @@ -0,0 +1,81 @@ +. + +/** + * Privacy Subsystem implementation for customfield_checkbox. + * + * @package customfield_checkbox + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace customfield_checkbox\privacy; + +use core_customfield\data_controller; +use core_customfield\privacy\customfield_provider; +use core_privacy\local\request\writer; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Privacy Subsystem for customfield_checkbox implementing null_provider. + * + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider, customfield_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason() : string { + return 'privacy:metadata'; + } + + /** + * Preprocesses data object that is going to be exported + * + * @param data_controller $data + * @param \stdClass $exportdata + * @param array $subcontext + */ + public static function export_customfield_data(data_controller $data, \stdClass $exportdata, array $subcontext) { + writer::with_context($data->get_context())->export_data($subcontext, $exportdata); + } + + /** + * Allows plugins to delete everything they store related to the data (usually files) + * + * @param string $dataidstest + * @param array $params + * @param array $contextids + * @return mixed|void + */ + public static function before_delete_data(string $dataidstest, array $params, array $contextids) { + } + + /** + * Allows plugins to delete everything they store related to the field configuration (usually files) + * + * @param string $fieldidstest + * @param array $params + * @param array $contextids + */ + public static function before_delete_fields(string $fieldidstest, array $params, array $contextids) { + } +} diff --git a/field/checkbox/lang/en/customfield_checkbox.php b/field/checkbox/lang/en/customfield_checkbox.php new file mode 100644 index 0000000..cffdc85 --- /dev/null +++ b/field/checkbox/lang/en/customfield_checkbox.php @@ -0,0 +1,30 @@ +. + +/** + * Customfield checkbox plugin + * @package customfield_checkbox + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$string['checkedbydefault'] = 'Checked by default'; +$string['errorconfigunique'] = 'The checkbox field cannot be defined as unique.'; +$string['pluginname'] = 'Checkbox'; +$string['privacy:metadata'] = 'The Checkbox field type plugin doesn\'t store any personal data; it uses tables defined in core.'; +$string['specificsettings'] = 'Checkbox field settings'; diff --git a/field/checkbox/tests/behat/field.feature b/field/checkbox/tests/behat/field.feature new file mode 100644 index 0000000..66705f9 --- /dev/null +++ b/field/checkbox/tests/behat/field.feature @@ -0,0 +1,78 @@ +@customfield @customfield_checkbox @javascript +Feature: Managers can manage course custom fields checkbox + In order to have additional data on the course + As a manager + I need to create, edit, remove and sort custom fields + + Background: + Given the following "custom field categories" exist: + | name | component | area | itemid | + | Category for test | core_course | course | 0 | + And I log in as "admin" + And I navigate to "Courses > Course custom fields" in site administration + + Scenario: Create a custom course checkbox field + When I click on "Add a new custom field" "link" + And I click on "Checkbox" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + And I click on "Save changes" "button" in the "Adding a new Checkbox" "dialogue" + Then I should see "Test field" + And I log out + + Scenario: Edit a custom course checkbox field + When I click on "Add a new custom field" "link" + And I click on "Checkbox" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + And I click on "Save changes" "button" in the "Adding a new Checkbox" "dialogue" + And I click on "Edit" "link" in the "Test field" "table_row" + And I set the following fields to these values: + | Name | Edited field | + And I click on "Save changes" "button" in the "Updating Test field" "dialogue" + Then I should see "Edited field" + And I should not see "Test field" + + Scenario: Delete a custom course checkbox field + When I click on "Add a new custom field" "link" + And I click on "Checkbox" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + And I click on "Save changes" "button" in the "Adding a new Checkbox" "dialogue" + And I click on "Delete" "link" in the "Test field" "table_row" + And I click on "Yes" "button" in the "Confirm" "dialogue" + Then I should not see "Test field" + And I log out + + Scenario: A checkbox checked by default must be shown on listing but allow uncheck that will keep showing + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | Example 1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | topics | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + When I click on "Add a new custom field" "link" + And I click on "Checkbox" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + | Checked by default | Yes | + And I click on "Save changes" "button" in the "Adding a new Checkbox" "dialogue" + And I log out + And I log in as "teacher1" + And I am on site homepage + Then I should see "Test field: Yes" + When I am on "Course 1" course homepage + And I navigate to "Settings" in current page administration + And I expand all fieldsets + And I set the field "Test field" to "" + And I press "Save and display" + And I am on site homepage + Then I should see "Test field: No" + And I log out diff --git a/field/checkbox/tests/plugin_test.php b/field/checkbox/tests/plugin_test.php new file mode 100644 index 0000000..23be055 --- /dev/null +++ b/field/checkbox/tests/plugin_test.php @@ -0,0 +1,172 @@ +. + +namespace customfield_checkbox; + +use core_customfield_generator; +use core_customfield_test_instance_form; + +/** + * Functional test for customfield_checkbox + * + * @package customfield_checkbox + * @copyright 2019 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class plugin_test extends \advanced_testcase { + + /** @var \stdClass[] */ + private $courses = []; + /** @var \core_customfield\category_controller */ + private $cfcat; + /** @var \core_customfield\field_controller[] */ + private $cfields; + /** @var \core_customfield\data_controller[] */ + private $cfdata; + + /** + * Tests set up. + */ + public function setUp(): void { + $this->resetAfterTest(); + + $this->cfcat = $this->get_generator()->create_category(); + + $this->cfields[1] = $this->get_generator()->create_field( + ['categoryid' => $this->cfcat->get('id'), 'shortname' => 'myfield1', 'type' => 'checkbox']); + $this->cfields[2] = $this->get_generator()->create_field( + ['categoryid' => $this->cfcat->get('id'), 'shortname' => 'myfield2', 'type' => 'checkbox', + 'configdata' => ['required' => 1]]); + $this->cfields[3] = $this->get_generator()->create_field( + ['categoryid' => $this->cfcat->get('id'), 'shortname' => 'myfield3', 'type' => 'checkbox', + 'configdata' => ['checkbydefault' => 1]]); + + $this->courses[1] = $this->getDataGenerator()->create_course(); + $this->courses[2] = $this->getDataGenerator()->create_course(); + $this->courses[3] = $this->getDataGenerator()->create_course(); + + $this->cfdata[1] = $this->get_generator()->add_instance_data($this->cfields[1], $this->courses[1]->id, 1); + $this->cfdata[2] = $this->get_generator()->add_instance_data($this->cfields[1], $this->courses[2]->id, 1); + + $this->setUser($this->getDataGenerator()->create_user()); + } + + /** + * Get generator + * @return core_customfield_generator + */ + protected function get_generator() : core_customfield_generator { + return $this->getDataGenerator()->get_plugin_generator('core_customfield'); + } + + /** + * Test for initialising field and data controllers + */ + public function test_initialise() { + $f = \core_customfield\field_controller::create($this->cfields[1]->get('id')); + $this->assertTrue($f instanceof field_controller); + + $f = \core_customfield\field_controller::create(0, (object)['type' => 'checkbox'], $this->cfcat); + $this->assertTrue($f instanceof field_controller); + + $d = \core_customfield\data_controller::create($this->cfdata[1]->get('id')); + $this->assertTrue($d instanceof data_controller); + + $d = \core_customfield\data_controller::create(0, null, $this->cfields[1]); + $this->assertTrue($d instanceof data_controller); + } + + /** + * Test for configuration form functions + * + * Create a configuration form and submit it with the same values as in the field + */ + public function test_config_form() { + $this->setAdminUser(); + $submitdata = (array)$this->cfields[1]->to_record(); + $submitdata['configdata'] = $this->cfields[1]->get('configdata'); + + $submitdata = \core_customfield\field_config_form::mock_ajax_submit($submitdata); + $form = new \core_customfield\field_config_form(null, null, 'post', '', null, true, + $submitdata, true); + $form->set_data_for_dynamic_submission(); + $this->assertTrue($form->is_validated()); + $form->process_dynamic_submission(); + + // Try submitting with 'unique values' checked. + $submitdata['configdata']['uniquevalues'] = 1; + + $submitdata = \core_customfield\field_config_form::mock_ajax_submit($submitdata); + $form = new \core_customfield\field_config_form(null, null, 'post', '', null, true, + $submitdata, true); + $form->set_data_for_dynamic_submission(); + $this->assertFalse($form->is_validated()); + } + + /** + * Test for instance form functions + */ + public function test_instance_form() { + global $CFG; + require_once($CFG->dirroot . '/customfield/tests/fixtures/test_instance_form.php'); + $this->setAdminUser(); + $handler = $this->cfcat->get_handler(); + + // First try to submit without required field. + $submitdata = (array)$this->courses[1]; + core_customfield_test_instance_form::mock_submit($submitdata, []); + $form = new core_customfield_test_instance_form('POST', + ['handler' => $handler, 'instance' => $this->courses[1]]); + $this->assertFalse($form->is_validated()); + + // Now with required field. + $submitdata['customfield_myfield2'] = 1; + core_customfield_test_instance_form::mock_submit($submitdata, []); + $form = new core_customfield_test_instance_form('POST', + ['handler' => $handler, 'instance' => $this->courses[1]]); + $this->assertTrue($form->is_validated()); + + $data = $form->get_data(); + $this->assertNotEmpty($data->customfield_myfield1); + $this->assertNotEmpty($data->customfield_myfield2); + $handler->instance_form_save($data); + } + + /** + * Test for data_controller::get_value and export_value + */ + public function test_get_export_value() { + $this->assertEquals(1, $this->cfdata[1]->get_value()); + $this->assertEquals('Yes', $this->cfdata[1]->export_value()); + + // Field without data. + $d = \core_customfield\data_controller::create(0, null, $this->cfields[2]); + $this->assertEquals(0, $d->get_value()); + $this->assertEquals('No', $d->export_value()); + + // Field without data that is checked by default. + $d = \core_customfield\data_controller::create(0, null, $this->cfields[3]); + $this->assertEquals(1, $d->get_value()); + $this->assertEquals('Yes', $d->export_value()); + } + + /** + * Deleting fields and data + */ + public function test_delete() { + $this->cfcat->get_handler()->delete_all(); + } +} diff --git a/field/checkbox/version.php b/field/checkbox/version.php new file mode 100644 index 0000000..0efff64 --- /dev/null +++ b/field/checkbox/version.php @@ -0,0 +1,28 @@ +. + +/** + * Customfield checkbox plugin + * @package customfield_checkbox + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->component = 'customfield_checkbox'; +$plugin->version = 2022041900; +$plugin->requires = 2022041200; diff --git a/field/date/classes/data_controller.php b/field/date/classes/data_controller.php new file mode 100644 index 0000000..98115e4 --- /dev/null +++ b/field/date/classes/data_controller.php @@ -0,0 +1,147 @@ +. + +/** + * Customfield date plugin + * + * @package customfield_date + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace customfield_date; + +use core_customfield\api; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class data + * + * @package customfield_date + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class data_controller extends \core_customfield\data_controller { + + /** + * Return the name of the field where the information is stored + * @return string + */ + public function datafield() : string { + return 'intvalue'; + } + + /** + * Add fields for editing data of a date field on a context. + * + * @param \MoodleQuickForm $mform + */ + public function instance_form_definition(\MoodleQuickForm $mform) { + $field = $this->get_field(); + // Get the current calendar in use - see MDL-18375. + $calendartype = \core_calendar\type_factory::get_calendar_instance(); + + $config = $field->get('configdata'); + + // Always set the form element to "optional", even when it's required. Otherwise it defaults to the + // current date and is easy to miss. + $attributes = ['optional' => true]; + + if (!empty($config['mindate'])) { + $attributes['startyear'] = $calendartype->timestamp_to_date_array($config['mindate'])['year']; + } + + if (!empty($config['maxdate'])) { + $attributes['stopyear'] = $calendartype->timestamp_to_date_array($config['maxdate'])['year']; + } + + if (empty($config['includetime'])) { + $element = 'date_selector'; + } else { + $element = 'date_time_selector'; + } + $elementname = $this->get_form_element_name(); + $mform->addElement($element, $elementname, $this->get_field()->get_formatted_name(), $attributes); + $mform->setType($elementname, PARAM_INT); + $mform->setDefault($elementname, time()); + if ($field->get_configdata_property('required')) { + $mform->addRule($elementname, null, 'required', null, 'client'); + } + } + + /** + * Validates data for this field. + * + * @param array $data + * @param array $files + * @return array + */ + public function instance_form_validation(array $data, array $files) : array { + $errors = parent::instance_form_validation($data, $files); + + $elementname = $this->get_form_element_name(); + if (!empty($data[$elementname])) { + // Compare the date with min/max values, trim the date to the minute or to the day (depending on inludetime setting). + $includetime = $this->get_field()->get_configdata_property('includetime'); + $machineformat = $includetime ? '%Y-%m-%d %H:%M' : '%Y-%m-%d'; + $humanformat = $includetime ? get_string('strftimedatetimeshort') : get_string('strftimedatefullshort'); + $value = userdate($data[$elementname], $machineformat, 99, false, false); + $mindate = $this->get_field()->get_configdata_property('mindate'); + $maxdate = $this->get_field()->get_configdata_property('maxdate'); + + if ($mindate && userdate($mindate, $machineformat, 99, false, false) > $value) { + $errors[$elementname] = get_string('errormindate', 'customfield_date', userdate($mindate, $humanformat)); + } + if ($maxdate && userdate($maxdate, $machineformat, 99, false, false) < $value) { + $errors[$elementname] = get_string('errormaxdate', 'customfield_date', userdate($maxdate, $humanformat)); + } + } + + return $errors; + } + + /** + * Returns the default value as it would be stored in the database (not in human-readable format). + * + * @return mixed + */ + public function get_default_value() { + return 0; + } + + /** + * Returns value in a human-readable format + * + * @return mixed|null value or null if empty + */ + public function export_value() { + $value = $this->get_value(); + + if ($this->is_empty($value)) { + return null; + } + + // Check if time needs to be included. + if ($this->get_field()->get_configdata_property('includetime')) { + $format = get_string('strftimedaydatetime', 'langconfig'); + } else { + $format = get_string('strftimedate', 'langconfig'); + } + + return userdate($value, $format); + } +} diff --git a/field/date/classes/field_controller.php b/field/date/classes/field_controller.php new file mode 100644 index 0000000..f8fb5ad --- /dev/null +++ b/field/date/classes/field_controller.php @@ -0,0 +1,130 @@ +. + +/** + * Customfield date plugin + * + * @package customfield_date + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace customfield_date; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class field + * + * @package customfield_date + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class field_controller extends \core_customfield\field_controller { + /** + * Type of plugin data + */ + const TYPE = 'date'; + + /** + * Validate the data from the config form. + * + * @param array $data + * @param array $files + * @return array associative array of error messages + */ + public function config_form_validation(array $data, $files = array()) : array { + $errors = array(); + + // Make sure the start year is not greater than the end year. + if (!empty($data['configdata']['mindate']) && !empty($data['configdata']['maxdate']) && + $data['configdata']['mindate'] > $data['configdata']['maxdate']) { + $errors['configdata[mindate]'] = get_string('mindateaftermax', 'customfield_date'); + } + + return $errors; + } + + /** + * Add fields for editing a date field. + * + * @param \MoodleQuickForm $mform + */ + public function config_form_definition(\MoodleQuickForm $mform) { + $config = $this->get('configdata'); + + // Add elements. + $mform->addElement('header', 'header_specificsettings', get_string('specificsettings', 'customfield_date')); + $mform->setExpanded('header_specificsettings', true); + + $mform->addElement('advcheckbox', 'configdata[includetime]', get_string('includetime', 'customfield_date')); + + $mform->addElement('date_time_selector', 'configdata[mindate]', get_string('mindate', 'customfield_date'), + ['optional' => true]); + + $mform->addElement('date_time_selector', 'configdata[maxdate]', get_string('maxdate', 'customfield_date'), + ['optional' => true]); + + $mform->hideIf('configdata[maxdate][hour]', 'configdata[includetime]'); + $mform->hideIf('configdata[maxdate][minute]', 'configdata[includetime]'); + $mform->hideIf('configdata[mindate][hour]', 'configdata[includetime]'); + $mform->hideIf('configdata[mindate][minute]', 'configdata[includetime]'); + } + + /** + * Does this custom field type support being used as part of the block_myoverview + * custom field grouping? + * @return bool + */ + public function supports_course_grouping(): bool { + return true; + } + + /** + * If this field supports course grouping, then this function needs overriding to + * return the formatted values for this. + * @param array $values the used values that need formatting + * @return array + */ + public function course_grouping_format_values($values): array { + $format = get_string('strftimedate', 'langconfig'); + $ret = []; + foreach ($values as $value) { + if ($value) { + $ret[$value] = userdate($value, $format); + } + } + if (!$ret) { + return []; // If the only dates found are 0, then do not show any options. + } + $ret[BLOCK_MYOVERVIEW_CUSTOMFIELD_EMPTY] = get_string('nocustomvalue', 'block_myoverview', + $this->get_formatted_name()); + return $ret; + } + + /** + * Convert given value into appropriate timestamp + * + * @param string $value + * @return int + */ + public function parse_value(string $value) { + $timestamp = strtotime($value); + + // If we have a valid, positive timestamp then return it. + return $timestamp > 0 ? $timestamp : 0; + } +} \ No newline at end of file diff --git a/field/date/classes/privacy/provider.php b/field/date/classes/privacy/provider.php new file mode 100644 index 0000000..74a6358 --- /dev/null +++ b/field/date/classes/privacy/provider.php @@ -0,0 +1,85 @@ +. +/** + * Privacy Subsystem implementation for customfield_date. + * + * @package customfield_date + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace customfield_date\privacy; + +use core_customfield\data_controller; +use core_customfield\privacy\customfield_provider; +use core_privacy\local\request\writer; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Privacy Subsystem for customfield_date implementing null_provider. + * + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider, customfield_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason() : string { + return 'privacy:metadata'; + } + + /** + * Preprocesses data object that is going to be exported + * + * @param data_controller $data + * @param \stdClass $exportdata + * @param array $subcontext + */ + public static function export_customfield_data(data_controller $data, \stdClass $exportdata, array $subcontext) { + $context = $data->get_context(); + // For date field we want to use PrivacyAPI date format instead of export_value(). + $exportdata->value = \core_privacy\local\request\transform::datetime($data->get_value()); + writer::with_context($context) + ->export_data($subcontext, $exportdata); + } + + /** + * Allows plugins to delete everything they store related to the data (usually files) + * + * @param string $dataidstest + * @param array $params + * @param array $contextids + * @return mixed|void + */ + public static function before_delete_data(string $dataidstest, array $params, array $contextids) { + } + + /** + * Allows plugins to delete everything they store related to the field configuration (usually files) + * + * @param string $fieldidstest + * @param array $params + * @param array $contextids + */ + public static function before_delete_fields(string $fieldidstest, array $params, array $contextids) { + } +} diff --git a/field/date/lang/en/customfield_date.php b/field/date/lang/en/customfield_date.php new file mode 100644 index 0000000..d23c2e2 --- /dev/null +++ b/field/date/lang/en/customfield_date.php @@ -0,0 +1,35 @@ +. + +/** + * Customfields date plugin + * + * @package customfield_date + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$string['errormaxdate'] = 'Please enter a date no later than {$a}.'; +$string['errormindate'] = 'Please enter a date on or after {$a}.'; +$string['includetime'] = 'Include time'; +$string['maxdate'] = 'Maximum value'; +$string['mindate'] = 'Minimum value'; +$string['mindateaftermax'] = 'The minimum value cannot be bigger than the maximum value.'; +$string['pluginname'] = 'Date and time'; +$string['privacy:metadata'] = 'The Date and time field type plugin doesn\'t store any personal data; it uses tables defined in core.'; +$string['specificsettings'] = 'Date and time field settings'; diff --git a/field/date/lib.php b/field/date/lib.php new file mode 100644 index 0000000..00a7d3c --- /dev/null +++ b/field/date/lib.php @@ -0,0 +1,35 @@ +. + +/** + * Customfield date plugin + * + * @package customfield_date + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Get icon mapping for font-awesome. + */ +function customfield_date_get_fontawesome_icon_map() { + return [ + 'customfield_date:checked' => 'fa-check-square-o', + 'customfield_date:notchecked' => 'fa-square-o', + ]; +} diff --git a/field/date/pix/checked.png b/field/date/pix/checked.png new file mode 100644 index 0000000000000000000000000000000000000000..4da4b723fc019eea3417f474997b3b45a7f4cacd GIT binary patch literal 372 zcmV-)0gL{LP)95M1N?Om031>XBJlGjyLoeYP=w^ekOWm`#-9FrWyrbo`Xi2(p(%)X@Obua)l;C3@_ zqRo(0oTlTw?=*|N_uU3Ola$U^Fh2@AB*!Gj-uvzbxLlZEMYKtd@;uKr!Ak2d5*|n SbQYrk0000 + + + + + diff --git a/field/date/pix/notchecked.png b/field/date/pix/notchecked.png new file mode 100644 index 0000000000000000000000000000000000000000..02f53fbadc6a0237b2d3e7e812ebe5c07b333b62 GIT binary patch literal 266 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Y)RhkE)4%caKYZ?lYt`pJzX3_ zEPC%wGR$KNI?7J9TvUh`bg$Zv&Tz&=f-IP z8yv({gCjT1x1GkjUzfKrw={M;Q(WUokH6*{&ROcKN + + + + + diff --git a/field/date/tests/behat/field.feature b/field/date/tests/behat/field.feature new file mode 100644 index 0000000..f4b208e --- /dev/null +++ b/field/date/tests/behat/field.feature @@ -0,0 +1,102 @@ +@customfield @customfield_date @javascript +Feature: Managers can manage course custom fields date + In order to have additional data on the course + As a manager + I need to create, edit, remove and sort custom fields + + Background: + Given the following "custom field categories" exist: + | name | component | area | itemid | + | Category for test | core_course | course | 0 | + And I log in as "admin" + And I navigate to "Courses > Course custom fields" in site administration + + Scenario: Create a custom course date field + When I click on "Add a new custom field" "link" + And I click on "Date and time" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + And I click on "Save changes" "button" in the "Adding a new Date and time" "dialogue" + Then I should see "Test field" + And I log out + + Scenario: Edit a custom course date field + When I click on "Add a new custom field" "link" + And I click on "Date and time" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + And I click on "Save changes" "button" in the "Adding a new Date and time" "dialogue" + And I click on "[data-role='editfield']" "css_element" + And I set the following fields to these values: + | Name | Edited field | + And I click on "Save changes" "button" in the "Updating Test field" "dialogue" + Then I should see "Edited field" + And I log out + + Scenario: Delete a custom course date field + When I click on "Add a new custom field" "link" + And I click on "Date and time" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + And I click on "Save changes" "button" in the "Adding a new Date and time" "dialogue" + And I click on "[data-role='deletefield']" "css_element" + And I click on "Yes" "button" in the "Confirm" "dialogue" + Then I should not see "Test field" + And I log out + + Scenario: A date field makerd to include time must show those fields on course form + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | Example 1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | topics | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + When I click on "Add a new custom field" "link" + And I click on "Date and time" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + | Include time | 1 | + And I click on "Save changes" "button" in the "Adding a new Date and time" "dialogue" + And I log out + Then I log in as "teacher1" + When I am on site homepage + When I am on "Course 1" course homepage + And I navigate to "Settings" in current page administration + And I expand all fieldsets + Then "#id_customfield_testfield_hour" "css_element" should be visible + Then "#id_customfield_testfield_minute" "css_element" should be visible + And I log out + + Scenario: A date field makerd to not include time must not show those fields on course form + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | Example 1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | topics | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + When I click on "Add a new custom field" "link" + And I click on "Date and time" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + | Include time | | + And I click on "Save changes" "button" in the "Adding a new Date and time" "dialogue" + And I log out + Then I log in as "teacher1" + When I am on site homepage + When I am on "Course 1" course homepage + And I navigate to "Settings" in current page administration + And I expand all fieldsets + Then "#id_customfield_testfield_hour" "css_element" should not be visible + Then "#id_customfield_testfield_minute" "css_element" should not be visible + And I log out diff --git a/field/date/tests/plugin_test.php b/field/date/tests/plugin_test.php new file mode 100644 index 0000000..a2cd087 --- /dev/null +++ b/field/date/tests/plugin_test.php @@ -0,0 +1,202 @@ +. + +namespace customfield_date; + +use core_customfield_generator; +use core_customfield_test_instance_form; + +/** + * Functional test for customfield_date + * + * @package customfield_date + * @copyright 2019 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class plugin_test extends \advanced_testcase { + + /** @var stdClass[] */ + private $courses = []; + /** @var \core_customfield\category_controller */ + private $cfcat; + /** @var \core_customfield\field_controller[] */ + private $cfields; + /** @var \core_customfield\data_controller[] */ + private $cfdata; + + /** + * Tests set up. + */ + public function setUp(): void { + $this->resetAfterTest(); + + $this->cfcat = $this->get_generator()->create_category(); + + $this->cfields[1] = $this->get_generator()->create_field( + ['categoryid' => $this->cfcat->get('id'), 'shortname' => 'myfield1', 'type' => 'date']); + $this->cfields[2] = $this->get_generator()->create_field( + ['categoryid' => $this->cfcat->get('id'), 'shortname' => 'myfield2', 'type' => 'date', + 'configdata' => ['required' => 1, 'includetime' => 0, 'mindate' => 946684800, 'maxdate' => 1893456000]]); + + $this->courses[1] = $this->getDataGenerator()->create_course(); + $this->courses[2] = $this->getDataGenerator()->create_course(); + $this->courses[3] = $this->getDataGenerator()->create_course(); + + $this->cfdata[1] = $this->get_generator()->add_instance_data($this->cfields[1], $this->courses[1]->id, 1546300800); + $this->cfdata[2] = $this->get_generator()->add_instance_data($this->cfields[1], $this->courses[2]->id, 1546300800); + + $this->setUser($this->getDataGenerator()->create_user()); + } + + /** + * Get generator + * @return core_customfield_generator + */ + protected function get_generator() : core_customfield_generator { + return $this->getDataGenerator()->get_plugin_generator('core_customfield'); + } + + /** + * Test for initialising field and data controllers + */ + public function test_initialise() { + $f = \core_customfield\field_controller::create($this->cfields[1]->get('id')); + $this->assertTrue($f instanceof field_controller); + + $f = \core_customfield\field_controller::create(0, (object)['type' => 'date'], $this->cfcat); + $this->assertTrue($f instanceof field_controller); + + $d = \core_customfield\data_controller::create($this->cfdata[1]->get('id')); + $this->assertTrue($d instanceof data_controller); + + $d = \core_customfield\data_controller::create(0, null, $this->cfields[1]); + $this->assertTrue($d instanceof data_controller); + } + + /** + * Test for configuration form functions + * + * Create a configuration form and submit it with the same values as in the field + */ + public function test_config_form() { + $this->setAdminUser(); + $submitdata = (array)$this->cfields[1]->to_record(); + $submitdata['configdata'] = $this->cfields[1]->get('configdata'); + + $submitdata = \core_customfield\field_config_form::mock_ajax_submit($submitdata); + $form = new \core_customfield\field_config_form(null, null, 'post', '', null, true, + $submitdata, true); + $form->set_data_for_dynamic_submission(); + $this->assertTrue($form->is_validated()); + $form->process_dynamic_submission(); + } + + /** + * Test for instance form functions + */ + public function test_instance_form() { + global $CFG; + require_once($CFG->dirroot . '/customfield/tests/fixtures/test_instance_form.php'); + $this->setAdminUser(); + $handler = $this->cfcat->get_handler(); + + // First try to submit without required field. + $submitdata = (array)$this->courses[1]; + core_customfield_test_instance_form::mock_submit($submitdata, []); + $form = new core_customfield_test_instance_form('POST', + ['handler' => $handler, 'instance' => $this->courses[1]]); + $this->assertFalse($form->is_validated()); + + // Now with required field. + $submitdata['customfield_myfield2'] = time(); + core_customfield_test_instance_form::mock_submit($submitdata, []); + $form = new core_customfield_test_instance_form('POST', + ['handler' => $handler, 'instance' => $this->courses[1]]); + $this->assertTrue($form->is_validated()); + + $data = $form->get_data(); + $this->assertEmpty($data->customfield_myfield1); + $this->assertNotEmpty($data->customfield_myfield2); + $handler->instance_form_save($data); + } + + /** + * Test for min/max date validation + */ + public function test_instance_form_validation() { + $this->setAdminUser(); + $handler = $this->cfcat->get_handler(); + $submitdata = (array)$this->courses[1]; + $data = data_controller::create(0, null, $this->cfields[2]); + + // Submit with date less than mindate. + $submitdata['customfield_myfield2'] = 915148800; + $this->assertNotEmpty($data->instance_form_validation($submitdata, [])); + + // Submit with date more than maxdate. + $submitdata['customfield_myfield2'] = 1893557000; + $this->assertNotEmpty($data->instance_form_validation($submitdata, [])); + } + + /** + * Test for data_controller::get_value and export_value + */ + public function test_get_export_value() { + $this->assertEquals(1546300800, $this->cfdata[1]->get_value()); + $this->assertStringMatchesFormat('%a 1 January 2019%a', $this->cfdata[1]->export_value()); + + // Field without data. + $d = \core_customfield\data_controller::create(0, null, $this->cfields[2]); + $this->assertEquals(0, $d->get_value()); + $this->assertEquals(null, $d->export_value()); + } + + /** + * Data provider for {@see test_parse_value} + * + * @return array + */ + public function parse_value_provider() : array { + return [ + // Valid times. + ['2019-10-01', strtotime('2019-10-01')], + ['2019-10-01 14:00', strtotime('2019-10-01 14:00')], + // Invalid times. + ['ZZZZZ', 0], + ['202-04-01', 0], + ['2019-15-15', 0], + ]; + } + /** + * Test field parse_value method + * + * @param string $value + * @param int $expected + * @return void + * + * @dataProvider parse_value_provider + */ + public function test_parse_value(string $value, int $expected) { + $this->assertSame($expected, $this->cfields[1]->parse_value($value)); + } + + /** + * Deleting fields and data + */ + public function test_delete() { + $this->cfcat->get_handler()->delete_all(); + } +} diff --git a/field/date/version.php b/field/date/version.php new file mode 100644 index 0000000..cafdd39 --- /dev/null +++ b/field/date/version.php @@ -0,0 +1,30 @@ +. + +/** + * Customfield date plugin + * + * @package customfield_date + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->component = 'customfield_date'; +$plugin->version = 2022041900; +$plugin->requires = 2022041200; + diff --git a/.travis.yml b/field/multiselect/.travis.yml similarity index 57% rename from .travis.yml rename to field/multiselect/.travis.yml index 31ebffb..1b1bfab 100644 --- a/.travis.yml +++ b/field/multiselect/.travis.yml @@ -1,7 +1,7 @@ language: php addons: - postgresql: "9.6" + postgresql: "9.4" services: - mysql @@ -14,20 +14,25 @@ cache: - $HOME/.npm php: + - 7.1 - 7.2 - - 7.4 + - 7.3 env: matrix: + - DB=mysqli MOODLE_BRANCH=MOODLE_37_STABLE - DB=mysqli MOODLE_BRANCH=MOODLE_38_STABLE - - DB=mysqli MOODLE_BRANCH=MOODLE_39_STABLE - - DB=pgsql MOODLE_BRANCH=MOODLE_39_STABLE + - DB=pgsql MOODLE_BRANCH=MOODLE_37_STABLE + - DB=pgsql MOODLE_BRANCH=MOODLE_38_STABLE before_install: - phpenv config-rm xdebug.ini + - nvm install 14.2.0 + - nvm use 14.2.0 - cd ../.. - - composer create-project -n --no-dev --prefer-dist moodlehq/moodle-plugin-ci ci ^3 + - composer create-project -n --no-dev --prefer-dist blackboard-open-source/moodle-plugin-ci ci ^2 - export PATH="$(cd ci/bin; pwd):$(cd ci/vendor/bin; pwd):$PATH" + - docker run -d -p 127.0.0.1:4444:4444 --net=host -v /dev/shm:/dev/shm selenium/standalone-firefox:2.53.1-beryllium install: - moodle-plugin-ci install @@ -40,7 +45,7 @@ script: - moodle-plugin-ci validate - moodle-plugin-ci savepoints - moodle-plugin-ci mustache - - moodle-plugin-ci grunt +# - moodle-plugin-ci grunt # Grunt seems to fail on 3.8 for no obvious reasons - moodle-plugin-ci phpdoc - moodle-plugin-ci phpunit - moodle-plugin-ci behat \ No newline at end of file diff --git a/LICENSE.txt b/field/multiselect/LICENSE.txt similarity index 100% rename from LICENSE.txt rename to field/multiselect/LICENSE.txt diff --git a/field/multiselect/README.md b/field/multiselect/README.md new file mode 100644 index 0000000..9030f07 --- /dev/null +++ b/field/multiselect/README.md @@ -0,0 +1,19 @@ +Multiselect Custom Field +======================== + +[![Build Status](https://travis-ci.org/call-learning/moodle-customfield_multiselect.svg?branch=master)](https://travis-ci.org/call-learning/moodle-customfield_multiselect) + + +This plugin is a new multiselect profile inspired from the existing select customfield (customfield/field/select) + +It allows for several choices to be selected. +The data is stored in the database as comma separated values of option indexes. + + +Still work in progress. + + +TODO +==== + * Allow to change the select into a series of cheboxes for small amounts of choices. + * When values are removed from the list, should we re-index ? \ No newline at end of file diff --git a/field/multiselect/amd/build/clear.min.js b/field/multiselect/amd/build/clear.min.js new file mode 100644 index 0000000..3ad5a52 --- /dev/null +++ b/field/multiselect/amd/build/clear.min.js @@ -0,0 +1 @@ +define(["jquery"],function(a){return{init:function(b,c){a("#"+b).click(function(){var b=a("#"+c);b.val("")})}}}); \ No newline at end of file diff --git a/field/multiselect/amd/src/clear.js b/field/multiselect/amd/src/clear.js new file mode 100644 index 0000000..b31ef6e --- /dev/null +++ b/field/multiselect/amd/src/clear.js @@ -0,0 +1,33 @@ +// 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 . + +/** + * + * @package customfield_multiselect + * @copyright 2020 CALL Learning 2020 - Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define(['jquery'], + function ( + $) { + return { + init: function (buttonid, selectid) { + $('#' + buttonid).click(function () { + var ms = $('#' + selectid); + ms.val(''); + }); + } + }; + }); diff --git a/field/multiselect/classes/data_controller.php b/field/multiselect/classes/data_controller.php new file mode 100644 index 0000000..6e7baae --- /dev/null +++ b/field/multiselect/classes/data_controller.php @@ -0,0 +1,239 @@ +. + +/** + * Customfield multiselect Type + * + * @package customfield_multiselect + * @copyright 2020 CALL Learning 2020 - Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace customfield_multiselect; + +use core_customfield\data; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class data + * + * @package customfield_multiselect + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class data_controller extends \core_customfield\data_controller { + + /** + * Datafield value (here 'value') + * + * @return string + */ + public function datafield(): string { + return 'value'; // There could be a discussion here if it could not be a char value, but for long list that could have + // been a limitation. + } + + /** + * Get the default value for this field. The default value is a list of valid options. + * We just verify they exist before sending their index back. + * + * @return array a list of index of matching options + */ + public function get_default_value() { + $defaultvalue = $this->get_field()->get_configdata_property('defaultvalue'); + $options = field_controller::get_options_array($this->get_field()); + $defaultvaluesarray = []; + $values = explode(",", $defaultvalue); + + foreach ($values as $val) { + $index = $this->get_option_index($val, $options); + if ($index !== false) { + $defaultvaluesarray[] = intval($index); + } + } + return $defaultvaluesarray; + } + + /** + * Get the default value for this field in string format. The default value is a list of valid options. + * We just verify they exist before sending their index back. + * + * @return a string of comma seperated options + */ + public function get_default_value_string() { + $defaultvalue = $this->get_field()->get_configdata_property('defaultvalue'); + $options = field_controller::get_options_array($this->get_field()); + $defaultvaluesarray = []; + $values = explode(",", $defaultvalue); + + foreach ($values as $val) { + $index = $this->get_option_index($val, $options); + if ($index !== false) { + $defaultvaluesarray[] = intval($index); + } + } + return implode(",", $defaultvalues); + } + + /** + * Get the option index in the array of options from the raw text value + * + * @param mixed $rawvalue + * @param array $options + * @return false|int|string + */ + protected function get_option_index($rawvalue, $options) { + return array_search($rawvalue, $options); + } + + /** + * Define the form + * + * @param \MoodleQuickForm $mform + * @throws \coding_exception + */ + public function instance_form_definition(\MoodleQuickForm $mform) { + global $PAGE; + $field = $this->get_field(); + $config = $field->get('configdata'); + $options = field_controller::get_options_array($field); + $formattedoptions = []; + $attributes = array('multiple' => true); + $context = $this->get_field()->get_handler()->get_configuration_context(); + foreach ($options as $key => $option) { + // Multilang formatting with filters. + $formattedoptions[$key] = format_string($option, true, ['context' => $context]); + } + + $elementname = $this->get_form_element_name(); + $mform->addElement('select', $elementname, + $this->get_field()->get_formatted_name(), + $formattedoptions, $attributes); + + $clearbtnname = $this->get_form_element_name() . '_cls'; + $mform->addElement('button', $clearbtnname, + get_string('clear', 'customfield_multiselect')); + + if (($defaultkey = array_search($config['defaultvalue'], $options)) !== false) { + $mform->setDefault($elementname, $defaultkey); + } + if ($field->get_configdata_property('required')) { + $mform->addRule($elementname, null, 'required', null, 'client'); + } + + $PAGE->requires->js_call_amd('customfield_multiselect/clear', 'init', array( + "id_{$clearbtnname}", "id_{$elementname}" + )); + + } + + /** + * Prepares the custom field data related to the object to pass to mform->set_data() and adds them to it + * + * This function must be called before calling $form->set_data($object); + * + * @param \stdClass $instance the instance that has custom fields, if 'id' attribute is present the custom + * fields for this instance will be added, otherwise the default values will be added. + */ + public function instance_form_before_set_data(\stdClass $instance) { + $instance->{$this->get_form_element_name()} = implode(',', $this->get_value_arr()); + } + + /** + * Saves the data coming from form + * + * @param \stdClass $datanew data coming from the form + * @throws \coding_exception + */ + public function instance_form_save(\stdClass $datanew) { + $elementname = $this->get_form_element_name(); + if (!property_exists($datanew, $elementname)) { + return; + } + $value = implode(',', $datanew->$elementname); + $this->data->set($this->datafield(), $value); + $this->data->set('value', $value); + $this->save(); + } + + /** + * Returns the value as it is stored in the database or default value if data record is not present + * + * @return array + */ + public function get_value() { + if (!$this->get('id')) { + return $this->get_default_value_string(); + } + return $this->get($this->datafield()); + } + + public function get_value_arr() { + if (!$this->get('id')) { + return $this->get_default_value(); + } + return explode(',', $this->get($this->datafield())); + } + + + /** + * Set the value as it should be stored in the database + * + * @param array $value to be set and transformed into a comma separated string + * @return data + */ + public function set_value($value) { + return $this->set($this->datafield(), implode(',', $value)); + } + + /** + * Checks if the value is empty + * + * @param mixed $value + * @return bool + */ + protected function is_empty($value): bool { + return empty($value); + } + + /** + * Returns value in a human-readable format or default value if data record is not present + * + * This is the default implementation that most likely needs to be overridden + * + * @return mixed|null value or null if empty + */ + public function export_value() { + $values = $this->get_value(); // This is a an array of indexes. + + if ($this->is_empty($values)) { + return null; + } + + $commasepoptionvalues = ""; + $options = field_controller::get_options_array($this->get_field()); + foreach ($values as $val) { + if (!empty($options[$val])) { + $commasepoptionvalues .= (empty($commasepoptionvalues) ? '' : ', ') . + format_string($options[$val], true, + ['context' => $this->get_field()->get_handler()->get_configuration_context()]); + } + } + return $commasepoptionvalues; + } + +} diff --git a/field/multiselect/classes/field_controller.php b/field/multiselect/classes/field_controller.php new file mode 100644 index 0000000..e5ff2b9 --- /dev/null +++ b/field/multiselect/classes/field_controller.php @@ -0,0 +1,105 @@ +. + +/** + * Customfield multiselect Type + * + * @package customfield_multiselect + * @copyright 2020 CALL Learning 2020 - Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace customfield_multiselect; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class field + * + * @package customfield_multiselect + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class field_controller extends \core_customfield\field_controller { + /** + * Customfield type + */ + const TYPE = 'multiselect'; + + /** + * Form defintion for multiselect + * + * @param \MoodleQuickForm $mform + * @throws \coding_exception + */ + public function config_form_definition(\MoodleQuickForm $mform) { + $mform->addElement('header', 'header_specificsettings', get_string('specificsettings', 'customfield_multiselect')); + $mform->setExpanded('header_specificsettings', true); + + $mform->addElement('textarea', 'configdata[options]', get_string('menuoptions', 'customfield_multiselect')); + $mform->setType('configdata[options]', PARAM_TEXT); + + $mform->addElement('text', 'configdata[defaultvalue]', + get_string('defaultvalue', 'customfield_multiselect') + ); + $mform->addHelpButton('configdata[defaultvalue]', + 'defaultvalue', 'customfield_multiselect'); + $mform->setType('configdata[defaultvalue]', PARAM_TEXT); + } + + /** + * Returns the options available as an array. + * + * @param \core_customfield\field_controller $field + * @return array + */ + public static function get_options_array(\core_customfield\field_controller $field): array { + if ($field->get_configdata_property('options')) { + $options = preg_split("/\s*\n\s*/", trim($field->get_configdata_property('options'))); + } else { + $options = array(); + } + return $options; + } + + /** + * Validate the data from the config form. + * Sub classes must reimplement it. + * + * @param array $data from the add/edit profile field form + * @param array $files + * @return array associative array of error messages + * @throws \coding_exception + */ + public function config_form_validation(array $data, $files = array()): array { + $options = preg_split("/\s*\n\s*/", trim($data['configdata']['options'])); + $errors = []; + if (!$options || count($options) < 2) { + $errors['configdata[options]'] = get_string('errornotenoughoptions', 'customfield_multiselect'); + } else if (!empty($data['configdata']['defaultvalue'])) { + $defaultvalue = $data['configdata']['defaultvalue']; + foreach (explode(',', $defaultvalue) as $val) { + $defaultkey = array_search($val, $options); + if ($defaultkey === false) { + $errors['configdata[defaultvalue]'] = get_string('errordefaultvaluenotinlist', + 'customfield_multiselect', $val); + break; + } + } + } + return $errors; + } +} diff --git a/field/multiselect/classes/privacy/provider.php b/field/multiselect/classes/privacy/provider.php new file mode 100644 index 0000000..36626d3 --- /dev/null +++ b/field/multiselect/classes/privacy/provider.php @@ -0,0 +1,84 @@ +. +/** + * Privacy provider multiselect Type + * + * @package customfield_multiselect + * @copyright 2020 CALL Learning 2020 - Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace customfield_multiselect\privacy; + +use core_customfield\data_controller; +use core_customfield\privacy\customfield_provider; +use core_privacy\local\request\writer; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Privacy Subsystem for customfield_multiselect implementing null_provider. + * + * @package customfield_multiselect + * @copyright 2020 CALL Learning 2020 - Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider, customfield_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason() : string { + return 'privacy:metadata'; + } + + /** + * Preprocesses data object that is going to be exported + * + * @param data_controller $data + * @param \stdClass $exportdata + * @param array $subcontext + */ + public static function export_customfield_data(data_controller $data, \stdClass $exportdata, array $subcontext) { + $context = $data->get_context(); + $exportdata->value = $data->export_value(); + writer::with_context($context) + ->export_data($subcontext, $exportdata); + } + + /** + * Allows plugins to delete everything they store related to the data (usually files) + * + * @param string $dataidstest + * @param array $params + * @param array $contextids + * @return mixed|void + */ + public static function before_delete_data(string $dataidstest, array $params, array $contextids) { + } + + /** + * Allows plugins to delete everything they store related to the field configuration (usually files) + * + * @param string $fieldidstest + * @param array $params + * @param array $contextids + */ + public static function before_delete_fields(string $fieldidstest, array $params, array $contextids) { + } +} diff --git a/lang/en/customfield_multiselect.php b/field/multiselect/lang/en/customfield_multiselect.php similarity index 93% rename from lang/en/customfield_multiselect.php rename to field/multiselect/lang/en/customfield_multiselect.php index 02cfdb3..b6ff26e 100644 --- a/lang/en/customfield_multiselect.php +++ b/field/multiselect/lang/en/customfield_multiselect.php @@ -30,6 +30,8 @@ $string['invalidoption'] = 'Invalid option selected'; $string['menuoptions'] = 'Menu options (one per line)'; $string['defaultvalue'] = 'Default value'; +$string['defaultvalue_help'] = 'Default value as comma separated values'; +$string['clear'] = 'Clear'; $string['pluginname'] = 'Multiselect menu'; $string['privacy:metadata'] = 'The Multiselect menu field type plugin doesn\'t store any personal data; it uses tables defined in core.'; $string['specificsettings'] = 'Multiselect menu field settings'; diff --git a/tests/behat/field.feature b/field/multiselect/tests/behat/field.feature similarity index 100% rename from tests/behat/field.feature rename to field/multiselect/tests/behat/field.feature diff --git a/tests/plugin_test.php b/field/multiselect/tests/plugin_test.php similarity index 86% rename from tests/plugin_test.php rename to field/multiselect/tests/plugin_test.php index 6f07baf..29c5fb7 100644 --- a/tests/plugin_test.php +++ b/field/multiselect/tests/plugin_test.php @@ -167,58 +167,20 @@ public function test_instance_form_values() { * Test for data_controller::get_value and export_value */ public function test_get_export_value() { - $this->assertEquals("0", $this->cfdata[1]->get_value()); + $this->assertEquals([0], $this->cfdata[1]->get_value()); $this->assertEquals('a', $this->cfdata[1]->export_value()); // Field without data but with a default value. $d = core_customfield\data_controller::create(0, null, $this->cfields[3]); - $this->assertEquals("1", $d->get_value()); + $this->assertEquals([1], $d->get_value()); $this->assertEquals('b', $d->export_value()); // Field without data but with a default value. $d = core_customfield\data_controller::create(0, null, $this->cfields[4]); - $this->assertEquals("1,2", $d->get_value()); + $this->assertEquals([1, 2], $d->get_value()); $this->assertEquals('b, c', $d->export_value()); } - - /** - * Data provider for {@see test_parse_value} - * - * @return array - */ - public function parse_value_provider() : array { - return [ - ['Red', "0"], - ['Blue|Green', "1,2"], - ['Green| red', "0,2"], - ['Mauve', ""], - ]; - } - - /** - * Test field parse_value method - * - * @param string $value - * @param int $expected - * @return void - * - * @dataProvider parse_value_provider - */ - public function test_parse_value(string $value, string $expected) { - $generator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); - $field = $generator->create_field([ - 'categoryid' => $this->cfcat->get('id'), - 'type' => 'multiselect', - 'shortname' => 'mymultiselect', - 'configdata' => [ - 'options' => "Red\nBlue\nGreen", - ], - ]); - - $this->assertSame($expected, $field->parse_value($value)); - } - /** * Deleting fields and data */ diff --git a/version.php b/field/multiselect/version.php similarity index 91% rename from version.php rename to field/multiselect/version.php index 23a6587..4f35c17 100644 --- a/version.php +++ b/field/multiselect/version.php @@ -26,5 +26,7 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'customfield_multiselect'; -$plugin->version = 2021090700; +$plugin->version = 2019052000; $plugin->requires = 2019051100; +$plugin->release = '1.0.0'; +$plugin->maturity = MATURITY_BETA; \ No newline at end of file diff --git a/field/select/classes/data_controller.php b/field/select/classes/data_controller.php new file mode 100644 index 0000000..73116b7 --- /dev/null +++ b/field/select/classes/data_controller.php @@ -0,0 +1,128 @@ +. + +/** + * Select plugin data controller + * + * @package customfield_select + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace customfield_select; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class data + * + * @package customfield_select + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class data_controller extends \core_customfield\data_controller { + + /** + * Return the name of the field where the information is stored + * @return string + */ + public function datafield() : string { + return 'intvalue'; + } + + /** + * Returns the default value as it would be stored in the database (not in human-readable format). + * + * @return mixed + */ + public function get_default_value() { + $defaultvalue = $this->get_field()->get_configdata_property('defaultvalue'); + if ('' . $defaultvalue !== '') { + $key = array_search($defaultvalue, $this->get_field()->get_options()); + if ($key !== false) { + return $key; + } + } + return 0; + } + + /** + * Add fields for editing a textarea field. + * + * @param \MoodleQuickForm $mform + */ + public function instance_form_definition(\MoodleQuickForm $mform) { + $field = $this->get_field(); + $config = $field->get('configdata'); + $options = $field->get_options(); + $formattedoptions = array(); + $context = $this->get_field()->get_handler()->get_configuration_context(); + foreach ($options as $key => $option) { + // Multilang formatting with filters. + $formattedoptions[$key] = format_string($option, true, ['context' => $context]); + } + + $elementname = $this->get_form_element_name(); + $mform->addElement('select', $elementname, $this->get_field()->get_formatted_name(), $formattedoptions); + + if (($defaultkey = array_search($config['defaultvalue'], $options)) !== false) { + $mform->setDefault($elementname, $defaultkey); + } + if ($field->get_configdata_property('required')) { + $mform->addRule($elementname, null, 'required', null, 'client'); + } + } + + /** + * Validates data for this field. + * + * @param array $data + * @param array $files + * @return array + */ + public function instance_form_validation(array $data, array $files) : array { + $errors = parent::instance_form_validation($data, $files); + if ($this->get_field()->get_configdata_property('required')) { + // Standard required rule does not work on select element. + $elementname = $this->get_form_element_name(); + if (empty($data[$elementname])) { + $errors[$elementname] = get_string('err_required', 'form'); + } + } + return $errors; + } + + /** + * Returns value in a human-readable format + * + * @return mixed|null value or null if empty + */ + public function export_value() { + $value = $this->get_value(); + + if ($this->is_empty($value)) { + return null; + } + + $options = $this->get_field()->get_options(); + if (array_key_exists($value, $options)) { + return format_string($options[$value], true, + ['context' => $this->get_field()->get_handler()->get_configuration_context()]); + } + + return null; + } +} diff --git a/field/select/classes/field_controller.php b/field/select/classes/field_controller.php new file mode 100644 index 0000000..6f198f7 --- /dev/null +++ b/field/select/classes/field_controller.php @@ -0,0 +1,146 @@ +. + +/** + * Class field + * + * @package customfield_select + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace customfield_select; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class field + * + * @package customfield_select + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class field_controller extends \core_customfield\field_controller { + /** + * Customfield type + */ + const TYPE = 'select'; + + /** + * Add fields for editing a select field. + * + * @param \MoodleQuickForm $mform + */ + public function config_form_definition(\MoodleQuickForm $mform) { + $mform->addElement('header', 'header_specificsettings', get_string('specificsettings', 'customfield_select')); + $mform->setExpanded('header_specificsettings', true); + + $mform->addElement('textarea', 'configdata[options]', get_string('menuoptions', 'customfield_select')); + $mform->setType('configdata[options]', PARAM_TEXT); + + $mform->addElement('text', 'configdata[defaultvalue]', get_string('defaultvalue', 'core_customfield'), 'size="50"'); + $mform->setType('configdata[defaultvalue]', PARAM_TEXT); + } + + /** + * Returns the options available as an array. + * + * @param \core_customfield\field_controller $field + * @return array + * + * @deprecated since Moodle 3.10 - MDL-68569 please use $field->get_options + */ + public static function get_options_array(\core_customfield\field_controller $field) : array { + debugging('get_options_array() is deprecated, please use $field->get_options() instead', DEBUG_DEVELOPER); + + return $field->get_options(); + } + + /** + * Return configured field options + * + * @return array + */ + public function get_options(): array { + $optionconfig = $this->get_configdata_property('options'); + if ($optionconfig) { + $options = preg_split("/\s*\n\s*/", trim($optionconfig)); + } else { + $options = array(); + } + return array_merge([''], $options); + } + + /** + * Validate the data from the config form. + * Sub classes must reimplement it. + * + * @param array $data from the add/edit profile field form + * @param array $files + * @return array associative array of error messages + */ + public function config_form_validation(array $data, $files = array()) : array { + $options = preg_split("/\s*\n\s*/", trim($data['configdata']['options'])); + $errors = []; + if (!$options || count($options) < 2) { + $errors['configdata[options]'] = get_string('errornotenoughoptions', 'customfield_select'); + } else if (!empty($data['configdata']['defaultvalue'])) { + $defaultkey = array_search($data['configdata']['defaultvalue'], $options); + if ($defaultkey === false) { + $errors['configdata[defaultvalue]'] = get_string('errordefaultvaluenotinlist', 'customfield_select'); + } + } + return $errors; + } + + /** + * Does this custom field type support being used as part of the block_myoverview + * custom field grouping? + * @return bool + */ + public function supports_course_grouping(): bool { + return true; + } + + /** + * If this field supports course grouping, then this function needs overriding to + * return the formatted values for this. + * @param array $values the used values that need formatting + * @return array + */ + public function course_grouping_format_values($values): array { + $options = $this->get_options(); + $ret = []; + foreach ($values as $value) { + if (isset($options[$value])) { + $ret[$value] = format_string($options[$value]); + } + } + $ret[BLOCK_MYOVERVIEW_CUSTOMFIELD_EMPTY] = get_string('nocustomvalue', 'block_myoverview', + $this->get_formatted_name()); + return $ret; + } + + /** + * Locate the value parameter in the field options array, and return it's index + * + * @param string $value + * @return int + */ + public function parse_value(string $value) { + return (int) array_search($value, $this->get_options()); + } +} \ No newline at end of file diff --git a/field/select/classes/privacy/provider.php b/field/select/classes/privacy/provider.php new file mode 100644 index 0000000..e1c5547 --- /dev/null +++ b/field/select/classes/privacy/provider.php @@ -0,0 +1,83 @@ +. +/** + * Privacy Subsystem implementation for customfield_select. + * + * @package customfield_select + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace customfield_select\privacy; + +use core_customfield\data_controller; +use core_customfield\privacy\customfield_provider; +use core_privacy\local\request\writer; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Privacy Subsystem for customfield_select implementing null_provider. + * + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider, customfield_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason() : string { + return 'privacy:metadata'; + } + + /** + * Preprocesses data object that is going to be exported + * + * @param data_controller $data + * @param \stdClass $exportdata + * @param array $subcontext + */ + public static function export_customfield_data(data_controller $data, \stdClass $exportdata, array $subcontext) { + $context = $data->get_context(); + $exportdata->value = $data->export_value(); + writer::with_context($context) + ->export_data($subcontext, $exportdata); + } + + /** + * Allows plugins to delete everything they store related to the data (usually files) + * + * @param string $dataidstest + * @param array $params + * @param array $contextids + * @return mixed|void + */ + public static function before_delete_data(string $dataidstest, array $params, array $contextids) { + } + + /** + * Allows plugins to delete everything they store related to the field configuration (usually files) + * + * @param string $fieldidstest + * @param array $params + * @param array $contextids + */ + public static function before_delete_fields(string $fieldidstest, array $params, array $contextids) { + } +} diff --git a/field/select/lang/en/customfield_select.php b/field/select/lang/en/customfield_select.php new file mode 100644 index 0000000..52f9809 --- /dev/null +++ b/field/select/lang/en/customfield_select.php @@ -0,0 +1,33 @@ +. + +/** + * Customfield text field plugin strings + * + * @package customfield_select + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$string['errordefaultvaluenotinlist'] = 'The default value must be one of the options from the list above.'; +$string['errornotenoughoptions'] = 'Please provide at least two options, with each on a new line.'; +$string['invalidoption'] = 'Invalid option selected'; +$string['menuoptions'] = 'Menu options (one per line)'; +$string['pluginname'] = 'Dropdown menu'; +$string['privacy:metadata'] = 'The Dropdown menu field type plugin doesn\'t store any personal data; it uses tables defined in core.'; +$string['specificsettings'] = 'Dropdown menu field settings'; diff --git a/field/select/tests/behat/field.feature b/field/select/tests/behat/field.feature new file mode 100644 index 0000000..9adb84e --- /dev/null +++ b/field/select/tests/behat/field.feature @@ -0,0 +1,85 @@ +@customfield @customfield_select @javascript +Feature: Managers can manage course custom fields select + In order to have additional data on the course + As a manager + I need to create, edit, remove and sort custom fields + + Background: + Given the following "custom field categories" exist: + | name | component | area | itemid | + | Category for test | core_course | course | 0 | + And I log in as "admin" + And I navigate to "Courses > Course custom fields" in site administration + + Scenario: Create a custom course select field + When I click on "Add a new custom field" "link" + And I click on "Dropdown menu" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + And I set the field "Menu options (one per line)" to multiline: + """ + a + b + """ + And I click on "Save changes" "button" in the "Adding a new Dropdown menu" "dialogue" + Then I should see "Test field" + And I log out + + Scenario: Edit a custom course select field + When I click on "Add a new custom field" "link" + And I click on "Dropdown menu" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + And I set the field "Menu options (one per line)" to multiline: + """ + a + b + """ + And I click on "Save changes" "button" in the "Adding a new Dropdown menu" "dialogue" + And I click on "Edit" "link" in the "Test field" "table_row" + And I set the following fields to these values: + | Name | Edited field | + And I click on "Save changes" "button" in the "Updating Test field" "dialogue" + Then I should see "Edited field" + And I should not see "Test field" + And I log out + + Scenario: Delete a custom course select field + When I click on "Add a new custom field" "link" + And I click on "Dropdown menu" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + And I set the field "Menu options (one per line)" to multiline: + """ + a + b + """ + And I click on "Save changes" "button" in the "Adding a new Dropdown menu" "dialogue" + And I click on "Delete" "link" in the "Test field" "table_row" + And I click on "Yes" "button" in the "Confirm" "dialogue" + Then I should not see "Test field" + And I log out + + Scenario: Validation of custom course select field configuration + When I click on "Add a new custom field" "link" + And I click on "Dropdown menu" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + And I click on "Save changes" "button" in the "Adding a new Dropdown menu" "dialogue" + And I should see "Please provide at least two options, with each on a new line." in the "Menu options (one per line)" "form_row" + And I set the field "Menu options (one per line)" to multiline: + """ + a + b + """ + And I set the field "Default value" to "c" + And I click on "Save changes" "button" in the "Adding a new Dropdown menu" "dialogue" + And I should see "The default value must be one of the options from the list above" in the "Default value" "form_row" + And I set the field "Default value" to "b" + And I click on "Save changes" "button" in the "Adding a new Dropdown menu" "dialogue" + And "testfield" "text" should exist in the "Test field" "table_row" + And I log out diff --git a/field/select/tests/plugin_test.php b/field/select/tests/plugin_test.php new file mode 100644 index 0000000..97194ef --- /dev/null +++ b/field/select/tests/plugin_test.php @@ -0,0 +1,195 @@ +. + +namespace customfield_select; + +use core_customfield_generator; +use core_customfield_test_instance_form; + +/** + * Functional test for customfield_select + * + * @package customfield_select + * @copyright 2019 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class plugin_test extends \advanced_testcase { + + /** @var stdClass[] */ + private $courses = []; + /** @var \core_customfield\category_controller */ + private $cfcat; + /** @var \core_customfield\field_controller[] */ + private $cfields; + /** @var \core_customfield\data_controller[] */ + private $cfdata; + + /** + * Tests set up. + */ + public function setUp(): void { + $this->resetAfterTest(); + + $this->cfcat = $this->get_generator()->create_category(); + + $this->cfields[1] = $this->get_generator()->create_field( + ['categoryid' => $this->cfcat->get('id'), 'shortname' => 'myfield1', 'type' => 'select', + 'configdata' => ['options' => "a\nb\nc"]]); + $this->cfields[2] = $this->get_generator()->create_field( + ['categoryid' => $this->cfcat->get('id'), 'shortname' => 'myfield2', 'type' => 'select', + 'configdata' => ['required' => 1, 'options' => "a\nb\nc"]]); + $this->cfields[3] = $this->get_generator()->create_field( + ['categoryid' => $this->cfcat->get('id'), 'shortname' => 'myfield3', 'type' => 'select', + 'configdata' => ['defaultvalue' => 'b', 'options' => "a\nb\nc"]]); + + $this->courses[1] = $this->getDataGenerator()->create_course(); + $this->courses[2] = $this->getDataGenerator()->create_course(); + $this->courses[3] = $this->getDataGenerator()->create_course(); + + $this->cfdata[1] = $this->get_generator()->add_instance_data($this->cfields[1], $this->courses[1]->id, 1); + $this->cfdata[2] = $this->get_generator()->add_instance_data($this->cfields[1], $this->courses[2]->id, 1); + + $this->setUser($this->getDataGenerator()->create_user()); + } + + /** + * Get generator + * @return core_customfield_generator + */ + protected function get_generator() : core_customfield_generator { + return $this->getDataGenerator()->get_plugin_generator('core_customfield'); + } + + /** + * Test for initialising field and data controllers + */ + public function test_initialise() { + $f = \core_customfield\field_controller::create($this->cfields[1]->get('id')); + $this->assertTrue($f instanceof field_controller); + + $f = \core_customfield\field_controller::create(0, (object)['type' => 'select'], $this->cfcat); + $this->assertTrue($f instanceof field_controller); + + $d = \core_customfield\data_controller::create($this->cfdata[1]->get('id')); + $this->assertTrue($d instanceof data_controller); + + $d = \core_customfield\data_controller::create(0, null, $this->cfields[1]); + $this->assertTrue($d instanceof data_controller); + } + + /** + * Test for configuration form functions + * + * Create a configuration form and submit it with the same values as in the field + */ + public function test_config_form() { + $this->setAdminUser(); + $submitdata = (array)$this->cfields[1]->to_record(); + $submitdata['configdata'] = $this->cfields[1]->get('configdata'); + + $submitdata = \core_customfield\field_config_form::mock_ajax_submit($submitdata); + $form = new \core_customfield\field_config_form(null, null, 'post', '', null, true, + $submitdata, true); + $form->set_data_for_dynamic_submission(); + $this->assertTrue($form->is_validated()); + $form->process_dynamic_submission(); + } + + /** + * Test for instance form functions + */ + public function test_instance_form() { + global $CFG; + require_once($CFG->dirroot . '/customfield/tests/fixtures/test_instance_form.php'); + $this->setAdminUser(); + $handler = $this->cfcat->get_handler(); + + // First try to submit without required field. + $submitdata = (array)$this->courses[1]; + core_customfield_test_instance_form::mock_submit($submitdata, []); + $form = new core_customfield_test_instance_form('POST', + ['handler' => $handler, 'instance' => $this->courses[1]]); + $this->assertFalse($form->is_validated()); + + // Now with required field. + $submitdata['customfield_myfield2'] = 1; + core_customfield_test_instance_form::mock_submit($submitdata, []); + $form = new core_customfield_test_instance_form('POST', + ['handler' => $handler, 'instance' => $this->courses[1]]); + $this->assertTrue($form->is_validated()); + + $data = $form->get_data(); + $this->assertNotEmpty($data->customfield_myfield1); + $this->assertNotEmpty($data->customfield_myfield2); + $handler->instance_form_save($data); + } + + /** + * Test for data_controller::get_value and export_value + */ + public function test_get_export_value() { + $this->assertEquals(1, $this->cfdata[1]->get_value()); + $this->assertEquals('a', $this->cfdata[1]->export_value()); + + // Field without data but with a default value. + $d = \core_customfield\data_controller::create(0, null, $this->cfields[3]); + $this->assertEquals(2, $d->get_value()); + $this->assertEquals('b', $d->export_value()); + } + + /** + * Data provider for {@see test_parse_value} + * + * @return array + */ + public function parse_value_provider() : array { + return [ + ['Red', 1], + ['Blue', 2], + ['Green', 3], + ['Mauve', 0], + ]; + } + + /** + * Test field parse_value method + * + * @param string $value + * @param int $expected + * @return void + * + * @dataProvider parse_value_provider + */ + public function test_parse_value(string $value, int $expected) { + $field = $this->get_generator()->create_field([ + 'categoryid' => $this->cfcat->get('id'), + 'type' => 'select', + 'shortname' => 'myselect', + 'configdata' => [ + 'options' => "Red\nBlue\nGreen", + ], + ]); + + $this->assertSame($expected, $field->parse_value($value)); + } + + /** + * Deleting fields and data + */ + public function test_delete() { + $this->cfcat->get_handler()->delete_all(); + } +} diff --git a/field/select/version.php b/field/select/version.php new file mode 100644 index 0000000..15b0827 --- /dev/null +++ b/field/select/version.php @@ -0,0 +1,29 @@ +. + +/** + * Customfield Select Type + * + * @package customfield_select + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->component = 'customfield_select'; +$plugin->version = 2022041900; +$plugin->requires = 2022041200; diff --git a/field/text/classes/data_controller.php b/field/text/classes/data_controller.php new file mode 100644 index 0000000..5d1562b --- /dev/null +++ b/field/text/classes/data_controller.php @@ -0,0 +1,116 @@ +. + +/** + * Customfields text field plugin + * + * @package customfield_text + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace customfield_text; + +defined('MOODLE_INTERNAL') || die; + +use core_customfield\api; + +/** + * Class data + * + * @package customfield_text + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class data_controller extends \core_customfield\data_controller { + + /** + * Return the name of the field where the information is stored + * @return string + */ + public function datafield() : string { + return 'charvalue'; + } + + /** + * Add fields for editing a text field. + * + * @param \MoodleQuickForm $mform + */ + public function instance_form_definition(\MoodleQuickForm $mform) { + $field = $this->get_field(); + $config = $field->get('configdata'); + $type = $config['ispassword'] ? 'password' : 'text'; + $elementname = $this->get_form_element_name(); + $mform->addElement($type, $elementname, $this->get_field()->get_formatted_name(), 'size=' . (int)$config['displaysize']); + $mform->setType($elementname, PARAM_TEXT); + if (!empty($config['defaultvalue'])) { + $mform->setDefault($elementname, $config['defaultvalue']); + } + if ($field->get_configdata_property('required')) { + $mform->addRule($elementname, null, 'required', null, 'client'); + } + } + + /** + * Validates data for this field. + * + * @param array $data + * @param array $files + * @return array + */ + public function instance_form_validation(array $data, array $files) : array { + + $errors = parent::instance_form_validation($data, $files); + $maxlength = $this->get_field()->get_configdata_property('maxlength'); + $elementname = $this->get_form_element_name(); + if (($maxlength > 0) && ($maxlength < \core_text::strlen($data[$elementname]))) { + $errors[$elementname] = get_string('errormaxlength', 'customfield_text', $maxlength); + } + return $errors; + } + + /** + * Returns the default value as it would be stored in the database (not in human-readable format). + * + * @return mixed + */ + public function get_default_value() { + return $this->get_field()->get_configdata_property('defaultvalue'); + } + + /** + * Returns value in a human-readable format + * + * @return mixed|null value or null if empty + */ + public function export_value() { + $value = parent::export_value(); + if ($value === null) { + return null; + } + + $link = $this->get_field()->get_configdata_property('link'); + if ($link) { + $linktarget = $this->get_field()->get_configdata_property('linktarget'); + $url = str_replace('$$', urlencode($this->get_value()), $link); + $attributes = $linktarget ? ['target' => $linktarget] : []; + $value = \html_writer::link($url, $value, $attributes); + } + + return $value; + } +} diff --git a/field/text/classes/field_controller.php b/field/text/classes/field_controller.php new file mode 100644 index 0000000..0a71d05 --- /dev/null +++ b/field/text/classes/field_controller.php @@ -0,0 +1,152 @@ +. + +/** + * Customfields text plugin + * + * @package customfield_text + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace customfield_text; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class field + * + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package customfield_text + */ +class field_controller extends \core_customfield\field_controller { + /** + * Plugin type text + */ + const TYPE = 'text'; + + /** + * Add fields for editing a text field. + * + * @param \MoodleQuickForm $mform + */ + public function config_form_definition(\MoodleQuickForm $mform) { + + $mform->addElement('header', 'header_specificsettings', get_string('specificsettings', 'customfield_text')); + $mform->setExpanded('header_specificsettings', true); + + $mform->addElement('text', 'configdata[defaultvalue]', get_string('defaultvalue', 'core_customfield'), + ['size' => 50]); + $mform->setType('configdata[defaultvalue]', PARAM_TEXT); + + $mform->addElement('text', 'configdata[displaysize]', get_string('displaysize', 'customfield_text'), ['size' => 6]); + $mform->setType('configdata[displaysize]', PARAM_INT); + if (!$this->get_configdata_property('displaysize')) { + $mform->setDefault('configdata[displaysize]', 50); + } + $mform->addRule('configdata[displaysize]', null, 'numeric', null, 'client'); + + $mform->addElement('text', 'configdata[maxlength]', get_string('maxlength', 'customfield_text'), ['size' => 6]); + $mform->setType('configdata[maxlength]', PARAM_INT); + if (!$this->get_configdata_property('maxlength')) { + $mform->setDefault('configdata[maxlength]', 1333); + } + $mform->addRule('configdata[maxlength]', null, 'numeric', null, 'client'); + + $mform->addElement('selectyesno', 'configdata[ispassword]', get_string('ispassword', 'customfield_text')); + $mform->setType('configdata[ispassword]', PARAM_INT); + + $mform->addElement('text', 'configdata[link]', get_string('islink', 'customfield_text'), ['size' => 50]); + $mform->setType('configdata[link]', PARAM_RAW_TRIMMED); + $mform->addHelpButton('configdata[link]', 'islink', 'customfield_text'); + + $mform->disabledIf('configdata[link]', 'configdata[ispassword]', 'eq', 1); + + $linkstargetoptions = array( + '' => get_string('none', 'customfield_text'), + '_blank' => get_string('newwindow', 'customfield_text'), + '_self' => get_string('sameframe', 'customfield_text'), + '_top' => get_string('samewindow', 'customfield_text') + ); + $mform->addElement('select', 'configdata[linktarget]', get_string('linktarget', 'customfield_text'), + $linkstargetoptions); + + $mform->disabledIf('configdata[linktarget]', 'configdata[link]', 'eq', ''); + } + + /** + * Validate the data on the field configuration form + * + * @param array $data from the add/edit profile field form + * @param array $files + * @return array associative array of error messages + */ + public function config_form_validation(array $data, $files = array()) : array { + global $CFG; + $errors = parent::config_form_validation($data, $files); + + $maxlength = (int)$data['configdata']['maxlength']; + if ($maxlength < 1 || $maxlength > 1333) { + $errors['configdata[maxlength]'] = get_string('errorconfigmaxlen', 'customfield_text'); + } + + $displaysize = (int)$data['configdata']['displaysize']; + if ($displaysize < 1 || $displaysize > 200) { + $errors['configdata[displaysize]'] = get_string('errorconfigdisplaysize', 'customfield_text'); + } + + if (isset($data['configdata']['link'])) { + $link = $data['configdata']['link']; + if (strlen($link)) { + require_once($CFG->dirroot . '/lib/validateurlsyntax.php'); + if (strpos($link, '$$') === false) { + $errors['configdata[link]'] = get_string('errorconfiglinkplaceholder', 'customfield_text'); + } else if (!validateUrlSyntax(str_replace('$$', 'XYZ', $link), 's+H?S?F-E-u-P-a?I?p?f?q?r?')) { + // This validation is more strict than PARAM_URL - it requires the protocol and it must be either http or https. + $errors['configdata[link]'] = get_string('errorconfiglinksyntax', 'customfield_text'); + } + } + } + + return $errors; + } + + /** + * Does this custom field type support being used as part of the block_myoverview + * custom field grouping? + * @return bool + */ + public function supports_course_grouping(): bool { + return true; + } + + /** + * If this field supports course grouping, then this function needs overriding to + * return the formatted values for this. + * @param array $values the used values that need formatting + * @return array + */ + public function course_grouping_format_values($values): array { + $ret = []; + foreach ($values as $value) { + $ret[$value] = format_string($value); + } + $ret[BLOCK_MYOVERVIEW_CUSTOMFIELD_EMPTY] = get_string('nocustomvalue', 'block_myoverview', + $this->get_formatted_name()); + return $ret; + } +} diff --git a/field/text/classes/privacy/provider.php b/field/text/classes/privacy/provider.php new file mode 100644 index 0000000..5937145 --- /dev/null +++ b/field/text/classes/privacy/provider.php @@ -0,0 +1,84 @@ +. +/** + * Privacy Subsystem implementation for customfield_text. + * + * @package customfield_text + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace customfield_text\privacy; + +use core_customfield\data_controller; +use core_customfield\privacy\customfield_provider; +use core_privacy\local\request\writer; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Privacy Subsystem for customfield_text implementing null_provider. + * + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider, customfield_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason() : string { + return 'privacy:metadata'; + } + + /** + * Preprocesses data object that is going to be exported + * + * @param data_controller $data + * @param \stdClass $exportdata + * @param array $subcontext + */ + public static function export_customfield_data(data_controller $data, \stdClass $exportdata, array $subcontext) { + $context = $data->get_context(); + // For text fields we want to apply format_string even to raw value to avoid CSS. + $exportdata->{$data->datafield()} = $data->export_value(); + writer::with_context($context) + ->export_data($subcontext, $exportdata); + } + + /** + * Allows plugins to delete everything they store related to the data (usually files) + * + * @param string $dataidstest + * @param array $params + * @param array $contextids + * @return mixed|void + */ + public static function before_delete_data(string $dataidstest, array $params, array $contextids) { + } + + /** + * Allows plugins to delete everything they store related to the field configuration (usually files) + * + * @param string $fieldidstest + * @param array $params + * @param array $contextids + */ + public static function before_delete_fields(string $fieldidstest, array $params, array $contextids) { + } +} diff --git a/field/text/lang/en/customfield_text.php b/field/text/lang/en/customfield_text.php new file mode 100644 index 0000000..e878d29 --- /dev/null +++ b/field/text/lang/en/customfield_text.php @@ -0,0 +1,44 @@ +. + +/** + * Customfield text plugin + * + * @package customfield_text + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$string['displaysize'] = 'Form input size'; +$string['errorconfigdisplaysize'] = 'The form input size must be between 1 and 200 characters.'; +$string['errorconfiglinkplaceholder'] = 'The link must contain a placeholder $$.'; +$string['errorconfiglinksyntax'] = 'The link must be a valid URL starting with either http:// or https://.'; +$string['errorconfigmaxlen'] = 'The maximum number of characters allowed must be between 1 and 1333.'; +$string['errormaxlength'] = 'The maximum number of characters allowed in this field is {$a}.'; +$string['islink'] = 'Link field'; +$string['islink_help'] = 'To transform the text into a link, enter a URL containing $$ as a placeholder, where $$ will be replaced with the text. For example, to transform a Twitter ID to a link, enter https://twitter.com/$$.'; +$string['ispassword'] = 'Password field'; +$string['linktarget'] = 'Link target'; +$string['maxlength'] = 'Maximum number of characters'; +$string['newwindow'] = 'New window'; +$string['none'] = 'None'; +$string['pluginname'] = 'Short text'; +$string['privacy:metadata'] = 'The Short text field type plugin doesn\'t store any personal data; it uses tables defined in core.'; +$string['sameframe'] = 'Same frame'; +$string['samewindow'] = 'Same window'; +$string['specificsettings'] = 'Short text field settings'; diff --git a/field/text/tests/behat/field.feature b/field/text/tests/behat/field.feature new file mode 100644 index 0000000..3e27af5 --- /dev/null +++ b/field/text/tests/behat/field.feature @@ -0,0 +1,140 @@ +@customfield @customfield_text @javascript +Feature: Managers can manage course custom fields text + In order to have additional data on the course + As a manager + I need to create, edit, remove and sort custom fields + + Background: + Given the following "custom field categories" exist: + | name | component | area | itemid | + | Category for test | core_course | course | 0 | + And I log in as "admin" + And I navigate to "Courses > Course custom fields" in site administration + + Scenario: Create a custom course text field + When I click on "Add a new custom field" "link" + And I click on "Short text" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue" + Then I should see "Test field" + And I log out + + Scenario: Edit a custom course text field + When I click on "Add a new custom field" "link" + And I click on "Short text" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue" + And I click on "Edit" "link" in the "Test field" "table_row" + And I set the following fields to these values: + | Name | Edited field | + And I click on "Save changes" "button" in the "Updating Test field" "dialogue" + Then I should see "Edited field" + And I navigate to "Reports > Logs" in site administration + And I press "Get these logs" + And I log out + + Scenario: Delete a custom course text field + When I click on "Add a new custom field" "link" + And I click on "Short text" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue" + And I click on "Delete" "link" in the "Test field" "table_row" + And I click on "Yes" "button" in the "Confirm" "dialogue" + And I wait until the page is ready + And I wait until "Test field" "text" does not exist + Then I should not see "Test field" + And I log out + + Scenario: A text field with a link setting must show link on course listing + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | Example 1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | topics | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + And I navigate to "Courses > Course custom fields" in site administration + And I click on "Add a new custom field" "link" + And I click on "Short text" "link" + And I set the following fields to these values: + | Name | See more on website | + | Short name | testfield | + | Visible to | Everyone | + | Link | https://www.moodle.org/$$ | + And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue" + And I log out + Then I log in as "teacher1" + And I am on "Course 1" course homepage + And I navigate to "Settings" in current page administration + And I set the following fields to these values: + | See more on website | course/view.php?id=35 | + And I press "Save and display" + And I am on site homepage + Then I should see "course/view.php?id=35" in the ".customfields-container .customfieldvalue a" "css_element" + Then I should see "See more on website" in the ".customfields-container .customfieldname" "css_element" + + Scenario: A text field with a max length must validate it on course edit form + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | Example 1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | topics | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + And I navigate to "Courses > Course custom fields" in site administration + And I click on "Add a new custom field" "link" + And I click on "Short text" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + | Maximum number of characters | 3 | + And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue" + And I log out + Then I log in as "teacher1" + And I am on "Course 1" course homepage + And I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Test field | 1234 | + And I press "Save and display" + Then I should see "The maximum number of characters allowed in this field is 3." + + Scenario: A text field with a default value must be shown on listing but allow empty values that will not be shown + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | Example 1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | topics | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + And I navigate to "Courses > Course custom fields" in site administration + And I click on "Add a new custom field" "link" + And I click on "Short text" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + | Default value | testdefault | + And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue" + And I log out + Then I log in as "teacher1" + When I am on site homepage + Then I should see "Test field: testdefault" + When I am on "Course 1" course homepage + And I navigate to "Settings" in current page administration + Then the "value" attribute of "#id_customfield_testfield" "css_element" should contain "testdefault" + When I set the following fields to these values: + | Test field | | + And I press "Save and display" + And I am on site homepage + And I should not see "Test field" diff --git a/field/text/tests/plugin_test.php b/field/text/tests/plugin_test.php new file mode 100644 index 0000000..9898aee --- /dev/null +++ b/field/text/tests/plugin_test.php @@ -0,0 +1,169 @@ +. + +namespace customfield_text; + +use core_customfield_generator; +use core_customfield_test_instance_form; + +/** + * Functional test for customfield_text + * + * @package customfield_text + * @copyright 2019 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class plugin_test extends \advanced_testcase { + + /** @var stdClass[] */ + private $courses = []; + /** @var \core_customfield\category_controller */ + private $cfcat; + /** @var \core_customfield\field_controller[] */ + private $cfields; + /** @var \core_customfield\data_controller[] */ + private $cfdata; + + /** + * Tests set up. + */ + public function setUp(): void { + $this->resetAfterTest(); + + $this->cfcat = $this->get_generator()->create_category(); + + $this->cfields[1] = $this->get_generator()->create_field( + ['categoryid' => $this->cfcat->get('id'), 'shortname' => 'myfield1', 'type' => 'text', + 'configdata' => ['maxlength' => 30, 'displaysize' => 50]]); + $this->cfields[2] = $this->get_generator()->create_field( + ['categoryid' => $this->cfcat->get('id'), 'shortname' => 'myfield2', 'type' => 'text', + 'configdata' => ['required' => 1, 'maxlength' => 30, 'displaysize' => 50]]); + $this->cfields[3] = $this->get_generator()->create_field( + ['categoryid' => $this->cfcat->get('id'), 'shortname' => 'myfield3', 'type' => 'text', + 'configdata' => ['defaultvalue' => 'Defvalue', 'maxlength' => 30, 'displaysize' => 50]]); + $this->cfields[4] = $this->get_generator()->create_field( + ['categoryid' => $this->cfcat->get('id'), 'shortname' => 'myfield4', 'type' => 'text', + 'configdata' => ['link' => 'https://twitter.com/$$', 'maxlength' => 30, 'displaysize' => 50]]); + + $this->courses[1] = $this->getDataGenerator()->create_course(); + $this->courses[2] = $this->getDataGenerator()->create_course(); + $this->courses[3] = $this->getDataGenerator()->create_course(); + + $this->cfdata[1] = $this->get_generator()->add_instance_data($this->cfields[1], $this->courses[1]->id, + 'Value1'); + $this->cfdata[2] = $this->get_generator()->add_instance_data($this->cfields[1], $this->courses[2]->id, + 'Value2'); + + $this->setUser($this->getDataGenerator()->create_user()); + } + + /** + * Get generator + * @return core_customfield_generator + */ + protected function get_generator() : core_customfield_generator { + return $this->getDataGenerator()->get_plugin_generator('core_customfield'); + } + + /** + * Test for initialising field and data controllers + */ + public function test_initialise() { + $f = \core_customfield\field_controller::create($this->cfields[1]->get('id')); + $this->assertTrue($f instanceof field_controller); + + $f = \core_customfield\field_controller::create(0, (object)['type' => 'text'], $this->cfcat); + $this->assertTrue($f instanceof field_controller); + + $d = \core_customfield\data_controller::create($this->cfdata[1]->get('id')); + $this->assertTrue($d instanceof data_controller); + + $d = \core_customfield\data_controller::create(0, null, $this->cfields[1]); + $this->assertTrue($d instanceof data_controller); + } + + /** + * Test for configuration form functions + * + * Create a configuration form and submit it with the same values as in the field + */ + public function test_config_form() { + $this->setAdminUser(); + $submitdata = (array)$this->cfields[1]->to_record(); + $submitdata['configdata'] = $this->cfields[1]->get('configdata'); + + $submitdata = \core_customfield\field_config_form::mock_ajax_submit($submitdata); + $form = new \core_customfield\field_config_form(null, null, 'post', '', null, true, + $submitdata, true); + $form->set_data_for_dynamic_submission(); + $this->assertTrue($form->is_validated()); + $form->process_dynamic_submission(); + } + + /** + * Test for instance form functions + */ + public function test_instance_form() { + global $CFG; + require_once($CFG->dirroot . '/customfield/tests/fixtures/test_instance_form.php'); + $this->setAdminUser(); + $handler = $this->cfcat->get_handler(); + + // First try to submit without required field. + $submitdata = (array)$this->courses[1]; + core_customfield_test_instance_form::mock_submit($submitdata, []); + $form = new core_customfield_test_instance_form('POST', + ['handler' => $handler, 'instance' => $this->courses[1]]); + $this->assertFalse($form->is_validated()); + + // Now with required field. + $submitdata['customfield_myfield2'] = 'Some text'; + core_customfield_test_instance_form::mock_submit($submitdata, []); + $form = new core_customfield_test_instance_form('POST', + ['handler' => $handler, 'instance' => $this->courses[1]]); + $this->assertTrue($form->is_validated()); + + $data = $form->get_data(); + $this->assertNotEmpty($data->customfield_myfield1); + $this->assertNotEmpty($data->customfield_myfield2); + $handler->instance_form_save($data); + } + + /** + * Test for data_controller::get_value and export_value + */ + public function test_get_export_value() { + $this->assertEquals('Value1', $this->cfdata[1]->get_value()); + $this->assertEquals('Value1', $this->cfdata[1]->export_value()); + + // Field without data but with a default value. + $d = \core_customfield\data_controller::create(0, null, $this->cfields[3]); + $this->assertEquals('Defvalue', $d->get_value()); + $this->assertEquals('Defvalue', $d->export_value()); + + // Field with a link. + $d = $this->get_generator()->add_instance_data($this->cfields[4], $this->courses[1]->id, 'mynickname'); + $this->assertEquals('mynickname', $d->get_value()); + $this->assertEquals('mynickname', $d->export_value()); + } + + /** + * Deleting fields and data + */ + public function test_delete() { + $this->cfcat->get_handler()->delete_all(); + } +} diff --git a/field/text/version.php b/field/text/version.php new file mode 100644 index 0000000..1f9170a --- /dev/null +++ b/field/text/version.php @@ -0,0 +1,29 @@ +. + +/** + * Customfield text plugin + * + * @package customfield_text + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->component = 'customfield_text'; +$plugin->version = 2022041900; +$plugin->requires = 2022041200; diff --git a/field/textarea/classes/data_controller.php b/field/textarea/classes/data_controller.php new file mode 100644 index 0000000..64e62d1 --- /dev/null +++ b/field/textarea/classes/data_controller.php @@ -0,0 +1,189 @@ +. + +/** + * Customfields textarea plugin + * + * @package customfield_textarea + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace customfield_textarea; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class data + * + * @package customfield_textarea + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class data_controller extends \core_customfield\data_controller { + + /** + * Return the name of the field where the information is stored + * @return string + */ + public function datafield() : string { + return 'value'; + } + + /** + * Options for the editor + * + * @return array + */ + protected function value_editor_options() { + /** @var field_controller $field */ + $field = $this->get_field(); + return $field->value_editor_options($this->get('id') ? $this->get_context() : null); + } + + /** + * Returns the name of the field to be used on HTML forms. + * + * @return string + */ + public function get_form_element_name() : string { + return parent::get_form_element_name() . '_editor'; + } + + /** + * Add fields for editing a textarea field. + * + * @param \MoodleQuickForm $mform + */ + public function instance_form_definition(\MoodleQuickForm $mform) { + $field = $this->get_field(); + $desceditoroptions = $this->value_editor_options(); + $elementname = $this->get_form_element_name(); + $mform->addElement('editor', $elementname, $this->get_field()->get_formatted_name(), null, $desceditoroptions); + if ($field->get_configdata_property('required')) { + $mform->addRule($elementname, null, 'required', null, 'client'); + } + } + + /** + * Saves the data coming from form + * + * @param \stdClass $datanew data coming from the form + */ + public function instance_form_save(\stdClass $datanew) { + $fieldname = $this->get_form_element_name(); + if (!property_exists($datanew, $fieldname)) { + return; + } + $fromform = $datanew->$fieldname; + + if (!$this->get('id')) { + $this->data->set('value', ''); + $this->data->set('valueformat', FORMAT_MOODLE); + $this->save(); + } + + if (array_key_exists('text', $fromform)) { + $textoptions = $this->value_editor_options(); + $data = (object) ['field_editor' => $fromform]; + $data = file_postupdate_standard_editor($data, 'field', $textoptions, $textoptions['context'], + 'customfield_textarea', 'value', $this->get('id')); + $this->data->set('value', $data->field); + $this->data->set('valueformat', $data->fieldformat); + + $this->save(); + } + } + + /** + * Prepares the custom field data related to the object to pass to mform->set_data() and adds them to it + * + * This function must be called before calling $form->set_data($object); + * + * @param \stdClass $instance the entity that has custom fields, if 'id' attribute is present the custom + * fields for this entity will be added, otherwise the default values will be added. + */ + public function instance_form_before_set_data(\stdClass $instance) { + $textoptions = $this->value_editor_options(); + if ($this->get('id')) { + $text = $this->get('value'); + $format = $this->get('valueformat'); + $temp = (object)['field' => $text, 'fieldformat' => $format]; + file_prepare_standard_editor($temp, 'field', $textoptions, $textoptions['context'], 'customfield_textarea', + 'value', $this->get('id')); + $value = $temp->field_editor; + } else { + $text = $this->get_field()->get_configdata_property('defaultvalue'); + $format = $this->get_field()->get_configdata_property('defaultvalueformat'); + $temp = (object)['field' => $text, 'fieldformat' => $format]; + file_prepare_standard_editor($temp, 'field', $textoptions, $textoptions['context'], 'customfield_textarea', + 'defaultvalue', $this->get_field()->get('id')); + $value = $temp->field_editor; + } + $instance->{$this->get_form_element_name()} = $value; + } + + /** + * Delete data + * + * @return bool + */ + public function delete() { + get_file_storage()->delete_area_files($this->get('contextid'), 'customfield_textarea', + 'value', $this->get('id')); + return parent::delete(); + } + + /** + * Returns the default value as it would be stored in the database (not in human-readable format). + * + * @return mixed + */ + public function get_default_value() { + return $this->get_field()->get_configdata_property('defaultvalue'); + } + + /** + * Returns value in a human-readable format + * + * @return mixed|null value or null if empty + */ + public function export_value() { + global $CFG; + require_once($CFG->libdir . '/filelib.php'); + + $value = $this->get_value(); + if ($this->is_empty($value)) { + return null; + } + + if ($dataid = $this->get('id')) { + $context = $this->get_context(); + $processed = file_rewrite_pluginfile_urls($value, 'pluginfile.php', + $context->id, 'customfield_textarea', 'value', $dataid); + $value = format_text($processed, $this->get('valueformat'), ['context' => $context]); + } else { + $fieldid = $this->get_field()->get('id'); + $configcontext = $this->get_field()->get_handler()->get_configuration_context(); + $processed = file_rewrite_pluginfile_urls($value, 'pluginfile.php', + $configcontext->id, 'customfield_textarea', 'defaultvalue', $fieldid); + $valueformat = $this->get_field()->get_configdata_property('defaultvalueformat'); + $value = format_text($processed, $valueformat, ['context' => $configcontext]); + } + + return $value; + } +} diff --git a/field/textarea/classes/field_controller.php b/field/textarea/classes/field_controller.php new file mode 100644 index 0000000..d27f802 --- /dev/null +++ b/field/textarea/classes/field_controller.php @@ -0,0 +1,145 @@ +. + +/** + * Customfield textarea plugin + * + * @package customfield_textarea + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace customfield_textarea; + +defined('MOODLE_INTERNAL') || die; + +/** + * Class field + * + * @package customfield_textarea + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class field_controller extends \core_customfield\field_controller { + /** + * Const type + */ + const TYPE = 'textarea'; + + /** + * Before delete bulk actions + */ + public function delete() : bool { + global $DB; + $fs = get_file_storage(); + + // Delete files in the defaultvalue. + $fs->delete_area_files($this->get_handler()->get_configuration_context()->id, 'customfield_textarea', + 'defaultvalue', $this->get('id')); + + // Delete files in the data. We can not use $fs->delete_area_files_select() because context may be different. + $params = ['component' => 'customfield_textarea', 'filearea' => 'value', 'fieldid' => $this->get('id')]; + $where = "component = :component AND filearea = :filearea + AND itemid IN (SELECT cfd.id FROM {customfield_data} cfd WHERE cfd.fieldid = :fieldid)"; + $filerecords = $DB->get_recordset_select('files', $where, $params); + foreach ($filerecords as $filerecord) { + $fs->get_file_instance($filerecord)->delete(); + } + $filerecords->close(); + + // Delete data and field. + return parent::delete(); + } + + /** + * Prepare the field data to set in the configuration form + * + * Necessary if some preprocessing required for editor or filemanager fields + * + * @param \stdClass $formdata + */ + public function prepare_for_config_form(\stdClass $formdata) { + + if (!empty($formdata->configdata['defaultvalue'])) { + $textoptions = $this->value_editor_options(); + $context = $textoptions['context']; + + $record = new \stdClass(); + $record->defaultvalue = $formdata->configdata['defaultvalue']; + $record->defaultvalueformat = $formdata->configdata['defaultvalueformat']; + file_prepare_standard_editor($record, 'defaultvalue', $textoptions, $context, + 'customfield_textarea', 'defaultvalue', $formdata->id); + $formdata->configdata['defaultvalue_editor'] = $record->defaultvalue_editor; + } + } + + /** + * Add fields for editing a textarea field. + * + * @param \MoodleQuickForm $mform + */ + public function config_form_definition(\MoodleQuickForm $mform) { + $mform->addElement('header', 'header_specificsettings', get_string('specificsettings', 'customfield_textarea')); + $mform->setExpanded('header_specificsettings', true); + + $desceditoroptions = $this->value_editor_options(); + + $mform->addElement('editor', 'configdata[defaultvalue_editor]', get_string('defaultvalue', 'core_customfield'), + null, $desceditoroptions); + } + + /** + * Options for editor + * + * @param \context|null $context context if known, otherwise configuration context will be used + * @return array + */ + public function value_editor_options(\context $context = null) { + global $CFG; + require_once($CFG->libdir.'/formslib.php'); + if (!$context) { + $context = $this->get_handler()->get_configuration_context(); + } + return ['maxfiles' => EDITOR_UNLIMITED_FILES, 'maxbytes' => $CFG->maxbytes, 'context' => $context]; + } + + /** + * Saves the field configuration + */ + public function save() { + $configdata = $this->get('configdata'); + if (!array_key_exists('defaultvalue_editor', $configdata)) { + $this->field->save(); + return; + } + + if (!$this->get('id')) { + $this->field->save(); + } + + // Store files. + $textoptions = $this->value_editor_options(); + $tempvalue = (object) ['defaultvalue_editor' => $configdata['defaultvalue_editor']]; + $tempvalue = file_postupdate_standard_editor($tempvalue, 'defaultvalue', $textoptions, $textoptions['context'], + 'customfield_textarea', 'defaultvalue', $this->get('id')); + + $configdata['defaultvalue'] = $tempvalue->defaultvalue; + $configdata['defaultvalueformat'] = $tempvalue->defaultvalueformat; + unset($configdata['defaultvalue_editor']); + $this->field->set('configdata', json_encode($configdata)); + $this->field->save(); + } +} diff --git a/field/textarea/classes/privacy/provider.php b/field/textarea/classes/privacy/provider.php new file mode 100644 index 0000000..f8924c9 --- /dev/null +++ b/field/textarea/classes/privacy/provider.php @@ -0,0 +1,97 @@ +. +/** + * Privacy Subsystem implementation for customfield_textarea. + * + * @package customfield_textarea + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace customfield_textarea\privacy; + +use core_customfield\data_controller; +use core_customfield\privacy\customfield_provider; +use core_privacy\local\request\writer; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Privacy Subsystem for customfield_textarea implementing null_provider. + * + * @copyright 2018 Daniel Neis Araujo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider, customfield_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason() : string { + return 'privacy:metadata'; + } + + /** + * Preprocesses data object that is going to be exported + * + * @param data_controller $data + * @param \stdClass $exportdata + * @param array $subcontext + */ + public static function export_customfield_data(data_controller $data, \stdClass $exportdata, array $subcontext) { + $context = $data->get_context(); + $exportdata->value = writer::with_context($context) + ->rewrite_pluginfile_urls($subcontext, 'customfield_textarea', 'value', + $exportdata->id, $exportdata->value); + writer::with_context($context) + ->export_data($subcontext, $exportdata) + ->export_area_files($subcontext, 'customfield_textarea', 'value', $exportdata->id); + } + + /** + * Allows plugins to delete everything they store related to the data (usually files) + * + * @param string $dataidstest + * @param array $params + * @param array $contextids + * @return mixed|void + */ + public static function before_delete_data(string $dataidstest, array $params, array $contextids) { + $fs = get_file_storage(); + foreach ($contextids as $contextid) { + $fs->delete_area_files_select($contextid, 'customfield_textarea', 'value', $dataidstest, $params); + } + } + + /** + * Allows plugins to delete everything they store related to the field configuration (usually files) + * + * The implementation should not delete data or anything related to the data, since "before_delete_data" is + * invoked separately. + * + * @param string $fieldidstest + * @param array $params + * @param array $contextids + */ + public static function before_delete_fields(string $fieldidstest, array $params, array $contextids) { + $fs = get_file_storage(); + foreach ($contextids as $contextid) { + $fs->delete_area_files_select($contextid, 'customfield_textarea', 'defaultvalue', $fieldidstest, $params); + } + } +} diff --git a/field/textarea/lang/en/customfield_textarea.php b/field/textarea/lang/en/customfield_textarea.php new file mode 100644 index 0000000..ca601f4 --- /dev/null +++ b/field/textarea/lang/en/customfield_textarea.php @@ -0,0 +1,29 @@ +. + +/** + * Customfield textarea plugin + * + * @package customfield_textarea + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$string['pluginname'] = 'Text area'; +$string['privacy:metadata'] = 'The Text area field type plugin doesn\'t store any personal data; it uses tables defined in core.'; +$string['specificsettings'] = 'Text area field settings'; diff --git a/field/textarea/lib.php b/field/textarea/lib.php new file mode 100644 index 0000000..83389f3 --- /dev/null +++ b/field/textarea/lib.php @@ -0,0 +1,76 @@ +. + +/** + * Callbacks + * + * @package customfield_textarea + * @copyright 2018 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die; + +/** + * Serve the files from the customfield_textarea file areas + * + * @param stdClass $course the course object + * @param stdClass $cm the course module object + * @param context $context the context + * @param string $filearea the name of the file area + * @param array $args extra arguments (itemid, path) + * @param bool $forcedownload whether or not force download + * @param array $options additional options affecting the file serving + * @return bool false if the file not found, just send the file otherwise and do not return + */ +function customfield_textarea_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options=array()) { + global $DB; + + $itemid = array_shift($args); + if ($filearea === 'value') { + // Value of the data, itemid = id in data table. + $datarecord = $DB->get_record(\core_customfield\data::TABLE, ['id' => $itemid], '*', MUST_EXIST); + $field = \core_customfield\field_controller::create($datarecord->fieldid); + $data = \core_customfield\data_controller::create(0, $datarecord, $field); + $handler = $field->get_handler(); + if ($field->get('type') !== 'textarea' || !$handler->can_view($field, $data->get('instanceid')) + || $data->get_context()->id != $context->id) { + send_file_not_found(); + } + } else if ($filearea === 'defaultvalue') { + // Default value of the field, itemid = id in the field table. + $field = \core_customfield\field_controller::create($itemid); + $handler = $field->get_handler(); + if ($field->get('type') !== 'textarea' || $handler->get_configuration_context()->id != $context->id) { + send_file_not_found(); + } + } else { + send_file_not_found(); + } + + $filename = array_pop($args); // The last item in the $args array. + $filepath = '/' . ($args ? implode('/', $args) . '/' : ''); + + // Retrieve the file from the Files API. + $fs = get_file_storage(); + $file = $fs->get_file($context->id, 'customfield_textarea', $filearea, $itemid, $filepath, $filename); + if (!$file) { + send_file_not_found(); + } + + // We can now send the file back to the browser - in this case with a cache lifetime of 1 day and no filtering. + send_file($file, 86400, 0, $forcedownload, $options); +} diff --git a/field/textarea/tests/behat/default_value.feature b/field/textarea/tests/behat/default_value.feature new file mode 100644 index 0000000..113ace6 --- /dev/null +++ b/field/textarea/tests/behat/default_value.feature @@ -0,0 +1,83 @@ +@customfield @customfield_textarea @javascript @_file_upload +Feature: Default value for the textarea custom field can contain images + In order to see images on custom fields + As a manager + I need to be able to add images to the default value + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher | Teacher | 1 | teacher1@example.com | + | manager | Manager | 1 | manager1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | topics | + And the following "course enrolments" exist: + | user | course | role | + | teacher | C1 | editingteacher | + And the following "system role assigns" exist: + | user | course | role | + | manager | Acceptance test site | manager | + And the following "custom field categories" exist: + | name | component | area | itemid | + | Category for test | core_course | course | 0 | + And the following "blocks" exist: + | blockname | contextlevel | reference | pagetypepattern | defaultregion | + | private_files | System | 1 | my-index | side-post | + # Upload an image into the private files. + And I log in as "admin" + And I follow "Manage private files" + And I upload "lib/tests/fixtures/gd-logo.png" file to "Files" filemanager + And I click on "Save changes" "button" + And I navigate to "Courses > Course custom fields" in site administration + And I click on "Add a new custom field" "link" + And I click on "Text area" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + | Default value | v | + # Embed the image into Default value. + And I select the text in the "Default value" Atto editor + And I click on "Insert or edit image" "button" in the "Default value" "form_row" + And I click on "Browse repositories..." "button" + And I click on "Private files" "link" in the ".fp-repo-area" "css_element" + And I click on "gd-logo.png" "link" + And I click on "Select this file" "button" + And I set the field "Describe this image for someone who cannot see it" to "Example" + And I click on "Save image" "button" + And I click on "Save changes" "button" in the "Adding a new Text area" "dialogue" + And I log out + + Scenario: For the courses that existed before the custom field was created the default value is displayed + When I am on site homepage + Then the image at "//*[contains(@class, 'frontpage-course-list-all')]//*[contains(@class, 'customfield_textarea')]//img[contains(@src, 'pluginfile.php') and contains(@src, '/customfield_textarea/defaultvalue/') and @alt='Example']" "xpath_element" should be identical to "lib/tests/fixtures/gd-logo.png" + + Scenario: Teacher will see textarea default value when editing a course created before custom field was created + # Teacher will see the image when editing existing course. + And I log in as "teacher" + And I am on "Course 1" course homepage + And I navigate to "Settings" in current page administration + And I expand all fieldsets + Then "//img[contains(@src, 'draftfile.php') and contains(@src, '/gd-logo.png') and @alt='Example']" "xpath_element" should exist in the "Test field" "form_row" + # Save the course without changing the default value. + And I press "Save and display" + And I log out + # Now the same image is displayed as "value" and not as "defaultvalue". + And I am on site homepage + And "//img[contains(@src, '/customfield_textarea/defaultvalue/')]" "xpath_element" should not exist + And the image at "//*[contains(@class, 'frontpage-course-list-all')]//*[contains(@class, 'customfield_textarea')]//img[contains(@src, 'pluginfile.php') and contains(@src, '/customfield_textarea/value/') and @alt='Example']" "xpath_element" should be identical to "lib/tests/fixtures/gd-logo.png" + + Scenario: Manager can create a course and the default value for textarea custom field will apply. + When I log in as "manager" + And I go to the courses management page + And I click on "Create new course" "link" in the "#course-listing" "css_element" + And I set the following fields to these values: + | Course full name | Course 2 | + | Course short name | C2 | + And I expand all fieldsets + Then "//img[contains(@src, 'draftfile.php') and contains(@src, '/gd-logo.png') and @alt='Example']" "xpath_element" should exist in the "Test field" "form_row" + And I press "Save and display" + And I log out + # Now the same image is displayed as "value" and not as "defaultvalue". + And I am on site homepage + And the image at "//*[contains(@class, 'frontpage-course-list-all')]//*[contains(@class, 'customfield_textarea')]//img[contains(@src, 'pluginfile.php') and contains(@src, '/customfield_textarea/value/') and @alt='Example']" "xpath_element" should be identical to "lib/tests/fixtures/gd-logo.png" diff --git a/field/textarea/tests/behat/field.feature b/field/textarea/tests/behat/field.feature new file mode 100644 index 0000000..b0416b5 --- /dev/null +++ b/field/textarea/tests/behat/field.feature @@ -0,0 +1,49 @@ +@customfield @customfield_textarea @javascript +Feature: Managers can manage course custom fields textarea + In order to have additional data on the course + As a manager + I need to create, edit, remove and sort custom fields + + Background: + Given the following "custom field categories" exist: + | name | component | area | itemid | + | Category for test | core_course | course | 0 | + And I log in as "admin" + And I navigate to "Courses > Course custom fields" in site administration + + Scenario: Create a custom course textarea field + When I click on "Add a new custom field" "link" + And I click on "Text area" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + And I click on "Save changes" "button" in the "Adding a new Text area" "dialogue" + Then I should see "Test field" + And I log out + + Scenario: Edit a custom course textarea field + When I click on "Add a new custom field" "link" + And I click on "Text area" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + And I click on "Save changes" "button" in the "Adding a new Text area" "dialogue" + And I click on "Edit" "link" in the "Test field" "table_row" + And I set the following fields to these values: + | Name | Edited field | + And I click on "Save changes" "button" in the "Updating Test field" "dialogue" + Then I should see "Edited field" + And I should not see "Test field" + And I log out + + Scenario: Delete a custom course textarea field + When I click on "Add a new custom field" "link" + And I click on "Text area" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + And I click on "Save changes" "button" in the "Adding a new Text area" "dialogue" + And I click on "Delete" "link" in the "Test field" "table_row" + And I click on "Yes" "button" in the "Confirm" "dialogue" + Then I should not see "Test field" + And I log out diff --git a/field/textarea/tests/plugin_test.php b/field/textarea/tests/plugin_test.php new file mode 100644 index 0000000..884e230 --- /dev/null +++ b/field/textarea/tests/plugin_test.php @@ -0,0 +1,195 @@ +. + +namespace customfield_textarea; + +use core_customfield_generator; +use core_customfield_test_instance_form; + +/** + * Functional test for customfield_textarea + * + * @package customfield_textarea + * @copyright 2019 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class plugin_test extends \advanced_testcase { + + /** @var stdClass[] */ + private $courses = []; + /** @var \core_customfield\category_controller */ + private $cfcat; + /** @var \core_customfield\field_controller[] */ + private $cfields; + /** @var \core_customfield\data_controller[] */ + private $cfdata; + + /** + * Tests set up. + */ + public function setUp(): void { + $this->resetAfterTest(); + + $this->cfcat = $this->get_generator()->create_category(); + + $this->cfields[1] = $this->get_generator()->create_field( + ['categoryid' => $this->cfcat->get('id'), 'shortname' => 'myfield1', 'type' => 'textarea']); + $this->cfields[2] = $this->get_generator()->create_field( + ['categoryid' => $this->cfcat->get('id'), 'shortname' => 'myfield2', 'type' => 'textarea', + 'configdata' => ['required' => 1]]); + $this->cfields[3] = $this->get_generator()->create_field( + ['categoryid' => $this->cfcat->get('id'), 'shortname' => 'myfield3', 'type' => 'textarea', + 'configdata' => ['defaultvalue' => 'Value3', 'defaultvalueformat' => FORMAT_MOODLE]]); + + $this->courses[1] = $this->getDataGenerator()->create_course(); + $this->courses[2] = $this->getDataGenerator()->create_course(); + $this->courses[3] = $this->getDataGenerator()->create_course(); + + $this->cfdata[1] = $this->get_generator()->add_instance_data($this->cfields[1], $this->courses[1]->id, + ['text' => 'Value1', 'format' => FORMAT_MOODLE]); + $this->cfdata[2] = $this->get_generator()->add_instance_data($this->cfields[1], $this->courses[2]->id, + ['text' => 'Value2', 'format' => FORMAT_MOODLE]); + + $this->setUser($this->getDataGenerator()->create_user()); + } + + /** + * Get generator + * @return core_customfield_generator + */ + protected function get_generator() : core_customfield_generator { + return $this->getDataGenerator()->get_plugin_generator('core_customfield'); + } + + /** + * Test for initialising field and data controllers + */ + public function test_initialise() { + $f = \core_customfield\field_controller::create($this->cfields[1]->get('id')); + $this->assertTrue($f instanceof field_controller); + + $f = \core_customfield\field_controller::create(0, (object)['type' => 'textarea'], $this->cfcat); + $this->assertTrue($f instanceof field_controller); + + $d = \core_customfield\data_controller::create($this->cfdata[1]->get('id')); + $this->assertTrue($d instanceof data_controller); + + $d = \core_customfield\data_controller::create(0, null, $this->cfields[1]); + $this->assertTrue($d instanceof data_controller); + } + + /** + * Test for configuration form functions + * + * Create a configuration form and submit it with the same values as in the field + */ + public function test_config_form() { + $this->setAdminUser(); + $submitdata = (array)$this->cfields[3]->to_record(); + $submitdata['configdata'] = $this->cfields[3]->get('configdata'); + + $submitdata = \core_customfield\field_config_form::mock_ajax_submit($submitdata); + $form = new \core_customfield\field_config_form(null, null, 'post', '', null, true, + $submitdata, true); + $form->set_data_for_dynamic_submission(); + $this->assertTrue($form->is_validated()); + $form->process_dynamic_submission(); + } + + /** + * Test for instance form functions + */ + public function test_instance_form() { + global $CFG; + require_once($CFG->dirroot . '/customfield/tests/fixtures/test_instance_form.php'); + $this->setAdminUser(); + $handler = $this->cfcat->get_handler(); + + // First try to submit without required field. + $submitdata = (array)$this->courses[1]; + core_customfield_test_instance_form::mock_submit($submitdata, []); + $form = new core_customfield_test_instance_form('POST', + ['handler' => $handler, 'instance' => $this->courses[1]]); + $this->assertFalse($form->is_validated()); + + // Now with required field. + $submitdata['customfield_myfield2_editor'] = ['text' => 'Some text', 'format' => FORMAT_HTML]; + core_customfield_test_instance_form::mock_submit($submitdata, []); + $form = new core_customfield_test_instance_form('POST', + ['handler' => $handler, 'instance' => $this->courses[1]]); + $this->assertTrue($form->is_validated()); + + $data = $form->get_data(); + $this->assertNotEmpty($data->customfield_myfield1_editor); + $this->assertNotEmpty($data->customfield_myfield2_editor); + $handler->instance_form_save($data); + } + + /** + * Test that instance form save empties the field content for blank values + */ + public function test_instance_form_save_clear(): void { + global $CFG; + + require_once("{$CFG->dirroot}/customfield/tests/fixtures/test_instance_form.php"); + + $this->setAdminUser(); + + $handler = $this->cfcat->get_handler(); + + // Set our custom field to a known value. + $submitdata = (array) $this->courses[1] + [ + 'customfield_myfield1_editor' => ['text' => 'I can see it in your eyes', 'format' => FORMAT_HTML], + 'customfield_myfield2_editor' => ['text' => 'I can see it in your smile', 'format' => FORMAT_HTML], + ]; + + core_customfield_test_instance_form::mock_submit($submitdata, []); + $form = new core_customfield_test_instance_form('post', ['handler' => $handler, 'instance' => $this->courses[1]]); + $handler->instance_form_save($form->get_data()); + + $this->assertEquals($submitdata['customfield_myfield1_editor']['text'], + \core_customfield\data_controller::create($this->cfdata[1]->get('id'))->export_value()); + + // Now empty our non-required field. + $submitdata['customfield_myfield1_editor']['text'] = ''; + + core_customfield_test_instance_form::mock_submit($submitdata, []); + $form = new core_customfield_test_instance_form('post', ['handler' => $handler, 'instance' => $this->courses[1]]); + $handler->instance_form_save($form->get_data()); + + $this->assertEmpty(\core_customfield\data_controller::create($this->cfdata[1]->get('id'))->export_value()); + } + + /** + * Test for data_controller::get_value and export_value + */ + public function test_get_export_value() { + $this->assertEquals('Value1', $this->cfdata[1]->get_value()); + $this->assertEquals('
Value1
', $this->cfdata[1]->export_value()); + + // Field without data but with a default value. + $d = \core_customfield\data_controller::create(0, null, $this->cfields[3]); + $this->assertEquals('Value3', $d->get_value()); + $this->assertEquals('
Value3
', $d->export_value()); + } + + /** + * Deleting fields and data + */ + public function test_delete() { + $this->cfcat->get_handler()->delete_all(); + } +} diff --git a/field/textarea/version.php b/field/textarea/version.php new file mode 100644 index 0000000..631345a --- /dev/null +++ b/field/textarea/version.php @@ -0,0 +1,29 @@ +. + +/** + * Customfield text area plugin + * + * @package customfield_textarea + * @copyright 2018 David Matamoros + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->component = 'customfield_textarea'; +$plugin->version = 2022041900; +$plugin->requires = 2022041200; diff --git a/field/upgrade.txt b/field/upgrade.txt new file mode 100644 index 0000000..2dab74b --- /dev/null +++ b/field/upgrade.txt @@ -0,0 +1,5 @@ +This files describes API changes in /customfield/field/* - customfield field types, +information provided here is intended especially for developers. + +=== 3.8 === +* supports_course_grouping() and course_grouping_format_values() functions added to support use of custom fields in block_myoverview diff --git a/lib.php b/lib.php new file mode 100644 index 0000000..b3ec2d6 --- /dev/null +++ b/lib.php @@ -0,0 +1,85 @@ +. + +/** + * Callbacks + * + * @package core_customfield + * @copyright 2018 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die; + +/** + * Edit customfield elements inplace + * + * @param string $itemtype + * @param int $itemid + * @param string $newvalue + * @return \core\output\inplace_editable + */ +function core_customfield_inplace_editable($itemtype, $itemid, $newvalue) { + if ($itemtype === 'category') { + $category = core_customfield\category_controller::create($itemid); + $handler = $category->get_handler(); + \external_api::validate_context($handler->get_configuration_context()); + if (!$handler->can_configure()) { + throw new moodle_exception('nopermissionconfigure', 'core_customfield'); + } + $newvalue = clean_param($newvalue, PARAM_NOTAGS); + $handler->rename_category($category, $newvalue); + return \core_customfield\api::get_category_inplace_editable($category, true); + } +} + +/** + * Serve the files from the core_customfield file areas + * + * @param stdClass $course the course object + * @param stdClass $cm the course module object + * @param context $context the context + * @param string $filearea the name of the file area + * @param array $args extra arguments (itemid, path) + * @param bool $forcedownload whether or not force download + * @param array $options additional options affecting the file serving + * @return bool false if the file not found, just send the file otherwise and do not return + */ +function core_customfield_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options=array()) { + if ($filearea !== 'description') { + return false; + } + + $itemid = array_shift($args); + $filename = array_pop($args); // The last item in the $args array. + + $field = \core_customfield\field_controller::create($itemid); + $handler = $field->get_handler(); + if ($handler->get_configuration_context()->id != $context->id) { + return false; + } + + // Retrieve the file from the Files API. + $fs = get_file_storage(); + $file = $fs->get_file($context->id, 'core_customfield', $filearea, $itemid, '/', $filename); + if (!$file) { + return false; // The file does not exist. + } + + // We can now send the file back to the browser - in this case with a cache lifetime of 1 day and no filtering. + // From Moodle 2.3, use send_stored_file instead. + send_file($file, 86400, 0, $forcedownload, $options); +} diff --git a/screenshot.png b/screenshot.png deleted file mode 100644 index 16239f79194d8c4398e54a42b865e17d1269f4b2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28177 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. + }} +{{! + @template core_customfield/display_field + + Example context (json): + { + "hasvalue": 1, + "fieldtype" : "text", + "fieldname" : "Nick name", + "fieldshortname" : "nickname", + "fieldvalue" : "Star Lord" + } +}} +{{#hasvalue}} +
+ {{{name}}}: {{{value}}} +
+{{/hasvalue}} diff --git a/templates/list.mustache b/templates/list.mustache new file mode 100644 index 0000000..521b268 --- /dev/null +++ b/templates/list.mustache @@ -0,0 +1,131 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . + }} +{{! + @template core_customfield/list + + Moodle list template. + + The purpose of this template is to render a list. + + Classes required for JS: + * none + + Data attributes required for JS: + * data-component + * data-area + * data-itemid + + Context variables required for this template: + * attributes Array of name / value pairs. + + Example context (json): + { + "component": "core_nonexisting", + "area": "course", + "itemid": 0, + "usescategories": 1, + "categories": [ + { "id": "0", + "nameeditable": "Other fields", + "addfieldmenu": "Add field", + "fields": [ + { "id": 0, "name": "Field name", "shortname": "shortname", "type": "Text" }, + { "id": 0, "name": "Another field", "shortname": "checkme", "type": "Checkbox" } + ] + }, + { "id": "00", + "nameeditable": "Empty category", + "addfieldmenu": "Add field", + "fields": [] } + ], + "singleselect" : "select" + } +}} + +{{{alert}}} + +
+
+
+ {{#usescategories}} + {{#str}}addnewcategory, core_customfield{{/str}} + {{/usescategories}} +
+
+ + {{^categories}} + {{{nocategories}}} + {{/categories}} + +
+ {{#categories}} +
+
+
+ {{#usescategories}} +

+ + {{> core/drag_handle}}{{{nameeditable}}} + {{#pix}} + t/delete, core, {{#str}} delete, moodle {{/str}} {{/pix}} +

+ {{/usescategories}} +
+
+ {{{addfieldmenu}}} +
+
+
+ + + + + + + + + + + {{#fields}} + + + + + + + {{/fields}} + {{^fields}} + {{> core_customfield/nofields }} + {{/fields}} + +
{{#str}} customfield, core_customfield {{/str}}{{#str}} shortname, core_customfield {{/str}}{{#str}} type, core_customfield {{/str}}{{#str}} action, core_customfield {{/str}}
{{> core/drag_handle}}{{{name}}}{{{shortname}}}{{{type}}} + {{#pix}} + t/edit, core, {{#str}} edit, moodle {{/str}} {{/pix}} + {{#pix}} + t/delete, core, {{#str}} delete, moodle {{/str}} {{/pix}} +
+
+
+ {{/categories}} +
+
+ +{{#js}} + require(['core_customfield/form'], function(s) { + s.init(); + }); +{{/js}} diff --git a/templates/nofields.mustache b/templates/nofields.mustache new file mode 100644 index 0000000..033449c --- /dev/null +++ b/templates/nofields.mustache @@ -0,0 +1,39 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . + }} +{{! + @template core_customfield/nofields + + Moodle list template. + + The purpose of this template is to render the nofields tbody. + + Classes required for JS: + * none + + Data attributes required for JS: + * data-component + * data-area + * data-itemid + + Context variables required for this template: + * attributes Array of name / value pairs. + + Example context (json): + { + } +}} +{{# str }} therearenofields, core_customfield {{/ str }} diff --git a/tests/api_test.php b/tests/api_test.php new file mode 100644 index 0000000..c3e4e2a --- /dev/null +++ b/tests/api_test.php @@ -0,0 +1,254 @@ +. + +namespace core_customfield; + +/** + * Functional test for class \core_customfield\api + * + * @package core_customfield + * @category test + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class api_test extends \advanced_testcase { + + /** + * Get generator. + * + * @return core_customfield_generator + */ + protected function get_generator(): \core_customfield_generator { + return $this->getDataGenerator()->get_plugin_generator('core_customfield'); + } + + /** + * Help to assert that the given property in an array of object has the expected value + * + * @param array $expected + * @param array $array array of objects with "get($property)" method + * @param string $propertyname + */ + protected function assert_property_in_array($expected, $array, $propertyname) { + $this->assertEquals($expected, array_values(array_map(function($a) use ($propertyname) { + return $a->get($propertyname); + }, $array))); + } + + /** + * Tests for \core_customfield\api::move_category() behaviour. + * + * This replicates what is happening when categories are moved + * in the interface using drag-drop. + */ + public function test_move_category() { + $this->resetAfterTest(); + + // Create the categories. + $params = ['component' => 'core_course', 'area' => 'course', 'itemid' => 0]; + $id0 = $this->get_generator()->create_category($params)->get('id'); + $id1 = $this->get_generator()->create_category($params)->get('id'); + $id2 = $this->get_generator()->create_category($params)->get('id'); + $id3 = $this->get_generator()->create_category($params)->get('id'); + $id4 = $this->get_generator()->create_category($params)->get('id'); + $id5 = $this->get_generator()->create_category($params)->get('id'); + + // Check order after re-fetch. + $categories = api::get_categories_with_fields($params['component'], $params['area'], $params['itemid']); + $this->assertEquals([$id0, $id1, $id2, $id3, $id4, $id5], array_keys($categories)); + $this->assert_property_in_array([0, 1, 2, 3, 4, 5], $categories, 'sortorder'); + + // Move up 1 position. + api::move_category(category_controller::create($id3), $id2); + $categories = api::get_categories_with_fields($params['component'], $params['area'], $params['itemid']); + $this->assertEquals([$id0, $id1, $id3, $id2, $id4, $id5], array_keys($categories)); + $this->assert_property_in_array([0, 1, 2, 3, 4, 5], $categories, 'sortorder'); + + // Move down 1 position. + api::move_category(category_controller::create($id2), $id3); + $categories = api::get_categories_with_fields($params['component'], $params['area'], $params['itemid']); + $this->assertEquals([$id0, $id1, $id2, $id3, $id4, $id5], array_keys($categories)); + $this->assert_property_in_array([0, 1, 2, 3, 4, 5], $categories, 'sortorder'); + + // Move up 2 positions. + api::move_category(category_controller::create($id4), $id2); + $categories = api::get_categories_with_fields($params['component'], $params['area'], $params['itemid']); + $this->assertEquals([$id0, $id1, $id4, $id2, $id3, $id5], array_keys($categories)); + $this->assert_property_in_array([0, 1, 2, 3, 4, 5], $categories, 'sortorder'); + + // Move down 2 positions. + api::move_category(category_controller::create($id4), $id5); + $categories = api::get_categories_with_fields($params['component'], $params['area'], $params['itemid']); + $this->assertEquals([$id0, $id1, $id2, $id3, $id4, $id5], array_keys($categories)); + $this->assert_property_in_array([0, 1, 2, 3, 4, 5], $categories, 'sortorder'); + + // Move to the end of the list. + api::move_category(category_controller::create($id2)); + $categories = api::get_categories_with_fields($params['component'], $params['area'], $params['itemid']); + $this->assertEquals([$id0, $id1, $id3, $id4, $id5, $id2], array_keys($categories)); + $this->assert_property_in_array([0, 1, 2, 3, 4, 5], $categories, 'sortorder'); + } + + /** + * Tests for \core_customfield\api::get_categories_with_fields() behaviour. + */ + public function test_get_categories_with_fields() { + $this->resetAfterTest(); + + // Create the categories. + $options = [ + 'component' => 'core_course', + 'area' => 'course', + 'itemid' => 0, + 'contextid' => \context_system::instance()->id + ]; + $category0 = $this->get_generator()->create_category(['name' => 'aaaa'] + $options); + $category1 = $this->get_generator()->create_category(['name' => 'bbbb'] + $options); + $category2 = $this->get_generator()->create_category(['name' => 'cccc'] + $options); + $category3 = $this->get_generator()->create_category(['name' => 'dddd'] + $options); + $category4 = $this->get_generator()->create_category(['name' => 'eeee'] + $options); + $category5 = $this->get_generator()->create_category(['name' => 'ffff'] + $options); + + // Let's test counts. + $this->assertCount(6, api::get_categories_with_fields($options['component'], $options['area'], $options['itemid'])); + api::delete_category($category5); + $this->assertCount(5, api::get_categories_with_fields($options['component'], $options['area'], $options['itemid'])); + api::delete_category($category4); + $this->assertCount(4, api::get_categories_with_fields($options['component'], $options['area'], $options['itemid'])); + api::delete_category($category3); + $this->assertCount(3, api::get_categories_with_fields($options['component'], $options['area'], $options['itemid'])); + api::delete_category($category2); + $this->assertCount(2, api::get_categories_with_fields($options['component'], $options['area'], $options['itemid'])); + api::delete_category($category1); + $this->assertCount(1, api::get_categories_with_fields($options['component'], $options['area'], $options['itemid'])); + api::delete_category($category0); + $this->assertCount(0, api::get_categories_with_fields($options['component'], $options['area'], $options['itemid'])); + } + + /** + * Test for functions api::save_category() and rename_category) + */ + public function test_save_category() { + $this->resetAfterTest(); + + $params = ['component' => 'core_course', 'area' => 'course', 'itemid' => 0, 'name' => 'Cat1', + 'contextid' => \context_system::instance()->id]; + $c1 = category_controller::create(0, (object)$params); + api::save_category($c1); + $this->assertNotEmpty($c1->get('id')); + + $c1 = category_controller::create($c1->get('id')); + $expected = $params + ['sortorder' => 0, 'id' => $c1->get('id'), 'description' => '', 'descriptionformat' => 0]; + $actual = array_intersect_key((array)$c1->to_record(), $expected); // Ignore timecreated, timemodified. + ksort($expected); + ksort($actual); + $this->assertEquals($expected, $actual); + + // Create new category and check that the sortorder will be 1. + $params['name'] = 'Cat2'; + $c2 = category_controller::create(0, (object)$params); + api::save_category($c2); + $this->assertNotEmpty($c2->get('id')); + $this->assertEquals(1, $c2->get('sortorder')); + $c2 = category_controller::create($c2->get('id')); + $this->assertEquals(1, $c2->get('sortorder')); + + // Rename a category. + $c1->set('name', 'Cat3'); + $c1->save(); + $c1 = category_controller::create($c1->get('id')); + $this->assertEquals('Cat3', $c1->get('name')); + } + + /** + * Test for function handler::create_category + */ + public function test_create_category() { + $this->resetAfterTest(); + + $handler = \core_course\customfield\course_handler::create(); + $c1id = $handler->create_category(); + $c1 = $handler->get_categories_with_fields()[$c1id]; + $this->assertEquals('Other fields', $c1->get('name')); + $this->assertEquals($handler->get_component(), $c1->get('component')); + $this->assertEquals($handler->get_area(), $c1->get('area')); + $this->assertEquals($handler->get_itemid(), $c1->get('itemid')); + $this->assertEquals($handler->get_configuration_context()->id, $c1->get('contextid')); + + // Generate more categories and make sure they have different names. + $c2id = $handler->create_category(); + $c3id = $handler->create_category(); + $c2 = $handler->get_categories_with_fields()[$c2id]; + $c3 = $handler->get_categories_with_fields()[$c3id]; + $this->assertEquals('Other fields 1', $c2->get('name')); + $this->assertEquals('Other fields 2', $c3->get('name')); + } + + /** + * Tests for \core_customfield\api::delete_category() behaviour. + */ + public function test_delete_category_with_fields() { + $this->resetAfterTest(); + + global $DB; + // Create two categories with fields and data. + $options = [ + 'component' => 'core_course', + 'area' => 'course', + 'itemid' => 0, + 'contextid' => \context_system::instance()->id + ]; + $lpg = $this->get_generator(); + $course = $this->getDataGenerator()->create_course(); + $dataparams = ['instanceid' => $course->id, 'contextid' => \context_course::instance($course->id)->id]; + $category0 = $lpg->create_category($options); + $category1 = $lpg->create_category($options); + for ($i = 0; $i < 6; $i++) { + $f = $lpg->create_field(['categoryid' => $category0->get('id')]); + \core_customfield\data_controller::create(0, (object)$dataparams, $f)->save(); + $f = $lpg->create_field(['categoryid' => $category1->get('id')]); + \core_customfield\data_controller::create(0, (object)$dataparams, $f)->save(); + } + + // Check that each category have fields and store ids for future checks. + list($category0, $category1) = array_values(api::get_categories_with_fields($options['component'], + $options['area'], $options['itemid'])); + $category0fieldsids = array_keys($category0->get_fields()); + $category1fieldsids = array_keys($category1->get_fields()); + + // There are 6 records in field table and 6 records in data table for each category. + list($sql, $p) = $DB->get_in_or_equal($category0fieldsids); + $this->assertCount(6, $DB->get_records_select(\core_customfield\field::TABLE, 'id '.$sql, $p)); + $this->assertCount(6, $DB->get_records_select(\core_customfield\data::TABLE, 'fieldid '.$sql, $p)); + + list($sql, $p) = $DB->get_in_or_equal($category1fieldsids); + $this->assertCount(6, $DB->get_records_select(\core_customfield\field::TABLE, 'id '.$sql, $p)); + $this->assertCount(6, $DB->get_records_select(\core_customfield\data::TABLE, 'fieldid '.$sql, $p)); + + // Delete one category. + $this->assertTrue($category0->get_handler()->delete_category($category0)); + + // Check that the category fields and data were deleted. + list($sql, $p) = $DB->get_in_or_equal($category0fieldsids); + $this->assertEmpty($DB->get_records_select(\core_customfield\field::TABLE, 'id '.$sql, $p)); + $this->assertEmpty($DB->get_records_select(\core_customfield\data::TABLE, 'fieldid '.$sql, $p)); + + // Check that fields and data for the other category remain. + list($sql, $p) = $DB->get_in_or_equal($category1fieldsids); + $this->assertCount(6, $DB->get_records_select(\core_customfield\field::TABLE, 'id '.$sql, $p)); + $this->assertCount(6, $DB->get_records_select(\core_customfield\data::TABLE, 'fieldid '.$sql, $p)); + } +} diff --git a/tests/behat/edit_categories.feature b/tests/behat/edit_categories.feature new file mode 100644 index 0000000..b44a661 --- /dev/null +++ b/tests/behat/edit_categories.feature @@ -0,0 +1,104 @@ +@core @core_course @core_customfield @javascript +Feature: Managers can manage categories for course custom fields + In order to have additional data on the course + As a manager + I need to create, edit, remove and sort custom field's categories + + Scenario: Create a category for custom course fields + Given I log in as "admin" + When I navigate to "Courses > Course custom fields" in site administration + And I press "Add a new category" + And I wait until the page is ready + Then I should see "Other fields" in the "#customfield_catlist" "css_element" + And I navigate to "Reports > Logs" in site administration + And I press "Get these logs" + + Scenario: Edit a category name for custom course fields + Given the following "custom field categories" exist: + | name | component | area | itemid | + | Category for test | core_course | course | 0 | + And I log in as "admin" + And I navigate to "Courses > Course custom fields" in site administration + And I set the field "Edit category name" in the "//div[contains(@class,'categoryinstance') and contains(.,'Category for test')]" "xpath_element" to "Good fields" + Then I should not see "Category for test" in the "#customfield_catlist" "css_element" + And "New value for Category for test" "field" should not exist + And I should see "Good fields" in the "#customfield_catlist" "css_element" + And I navigate to "Reports > Logs" in site administration + And I press "Get these logs" + + Scenario: Delete a category for custom course fields + Given the following "custom field categories" exist: + | name | component | area | itemid | + | Category for test | core_course | course | 0 | + And the following "custom fields" exist: + | name | category | type | shortname | + | Field 1 | Category for test | text | f1 | + And I log in as "admin" + And I navigate to "Courses > Course custom fields" in site administration + And I click on "[data-role='deletecategory']" "css_element" + And I click on "Yes" "button" in the "Confirm" "dialogue" + And I wait until the page is ready + And I wait until "Test category" "text" does not exist + Then I should not see "Test category" in the "#customfield_catlist" "css_element" + And I navigate to "Reports > Logs" in site administration + And I press "Get these logs" + + Scenario: Move field in the course custom fields to another category + Given the following "custom field categories" exist: + | name | component | area | itemid | + | Category1 | core_course | course | 0 | + | Category2 | core_course | course | 0 | + | Category3 | core_course | course | 0 | + And the following "custom fields" exist: + | name | category | type | shortname | + | Field1 | Category1 | text | f1 | + | Field2 | Category2 | text | f2 | + When I log in as "admin" + And I navigate to "Courses > Course custom fields" in site administration + Then "Field1" "text" should appear after "Category1" "text" + And "Category2" "text" should appear after "Field1" "text" + And "Field2" "text" should appear after "Category2" "text" + And "Category3" "text" should appear after "Field2" "text" + And I press "Move \"Field1\"" + And I follow "To the top of category Category2" + And "Category2" "text" should appear after "Category1" "text" + And "Field1" "text" should appear after "Category2" "text" + And "Field2" "text" should appear after "Field1" "text" + And "Category3" "text" should appear after "Field2" "text" + And I navigate to "Courses > Course custom fields" in site administration + And "Category2" "text" should appear after "Category1" "text" + And "Field1" "text" should appear after "Category2" "text" + And "Field2" "text" should appear after "Field1" "text" + And "Category3" "text" should appear after "Field2" "text" + And I press "Move \"Field1\"" + And I follow "After field Field2" + And "Field1" "text" should appear after "Field2" "text" + + Scenario: Reorder course custom field categories + Given the following "custom field categories" exist: + | name | component | area | itemid | + | Category1 | core_course | course | 0 | + | Category2 | core_course | course | 0 | + | Category3 | core_course | course | 0 | + And the following "custom fields" exist: + | name | category | type | shortname | + | Field1 | Category1 | text | f1 | + When I log in as "admin" + And I navigate to "Courses > Course custom fields" in site administration + Then "Field1" "text" should appear after "Category1" "text" + And "Category2" "text" should appear after "Field1" "text" + And "Category3" "text" should appear after "Category2" "text" + And I press "Move \"Category2\"" + And I follow "After \"Category3\"" + And "Field1" "text" should appear after "Category1" "text" + And "Category3" "text" should appear after "Field1" "text" + And "Category2" "text" should appear after "Category3" "text" + And I navigate to "Courses > Course custom fields" in site administration + And "Field1" "text" should appear after "Category1" "text" + And "Category3" "text" should appear after "Field1" "text" + And "Category2" "text" should appear after "Category3" "text" + And I press "Move \"Category2\"" + And I follow "After \"Category1\"" + And "Field1" "text" should appear after "Category1" "text" + And "Category2" "text" should appear after "Field1" "text" + And "Category3" "text" should appear after "Category2" "text" diff --git a/tests/behat/edit_fields_settings.feature b/tests/behat/edit_fields_settings.feature new file mode 100644 index 0000000..90c9a32 --- /dev/null +++ b/tests/behat/edit_fields_settings.feature @@ -0,0 +1,118 @@ +@core @core_course @core_customfield @javascript +Feature: Teachers can edit course custom fields + In order to have additional data on the course + As a teacher + I need to edit data for custom fields + + Background: + Given the following "custom field categories" exist: + | name | component | area | itemid | + | Category for test | core_course | course | 0 | + And the following "custom fields" exist: + | name | category | type | shortname | description | configdata | + | Field 1 | Category for test | text | f1 | d1 | | + | Field 2 | Category for test | textarea | f2 | d2 | | + | Field 3 | Category for test | checkbox | f3 | d3 | | + | Field 4 | Category for test | date | f4 | d4 | | + | Field 5 | Category for test | select | f5 | d5 | {"options":"a\nb\nc"} | + And the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | topics | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + And the following "blocks" exist: + | blockname | contextlevel | reference | pagetypepattern | defaultregion | + | private_files | System | 1 | my-index | side-post | + + Scenario: Display custom fields on course edit form + When I log in as "teacher1" + And I am on "Course 1" course homepage + And I navigate to "Settings" in current page administration + And I expand all fieldsets + Then I should see "Category for test" + And I should see "Field 1" + And I should see "Field 2" + And I should see "Field 3" + And I should see "Field 4" + And I should see "Field 5" + And I log out + + Scenario: Create a course with custom fields from the management interface + When I log in as "admin" + And I go to the courses management page + And I should see the "Categories" management page + And I click on category "Category 1" in the management interface + And I should see the "Course categories and courses" management page + And I click on "Create new course" "link" in the "#course-listing" "css_element" + And I set the following fields to these values: + | Course full name | Course 2 | + | Course short name | C2 | + | Field 1 | testcontent1 | + | Field 2 | testcontent2 | + | Field 3 | 1 | + | customfield_f4[enabled] | 1 | + | customfield_f4[day] | 1 | + | customfield_f4[month] | January | + | customfield_f4[year] | 2019 | + | Field 5 | b | + And I press "Save and display" + And I navigate to "Settings" in current page administration + And the following fields match these values: + | Course full name | Course 2 | + | Course short name | C2 | + | Field 1 | testcontent1 | + | Field 2 | testcontent2 | + | Field 3 | 1 | + | customfield_f4[day] | 1 | + | customfield_f4[month] | January | + | customfield_f4[year] | 2019 | + | Field 5 | b | + And I log out + + @javascript @_file_upload + Scenario: Use images in the custom field description + When I log in as "admin" + And I follow "Manage private files" + And I upload "lib/tests/fixtures/gd-logo.png" file to "Files" filemanager + And I click on "Save changes" "button" + And I navigate to "Courses > Course custom fields" in site administration + And I click on "Edit" "link" in the "Field 1" "table_row" + And I select the text in the "Description" Atto editor + And I click on "Insert or edit image" "button" in the "Description" "form_row" + And I click on "Browse repositories..." "button" + And I click on "Private files" "link" in the ".fp-repo-area" "css_element" + And I click on "gd-logo.png" "link" + And I click on "Select this file" "button" + And I set the field "Describe this image for someone who cannot see it" to "Example" + And I click on "Save image" "button" + And I click on "Save changes" "button" in the "Updating Field 1" "dialogue" + And I log out + And I log in as "teacher1" + And I am on "Course 1" course homepage + And I navigate to "Settings" in current page administration + And I expand all fieldsets + Then the image at "//div[contains(@class, 'fitem')][contains(., 'Field 1')]/following-sibling::div[1]//img[contains(@src, 'pluginfile.php') and contains(@src, '/core_customfield/description/') and @alt='Example']" "xpath_element" should be identical to "lib/tests/fixtures/gd-logo.png" + And I log out + + @javascript + Scenario: Custom field short name must be present and unique + When I log in as "admin" + And I navigate to "Courses > Course custom fields" in site administration + And I click on "Add a new custom field" "link" + And I click on "Short text" "link" + And I set the following fields to these values: + | Name | Test field | + And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue" + Then I should see "You must supply a value here" in the "Short name" "form_row" + And I set the field "Short name" to "short name" + And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue" + And I should see "The short name can only contain alphanumeric lowercase characters and underscores (_)." in the "Short name" "form_row" + And I set the field "Short name" to "f1" + And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue" + And I should see "Short name already exists" in the "Short name" "form_row" + And I click on "Cancel" "button" in the "Adding a new Short text" "dialogue" + And I log out diff --git a/tests/behat/required_field.feature b/tests/behat/required_field.feature new file mode 100644 index 0000000..a164f11 --- /dev/null +++ b/tests/behat/required_field.feature @@ -0,0 +1,58 @@ +@core @core_course @core_customfield @javascript +Feature: Requiredness The course custom fields can be mandatory or not + In order to make users required to fill a custom field + As a manager + I can change the requiredness of the fields + + Background: + Given the following "custom field categories" exist: + | name | component | area | itemid | + | Category for test | core_course | course | 0 | + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | topics | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + + Scenario: A required course custom field must be filled when editing course settings + When I log in as "admin" + And I navigate to "Courses > Course custom fields" in site administration + And I click on "Add a new custom field" "link" + And I click on "Short text" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + | Required | Yes | + And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue" + And I log out + And I log in as "teacher1" + And I am on "Course 1" course homepage + And I navigate to "Settings" in current page administration + And I press "Save and display" + Then I should see "You must supply a value here" + And I set the field "Test field" to "some value" + And I press "Save and display" + And I should not see "This field is required" + And I log out + + Scenario: A course custom field that is not required may not be filled + When I log in as "admin" + And I navigate to "Courses > Course custom fields" in site administration + And I click on "Add a new custom field" "link" + And I click on "Short text" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + | Required | No | + And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue" + And I log out + And I log in as "teacher1" + And I am on "Course 1" course homepage + And I navigate to "Settings" in current page administration + And I press "Save and display" + Then I should see "Course 1" + And I should see "Topic 1" diff --git a/tests/behat/unique_field.feature b/tests/behat/unique_field.feature new file mode 100644 index 0000000..adb59d8 --- /dev/null +++ b/tests/behat/unique_field.feature @@ -0,0 +1,74 @@ +@core @core_course @core_customfield @javascript +Feature: Uniqueness The course custom fields can be mandatory or not + In order to make users required to fill a custom field + As a manager + I can change the uniqueness of the fields + + Background: + Given the following "custom field categories" exist: + | name | component | area | itemid | + | Category for test | core_course | course | 0 | + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | topics | + | Course 2 | C2 | topics | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | teacher1 | C2 | editingteacher | + When I log in as "admin" + And I navigate to "Courses > Course custom fields" in site administration + And I click on "Add a new custom field" "link" + And I click on "Short text" "link" + And I set the following fields to these values: + | Name | Test field | + | Short name | testfield | + | Unique data | Yes | + And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue" + And I log out + + Scenario: A course custom field with unique data must not allow same data in same field in different courses + When I log in as "teacher1" + And I am on "Course 1" course homepage + And I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Test field | testcontent | + And I press "Save and display" + And I am on "Course 2" course homepage + And I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Test field | testcontent | + And I press "Save and display" + Then I should see "This value is already used" + + Scenario: A course custom field with unique data must not compare with itself + When I log in as "teacher1" + And I am on "Course 1" course homepage + And I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Test field | testcontent | + And I press "Save and display" + And I am on "Course 1" course homepage + And I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Test field | testcontent | + And I press "Save and display" + Then I should not see "This value is already used" + And I should see "Topic 1" + + Scenario: A course custom field with unique data must allow empty data + When I log in as "teacher1" + And I am on "Course 1" course homepage + And I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Test field | | + And I press "Save and display" + And I am on "Course 2" course homepage + And I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Test field | | + And I press "Save and display" + Then I should not see "This value is already used" diff --git a/tests/category_controller_test.php b/tests/category_controller_test.php new file mode 100644 index 0000000..5179a41 --- /dev/null +++ b/tests/category_controller_test.php @@ -0,0 +1,251 @@ +. + +namespace core_customfield; + +use core_customfield_generator; + +/** + * Functional test for class \core_customfield\category_controller. + * + * @package core_customfield + * @category test + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class category_controller_test extends \advanced_testcase { + + /** + * Get generator. + * + * @return core_customfield_generator + */ + protected function get_generator(): core_customfield_generator { + return $this->getDataGenerator()->get_plugin_generator('core_customfield'); + } + + /** + * Test for the field_controller::__construct function. + */ + public function test_constructor() { + $this->resetAfterTest(); + + $c = category_controller::create(0, (object)['component' => 'core_course', 'area' => 'course', 'itemid' => 0]); + $handler = $c->get_handler(); + $this->assertTrue($c instanceof category_controller); + + $cat = $this->get_generator()->create_category(); + $c = category_controller::create($cat->get('id')); + $this->assertTrue($c instanceof category_controller); + + $c = category_controller::create($cat->get('id'), null, $handler); + $this->assertTrue($c instanceof category_controller); + + $c = category_controller::create(0, $cat->to_record()); + $this->assertTrue($c instanceof category_controller); + + $c = category_controller::create(0, $cat->to_record(), $handler); + $this->assertTrue($c instanceof category_controller); + } + + /** + * Test for function \core_customfield\field_controller::create() in case of wrong parameters + */ + public function test_constructor_errors() { + global $DB; + $this->resetAfterTest(); + + $cat = $this->get_generator()->create_category(); + $catrecord = $cat->to_record(); + + // Both id and record give warning. + $c = category_controller::create($catrecord->id, $catrecord); + $debugging = $this->getDebuggingMessages(); + $this->assertEquals(1, count($debugging)); + $this->assertEquals('Too many parameters, either id need to be specified or a record, but not both.', + $debugging[0]->message); + $this->resetDebugging(); + $this->assertTrue($c instanceof category_controller); + + // Retrieve non-existing data. + try { + category_controller::create($catrecord->id + 1); + $this->fail('Expected exception'); + } catch (\moodle_exception $e) { + $this->assertEquals('Category not found', $e->getMessage()); + $this->assertEquals(\moodle_exception::class, get_class($e)); + } + + // Missing required elements. + try { + category_controller::create(0, (object)['area' => 'course', 'itemid' => 0]); + $this->fail('Expected exception'); + } catch (\coding_exception $e) { + $this->assertEquals('Coding error detected, it must be fixed by a programmer: Not enough parameters ' . + 'to initialise category_controller - unknown component', $e->getMessage()); + $this->assertEquals(\coding_exception::class, get_class($e)); + } + + // Missing required elements. + try { + category_controller::create(0, (object)['component' => 'core_course', 'itemid' => 0]); + $this->fail('Expected exception'); + } catch (\coding_exception $e) { + $this->assertEquals('Coding error detected, it must be fixed by a programmer: Not enough parameters ' . + 'to initialise category_controller - unknown area', $e->getMessage()); + $this->assertEquals(\coding_exception::class, get_class($e)); + } + + // Missing required elements. + try { + category_controller::create(0, (object)['component' => 'core_course', 'area' => 'course']); + $this->fail('Expected exception'); + } catch (\coding_exception $e) { + $this->assertEquals('Coding error detected, it must be fixed by a programmer: Not enough parameters ' . + 'to initialise category_controller - unknown itemid', $e->getMessage()); + $this->assertEquals(\coding_exception::class, get_class($e)); + } + + $handler = \core_course\customfield\course_handler::create(); + // Missing required elements. + try { + category_controller::create(0, (object)['component' => 'x', 'area' => 'course', 'itemid' => 0], $handler); + $this->fail('Expected exception'); + } catch (\coding_exception $e) { + $this->assertEquals('Coding error detected, it must be fixed by a programmer: Component of the handler ' . + 'does not match the one from the record', $e->getMessage()); + $this->assertEquals(\coding_exception::class, get_class($e)); + } + + try { + category_controller::create(0, (object)['component' => 'core_course', 'area' => 'x', 'itemid' => 0], $handler); + $this->fail('Expected exception'); + } catch (\coding_exception $e) { + $this->assertEquals('Coding error detected, it must be fixed by a programmer: Area of the handler ' . + 'does not match the one from the record', $e->getMessage()); + $this->assertEquals(\coding_exception::class, get_class($e)); + } + + try { + category_controller::create(0, (object)['component' => 'core_course', 'area' => 'course', 'itemid' => 1], $handler); + $this->fail('Expected exception'); + } catch (\coding_exception $e) { + $this->assertEquals('Coding error detected, it must be fixed by a programmer: Itemid of the ' . + 'handler does not match the one from the record', $e->getMessage()); + $this->assertEquals(\coding_exception::class, get_class($e)); + } + + try { + $user = $this->getDataGenerator()->create_user(); + category_controller::create(0, (object)['component' => 'core_course', 'area' => 'course', 'itemid' => 0, + 'contextid' => \context_user::instance($user->id)->id], $handler); + $this->fail('Expected exception'); + } catch (\coding_exception $e) { + $this->assertEquals('Coding error detected, it must be fixed by a programmer: Context of the ' . + 'handler does not match the one from the record', $e->getMessage()); + $this->assertEquals(\coding_exception::class, get_class($e)); + } + } + + /** + * Tests for behaviour of: + * \core_customfield\category_controller::save() + * \core_customfield\category_controller::get() + */ + public function test_create_category() { + $this->resetAfterTest(); + + // Create the category. + $lpg = $this->get_generator(); + $categorydata = new \stdClass(); + $categorydata->name = 'Category1'; + $categorydata->component = 'core_course'; + $categorydata->area = 'course'; + $categorydata->itemid = 0; + $categorydata->contextid = \context_system::instance()->id; + $category = category_controller::create(0, $categorydata); + $category->save(); + $this->assertNotEmpty($category->get('id')); + + // Confirm record exists. + $this->assertTrue(\core_customfield\category::record_exists($category->get('id'))); + + // Confirm that base data was inserted correctly. + $category = category_controller::create($category->get('id')); + $this->assertSame($category->get('name'), $categorydata->name); + $this->assertSame($category->get('component'), $categorydata->component); + $this->assertSame($category->get('area'), $categorydata->area); + $this->assertSame((int)$category->get('itemid'), $categorydata->itemid); + } + + /** + * Tests for \core_customfield\category_controller::set() behaviour. + */ + public function test_rename_category() { + $this->resetAfterTest(); + + // Create the category. + $params = ['component' => 'core_course', 'area' => 'course', 'itemid' => 0, 'name' => 'Cat1', + 'contextid' => \context_system::instance()->id]; + $c1 = category_controller::create(0, (object)$params); + $c1->save(); + $this->assertNotEmpty($c1->get('id')); + + // Checking new name are correct updated. + $category = category_controller::create($c1->get('id')); + $category->set('name', 'Cat2'); + $this->assertSame('Cat2', $category->get('name')); + + // Checking new name are correct updated after save. + $category->save(); + + $category = category_controller::create($c1->get('id')); + $this->assertSame('Cat2', $category->get('name')); + } + + /** + * Tests for \core_customfield\category_controller::delete() behaviour. + */ + public function test_delete_category() { + $this->resetAfterTest(); + + // Create the category. + $lpg = $this->get_generator(); + $category0 = $lpg->create_category(); + $id0 = $category0->get('id'); + + $category1 = $lpg->create_category(); + $id1 = $category1->get('id'); + + $category2 = $lpg->create_category(); + $id2 = $category2->get('id'); + + // Confirm that exist in the database. + $this->assertTrue(\core_customfield\category::record_exists($id0)); + + // Delete and confirm that is deleted. + $category0->delete(); + $this->assertFalse(\core_customfield\category::record_exists($id0)); + + // Confirm correct order after delete. + // Check order after re-fetch. + $category1 = category_controller::create($id1); + $category2 = category_controller::create($id2); + + $this->assertSame((int) $category1->get('sortorder'), 1); + $this->assertSame((int) $category2->get('sortorder'), 2); + } +} diff --git a/tests/data_controller_test.php b/tests/data_controller_test.php new file mode 100644 index 0000000..56b86c2 --- /dev/null +++ b/tests/data_controller_test.php @@ -0,0 +1,180 @@ +. + +namespace core_customfield; + +use core_customfield_generator; +use customfield_checkbox; +use customfield_date; +use customfield_select; +use customfield_text; +use customfield_textarea; + +/** + * Functional test for class data_controller. + * + * @package core_customfield + * @category test + * @copyright 2018 Toni Barbera + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class data_controller_test extends \advanced_testcase { + + /** + * Get generator. + * + * @return core_customfield_generator + */ + protected function get_generator(): core_customfield_generator { + return $this->getDataGenerator()->get_plugin_generator('core_customfield'); + } + + /** + * Test for function data_controller::create() + */ + public function test_constructor() { + global $DB; + $this->resetAfterTest(); + + // Create a course, fields category and fields. + $course = $this->getDataGenerator()->create_course(); + $category0 = $this->get_generator()->create_category(['name' => 'aaaa']); + + // Add fields to this category. + $fielddata = new \stdClass(); + $fielddata->categoryid = $category0->get('id'); + $fielddata->configdata = "{\"required\":\"0\",\"uniquevalues\":\"0\",\"locked\":\"0\",\"visibility\":\"0\", + \"defaultvalue\":\"\",\"displaysize\":0,\"maxlength\":0,\"ispassword\":\"0\", + \"link\":\"\",\"linktarget\":\"\"}"; + + $fielddata->type = 'checkbox'; + $field0 = $this->get_generator()->create_field($fielddata); + $fielddata->type = 'date'; + $field1 = $this->get_generator()->create_field($fielddata); + $fielddata->type = 'select'; + $field2 = $this->get_generator()->create_field($fielddata); + $fielddata->type = 'text'; + $field3 = $this->get_generator()->create_field($fielddata); + $fielddata->type = 'textarea'; + $field4 = $this->get_generator()->create_field($fielddata); + + $params = ['instanceid' => $course->id, 'contextid' => \context_course::instance($course->id)->id]; + + // Generate new data_controller records for these fields, specifying field controller or fieldid or both. + $data0 = data_controller::create(0, (object)$params, $field0); + $this->assertInstanceOf(customfield_checkbox\data_controller::class, $data0); + $data1 = data_controller::create(0, + (object)($params + ['fieldid' => $field1->get('id')]), $field1); + $this->assertInstanceOf(customfield_date\data_controller::class, $data1); + $data2 = data_controller::create(0, + (object)($params + ['fieldid' => $field2->get('id')])); + $this->assertInstanceOf(customfield_select\data_controller::class, $data2); + $data3 = data_controller::create(0, (object)$params, $field3); + $this->assertInstanceOf(customfield_text\data_controller::class, $data3); + $data4 = data_controller::create(0, (object)$params, $field4); + $this->assertInstanceOf(customfield_textarea\data_controller::class, $data4); + + // Save data so we can have ids. + $data0->save(); + $data1->save(); + $data2->save(); + $data3->save(); + $data4->save(); + + // Retrieve data by id. + $this->assertInstanceOf(customfield_checkbox\data_controller::class, data_controller::create($data0->get('id'))); + $this->assertInstanceOf(customfield_date\data_controller::class, data_controller::create($data1->get('id'))); + + // Retrieve data by id and field. + $this->assertInstanceOf(customfield_select\data_controller::class, + data_controller::create($data2->get('id'), null, $field2)); + + // Retrieve data by record without field. + $datarecord = $DB->get_record(\core_customfield\data::TABLE, ['id' => $data3->get('id')], '*', MUST_EXIST); + $this->assertInstanceOf(customfield_text\data_controller::class, data_controller::create(0, $datarecord)); + + // Retrieve data by record with field. + $datarecord = $DB->get_record(\core_customfield\data::TABLE, ['id' => $data4->get('id')], '*', MUST_EXIST); + $this->assertInstanceOf(customfield_textarea\data_controller::class, data_controller::create(0, $datarecord, $field4)); + + } + + /** + * Test for function \core_customfield\field_controller::create() in case of wrong parameters + */ + public function test_constructor_errors() { + global $DB; + $this->resetAfterTest(); + + // Create a category, field and data. + $category = $this->get_generator()->create_category(); + $field = $this->get_generator()->create_field(['categoryid' => $category->get('id')]); + $course = $this->getDataGenerator()->create_course(); + $data = data_controller::create(0, (object)['instanceid' => $course->id, + 'contextid' => \context_course::instance($course->id)->id], $field); + $data->save(); + + $datarecord = $DB->get_record(\core_customfield\data::TABLE, ['id' => $data->get('id')], '*', MUST_EXIST); + + // Both id and record give warning. + $d = data_controller::create($datarecord->id, $datarecord); + $debugging = $this->getDebuggingMessages(); + $this->assertEquals(1, count($debugging)); + $this->assertEquals('Too many parameters, either id need to be specified or a record, but not both.', + $debugging[0]->message); + $this->resetDebugging(); + $this->assertInstanceOf(customfield_text\data_controller::class, $d); + + // Retrieve non-existing data. + try { + data_controller::create($datarecord->id + 1); + $this->fail('Expected exception'); + } catch (\dml_missing_record_exception $e) { + $this->assertStringMatchesFormat('Can\'t find data record in database table customfield_data%a', $e->getMessage()); + $this->assertEquals(\dml_missing_record_exception::class, get_class($e)); + } + + // Missing field id. + try { + data_controller::create(0, (object)['instanceid' => $course->id]); + $this->fail('Expected exception'); + } catch (\coding_exception $e) { + $this->assertEquals('Coding error detected, it must be fixed by a programmer: Not enough parameters to ' . + 'initialise data_controller - unknown field', $e->getMessage()); + $this->assertEquals(\coding_exception::class, get_class($e)); + } + + // Mismatching field id. + try { + data_controller::create(0, (object)['instanceid' => $course->id, 'fieldid' => $field->get('id') + 1], $field); + $this->fail('Expected exception'); + } catch (\coding_exception $e) { + $this->assertEquals('Coding error detected, it must be fixed by a programmer: Field id from the record ' . + 'does not match field from the parameter', $e->getMessage()); + $this->assertEquals(\coding_exception::class, get_class($e)); + } + + // Nonexisting class. + try { + $field->set('type', 'invalid'); + data_controller::create(0, (object)['instanceid' => $course->id], $field); + $this->fail('Expected exception'); + } catch (\moodle_exception $e) { + $this->assertEquals('Field type invalid not found', $e->getMessage()); + $this->assertEquals(\moodle_exception::class, get_class($e)); + } + } +} diff --git a/tests/field_controller_test.php b/tests/field_controller_test.php new file mode 100644 index 0000000..1600310 --- /dev/null +++ b/tests/field_controller_test.php @@ -0,0 +1,245 @@ +. + +namespace core_customfield; + +use core_customfield_generator; +use customfield_checkbox; +use customfield_date; +use customfield_select; +use customfield_text; +use customfield_textarea; + +/** + * Functional test for class \core_customfield\field_controller. + * + * @package core_customfield + * @category test + * @copyright 2018 Ruslan Kabalin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class field_controller_test extends \advanced_testcase { + + /** + * Get generator. + * + * @return core_customfield_generator + */ + protected function get_generator(): core_customfield_generator { + return $this->getDataGenerator()->get_plugin_generator('core_customfield'); + } + + /** + * Test for function \core_customfield\field_controller::create() + */ + public function test_constructor() { + global $DB; + $this->resetAfterTest(); + + // Create the category. + $category0 = $this->get_generator()->create_category(); + + // Initiate objects without id, try with the category object or with category id or with both. + $field0 = field_controller::create(0, (object)['type' => 'checkbox'], $category0); + $this->assertInstanceOf(customfield_checkbox\field_controller::class, $field0); + $field1 = field_controller::create(0, (object)['type' => 'date', 'categoryid' => $category0->get('id')]); + $this->assertInstanceOf(customfield_date\field_controller::class, $field1); + $field2 = field_controller::create(0, (object)['type' => 'select', 'categoryid' => $category0->get('id')], $category0); + $this->assertInstanceOf(customfield_select\field_controller::class, $field2); + $field3 = field_controller::create(0, (object)['type' => 'text'], $category0); + $this->assertInstanceOf(customfield_text\field_controller::class, $field3); + $field4 = field_controller::create(0, (object)['type' => 'textarea'], $category0); + $this->assertInstanceOf(customfield_textarea\field_controller::class, $field4); + + // Save fields to the db so we have ids. + \core_customfield\api::save_field_configuration($field0, (object)['name' => 'a', 'shortname' => 'a']); + \core_customfield\api::save_field_configuration($field1, (object)['name' => 'b', 'shortname' => 'b']); + \core_customfield\api::save_field_configuration($field2, (object)['name' => 'c', 'shortname' => 'c']); + \core_customfield\api::save_field_configuration($field3, (object)['name' => 'd', 'shortname' => 'd']); + \core_customfield\api::save_field_configuration($field4, (object)['name' => 'e', 'shortname' => 'e']); + + // Retrieve fields by id. + $this->assertInstanceOf(customfield_checkbox\field_controller::class, field_controller::create($field0->get('id'))); + $this->assertInstanceOf(customfield_date\field_controller::class, field_controller::create($field1->get('id'))); + + // Retrieve field by id and category. + $this->assertInstanceOf(customfield_select\field_controller::class, + field_controller::create($field2->get('id'), null, $category0)); + + // Retrieve fields by record without category. + $fieldrecord = $DB->get_record(\core_customfield\field::TABLE, ['id' => $field3->get('id')], '*', MUST_EXIST); + $this->assertInstanceOf(customfield_text\field_controller::class, field_controller::create(0, $fieldrecord)); + + // Retrieve fields by record with category. + $fieldrecord = $DB->get_record(\core_customfield\field::TABLE, ['id' => $field4->get('id')], '*', MUST_EXIST); + $this->assertInstanceOf(customfield_textarea\field_controller::class, + field_controller::create(0, $fieldrecord, $category0)); + } + + /** + * Test for function \core_customfield\field_controller::create() in case of wrong parameters + */ + public function test_constructor_errors() { + global $DB; + $this->resetAfterTest(); + + // Create a category and a field. + $category = $this->get_generator()->create_category(); + $field = $this->get_generator()->create_field(['categoryid' => $category->get('id')]); + + $fieldrecord = $DB->get_record(\core_customfield\field::TABLE, ['id' => $field->get('id')], '*', MUST_EXIST); + + // Both id and record give warning. + $field = field_controller::create($fieldrecord->id, $fieldrecord); + $debugging = $this->getDebuggingMessages(); + $this->assertEquals(1, count($debugging)); + $this->assertEquals('Too many parameters, either id need to be specified or a record, but not both.', + $debugging[0]->message); + $this->resetDebugging(); + $this->assertInstanceOf(customfield_text\field_controller::class, $field); + + // Retrieve non-existing field. + try { + field_controller::create($fieldrecord->id + 1); + $this->fail('Expected exception'); + } catch (\moodle_exception $e) { + $this->assertEquals('Field not found', $e->getMessage()); + $this->assertEquals(\moodle_exception::class, get_class($e)); + } + + // Retrieve without id and without type. + try { + field_controller::create(0, (object)['name' => 'a'], $category); + $this->fail('Expected exception'); + } catch (\coding_exception $e) { + $this->assertEquals('Coding error detected, it must be fixed by a programmer: Not enough parameters to ' . + 'initialise field_controller - unknown field type', $e->getMessage()); + $this->assertEquals(\coding_exception::class, get_class($e)); + } + + // Missing category id. + try { + field_controller::create(0, (object)['type' => 'text']); + $this->fail('Expected exception'); + } catch (\coding_exception $e) { + $this->assertEquals('Coding error detected, it must be fixed by a programmer: Not enough parameters ' . + 'to initialise field_controller - unknown category', $e->getMessage()); + $this->assertEquals(\coding_exception::class, get_class($e)); + } + + // Mismatching category id. + try { + field_controller::create(0, (object)['type' => 'text', 'categoryid' => $category->get('id') + 1], $category); + $this->fail('Expected exception'); + } catch (\coding_exception $e) { + $this->assertEquals('Coding error detected, it must be fixed by a programmer: Category of the field ' . + 'does not match category from the parameter', $e->getMessage()); + $this->assertEquals(\coding_exception::class, get_class($e)); + } + + // Non-existing type. + try { + field_controller::create(0, (object)['type' => 'nonexisting'], $category); + $this->fail('Expected exception'); + } catch (\moodle_exception $e) { + $this->assertEquals('Field type nonexisting not found', $e->getMessage()); + $this->assertEquals(\moodle_exception::class, get_class($e)); + } + } + + /** + * Tests for behaviour of: + * \core_customfield\field_controller::save() + * \core_customfield\field_controller::get() + * \core_customfield\field_controller::get_category() + */ + public function test_create_field() { + global $DB; + $this->resetAfterTest(); + + $lpg = $this->get_generator(); + $category = $lpg->create_category(); + $fields = $DB->get_records(\core_customfield\field::TABLE, ['categoryid' => $category->get('id')]); + $this->assertCount(0, $fields); + + // Create field. + $fielddata = new \stdClass(); + $fielddata->name = 'Field'; + $fielddata->shortname = 'field'; + $fielddata->type = 'text'; + $fielddata->categoryid = $category->get('id'); + $field = field_controller::create(0, $fielddata); + $field->save(); + + $fields = $DB->get_records(\core_customfield\field::TABLE, ['categoryid' => $category->get('id')]); + $this->assertCount(1, $fields); + $this->assertTrue(\core_customfield\field::record_exists($field->get('id'))); + $this->assertInstanceOf(\customfield_text\field_controller::class, $field); + $this->assertSame($field->get('name'), $fielddata->name); + $this->assertSame($field->get('type'), $fielddata->type); + $this->assertEquals($field->get_category()->get('id'), $category->get('id')); + } + + /** + * Tests for \core_customfield\field_controller::delete() behaviour. + */ + public function test_delete_field() { + global $DB; + $this->resetAfterTest(); + + $lpg = $this->get_generator(); + $category = $lpg->create_category(); + $fields = $DB->get_records(\core_customfield\field::TABLE, ['categoryid' => $category->get('id')]); + $this->assertCount(0, $fields); + + // Create field using generator. + $field1 = $lpg->create_field(array('categoryid' => $category->get('id'))); + $field2 = $lpg->create_field(array('categoryid' => $category->get('id'))); + $fields = $DB->get_records(\core_customfield\field::TABLE, ['categoryid' => $category->get('id')]); + $this->assertCount(2, $fields); + + // Delete fields. + $this->assertTrue($field1->delete()); + $this->assertTrue($field2->delete()); + + // Check that the fields have been deleted. + $fields = $DB->get_records(\core_customfield\field::TABLE, ['categoryid' => $category->get('id')]); + $this->assertCount(0, $fields); + $this->assertFalse(\core_customfield\field::record_exists($field1->get('id'))); + $this->assertFalse(\core_customfield\field::record_exists($field2->get('id'))); + } + + /** + * Tests for \core_customfield\field_controller::get_configdata_property() behaviour. + */ + public function test_get_configdata_property() { + $this->resetAfterTest(); + + $lpg = $this->get_generator(); + $category = $lpg->create_category(); + $configdata = ['a' => 'b', 'c' => ['d', 'e']]; + $field = field_controller::create(0, (object)['type' => 'text', + 'configdata' => json_encode($configdata), 'shortname' => 'a', 'name' => 'a'], $category); + $field->save(); + + // Retrieve field and check configdata. + $field = field_controller::create($field->get('id')); + $this->assertEquals($configdata, $field->get('configdata')); + $this->assertEquals('b', $field->get_configdata_property('a')); + $this->assertEquals(['d', 'e'], $field->get_configdata_property('c')); + $this->assertEquals(null, $field->get_configdata_property('x')); + } +} diff --git a/tests/fixtures/test_instance_form.php b/tests/fixtures/test_instance_form.php new file mode 100644 index 0000000..97fce04 --- /dev/null +++ b/tests/fixtures/test_instance_form.php @@ -0,0 +1,79 @@ +. + +/** + * Class core_customfield_test_instance_form + * + * @package core_customfield + * @copyright 2019 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . '/formslib.php'); + +/** + * Class core_customfield_test_instance_form + * + * @package core_customfield + * @copyright 2019 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_customfield_test_instance_form extends moodleform { + /** @var \core_customfield\handler */ + protected $handler; + + /** @var stdClass */ + protected $instance; + + /** + * Form definition + */ + public function definition() { + $this->handler = $this->_customdata['handler']; + $this->instance = $this->_customdata['instance']; + + $this->_form->addElement('hidden', 'id'); + $this->_form->setType('id', PARAM_INT); + + $this->handler->instance_form_definition($this->_form, $this->instance->id); + + $this->add_action_buttons(); + + $this->handler->instance_form_before_set_data($this->instance); + $this->set_data($this->instance); + } + + /** + * Definition after data + */ + public function definition_after_data() { + $this->handler->instance_form_definition_after_data($this->_form, $this->instance->id); + } + + /** + * Form validation + * + * @param array $data + * @param array $files + * @return array + */ + public function validation($data, $files) { + return $this->handler->instance_form_validation($data, $files); + } +} diff --git a/tests/generator/lib.php b/tests/generator/lib.php new file mode 100644 index 0000000..06cfe47 --- /dev/null +++ b/tests/generator/lib.php @@ -0,0 +1,164 @@ +. + +/** + * Customfield data generator. + * + * @package core_customfield + * @category test + * @copyright 2018 Ruslan Kabalin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use \core_customfield\category_controller; +use \core_customfield\field_controller; +use \core_customfield\api; + +/** + * Customfield data generator class. + * + * @package core_customfield + * @category test + * @copyright 2018 Ruslan Kabalin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_customfield_generator extends component_generator_base { + + /** @var int Number of created categories. */ + protected $categorycount = 0; + + /** @var int Number of created fields. */ + protected $fieldcount = 0; + + /** + * Create a new category. + * + * @param array|stdClass $record + * @return category_controller + */ + public function create_category($record = null) { + $this->categorycount++; + $i = $this->categorycount; + $record = (object) $record; + + if (!isset($record->name)) { + $record->name = "Category $i"; + } + if (!isset($record->component)) { + $record->component = 'core_course'; + } + if (!isset($record->area)) { + $record->area = 'course'; + } + if (!isset($record->itemid)) { + $record->itemid = 0; + } + + $handler = \core_customfield\handler::get_handler($record->component, $record->area, $record->itemid); + $categoryid = $handler->create_category($record->name); + return $handler->get_categories_with_fields()[$categoryid]; + } + + /** + * Create a new field. + * + * @param array|stdClass $record + * @return field_controller + */ + public function create_field($record) : field_controller { + $this->fieldcount++; + $i = $this->fieldcount; + $record = (object) $record; + + if (empty($record->categoryid)) { + throw new coding_exception('The categoryid value is required.'); + } + $category = category_controller::create($record->categoryid); + $handler = $category->get_handler(); + + if (!isset($record->name)) { + $record->name = "Field $i"; + } + if (!isset($record->shortname)) { + $record->shortname = "fld$i"; + } + if (!isset($record->description)) { + $record->description = "Field $i description"; + } + if (!isset($record->descriptionformat)) { + $record->descriptionformat = FORMAT_HTML; + } + if (!isset($record->type)) { + $record->type = 'text'; + } + if (!isset($record->sortorder)) { + $record->sortorder = 0; + } + + if (empty($record->configdata)) { + $configdata = []; + } else if (is_array($record->configdata)) { + $configdata = $record->configdata; + } else { + $configdata = @json_decode($record->configdata, true); + $configdata = $configdata ?? []; + } + $configdata += [ + 'required' => 0, + 'uniquevalues' => 0, + 'locked' => 0, + 'visibility' => 2, + 'defaultvalue' => '', + 'displaysize' => 0, + 'maxlength' => 0, + 'ispassword' => 0, + 'link' => '', + 'linktarget' => '', + 'checkbydefault' => 0, + 'startyear' => 2000, + 'endyear' => 3000, + 'includetime' => 1, + ]; + $record->configdata = json_encode($configdata); + + $field = field_controller::create(0, (object)['type' => $record->type], $category); + $handler->save_field_configuration($field, $record); + return $handler->get_categories_with_fields()[$field->get('categoryid')]->get_fields()[$field->get('id')]; + } + + /** + * Adds instance data for one field + * + * @param field_controller $field + * @param int $instanceid + * @param mixed $value + * @return \core_customfield\data_controller + */ + public function add_instance_data(field_controller $field, int $instanceid, $value) : \core_customfield\data_controller { + $data = \core_customfield\data_controller::create(0, (object)['instanceid' => $instanceid], $field); + $data->set('contextid', $data->get_context()->id); + + $rc = new ReflectionClass(get_class($data)); + $rcm = $rc->getMethod('get_form_element_name'); + $rcm->setAccessible(true); + $formelementname = $rcm->invokeArgs($data, []); + $record = (object)[$formelementname => $value]; + $data->instance_form_save($record); + return $data; + } +} diff --git a/tests/generator_test.php b/tests/generator_test.php new file mode 100644 index 0000000..20f17b0 --- /dev/null +++ b/tests/generator_test.php @@ -0,0 +1,105 @@ +. + +namespace core_customfield; + +/** + * core_customfield test data generator testcase. + * + * @package core_customfield + * @category test + * @copyright 2018 Ruslan Kabalin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class generator_test extends \advanced_testcase { + + /** + * Get generator + * @return core_customfield_generator + */ + protected function get_generator(): \core_customfield_generator { + return $this->getDataGenerator()->get_plugin_generator('core_customfield'); + } + + /** + * Test creating category + */ + public function test_create_category() { + $this->resetAfterTest(true); + + $lpg = $this->get_generator(); + $category = $lpg->create_category(); + + $this->assertInstanceOf('\core_customfield\category_controller', $category); + $this->assertTrue(\core_customfield\category::record_exists($category->get('id'))); + } + + /** + * Test creating field + */ + public function test_create_field() { + $this->resetAfterTest(true); + + $lpg = $this->get_generator(); + $category = $lpg->create_category(); + $field = $lpg->create_field(['categoryid' => $category->get('id')]); + + $this->assertInstanceOf('\core_customfield\field_controller', $field); + $this->assertTrue(\core_customfield\field::record_exists($field->get('id'))); + + $category = \core_customfield\category_controller::create($category->get('id')); + $category = \core_customfield\api::get_categories_with_fields($category->get('component'), + $category->get('area'), $category->get('itemid'))[$category->get('id')]; + $this->assertCount(1, $category->get_fields()); + } + + /** + * Test for function add_instance_data() + */ + public function test_add_instance_data() { + $this->resetAfterTest(true); + + $lpg = $this->get_generator(); + $c1 = $lpg->create_category(); + $course1 = $this->getDataGenerator()->create_course(); + + $f11 = $this->get_generator()->create_field(['categoryid' => $c1->get('id'), 'type' => 'checkbox']); + $f12 = $this->get_generator()->create_field(['categoryid' => $c1->get('id'), 'type' => 'date']); + $f13 = $this->get_generator()->create_field(['categoryid' => $c1->get('id'), + 'type' => 'select', 'configdata' => ['options' => "a\nb\nc"]]); + $f14 = $this->get_generator()->create_field(['categoryid' => $c1->get('id'), 'type' => 'text']); + $f15 = $this->get_generator()->create_field(['categoryid' => $c1->get('id'), 'type' => 'textarea']); + + $this->get_generator()->add_instance_data($f11, $course1->id, 1); + $this->get_generator()->add_instance_data($f12, $course1->id, 1546300800); + $this->get_generator()->add_instance_data($f13, $course1->id, 2); + $this->get_generator()->add_instance_data($f14, $course1->id, 'Hello'); + $this->get_generator()->add_instance_data($f15, $course1->id, ['text' => '

Hi there

', 'format' => FORMAT_HTML]); + + $handler = $c1->get_handler(); + list($data1, $data2, $data3, $data4, $data5) = array_values($handler->get_instance_data($course1->id)); + $this->assertNotEmpty($data1->get('id')); + $this->assertEquals(1, $data1->get_value()); + $this->assertNotEmpty($data2->get('id')); + $this->assertEquals(1546300800, $data2->get_value()); + $this->assertNotEmpty($data3->get('id')); + $this->assertEquals(2, $data3->get_value()); + $this->assertNotEmpty($data4->get('id')); + $this->assertEquals('Hello', $data4->get_value()); + $this->assertNotEmpty($data5->get('id')); + $this->assertEquals('

Hi there

', $data5->get_value()); + } +} diff --git a/tests/privacy/provider_test.php b/tests/privacy/provider_test.php new file mode 100644 index 0000000..8ee9c41 --- /dev/null +++ b/tests/privacy/provider_test.php @@ -0,0 +1,290 @@ +. + +/** + * Class provider_test + * + * @package core_customfield + * @copyright 2019 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core_customfield\privacy; + +defined('MOODLE_INTERNAL') || die(); + +use core_privacy\tests\provider_testcase; +use core_privacy\local\request\approved_contextlist; +use core_privacy\local\request\writer; +use core_customfield\privacy\provider; + +/** + * Class provider_test + * + * @package core_customfield + * @copyright 2019 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider_test extends provider_testcase { + + /** + * Generate data. + * + * @return array + */ + protected function generate_test_data(): array { + $this->resetAfterTest(); + + $generator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); + $cfcats[1] = $generator->create_category(); + $cfcats[2] = $generator->create_category(); + $cffields[11] = $generator->create_field( + ['categoryid' => $cfcats[1]->get('id'), 'type' => 'checkbox']); + $cffields[12] = $generator->create_field( + ['categoryid' => $cfcats[1]->get('id'), 'type' => 'date']); + $cffields[13] = $generator->create_field( + ['categoryid' => $cfcats[1]->get('id'), + 'type' => 'select', 'configdata' => ['options' => "a\nb\nc"]]); + $cffields[14] = $generator->create_field( + ['categoryid' => $cfcats[1]->get('id'), 'type' => 'text']); + $cffields[15] = $generator->create_field( + ['categoryid' => $cfcats[1]->get('id'), 'type' => 'textarea']); + $cffields[21] = $generator->create_field( + ['categoryid' => $cfcats[2]->get('id')]); + $cffields[22] = $generator->create_field( + ['categoryid' => $cfcats[2]->get('id')]); + + $courses[1] = $this->getDataGenerator()->create_course(); + $courses[2] = $this->getDataGenerator()->create_course(); + $courses[3] = $this->getDataGenerator()->create_course(); + + $generator->add_instance_data($cffields[11], $courses[1]->id, 1); + $generator->add_instance_data($cffields[12], $courses[1]->id, 1546300800); + $generator->add_instance_data($cffields[13], $courses[1]->id, 2); + $generator->add_instance_data($cffields[14], $courses[1]->id, 'Hello1'); + $generator->add_instance_data($cffields[15], $courses[1]->id, + ['text' => '

Hi there

', 'format' => FORMAT_HTML]); + + $generator->add_instance_data($cffields[21], $courses[1]->id, 'hihi1'); + + $generator->add_instance_data($cffields[14], $courses[2]->id, 'Hello2'); + + $generator->add_instance_data($cffields[21], $courses[2]->id, 'hihi2'); + + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + return [ + 'user' => $user, + 'cfcats' => $cfcats, + 'cffields' => $cffields, + 'courses' => $courses, + ]; + } + + /** + * Test for provider::get_metadata() + */ + public function test_get_metadata() { + $collection = new \core_privacy\local\metadata\collection('core_customfield'); + $collection = provider::get_metadata($collection); + $this->assertNotEmpty($collection); + } + + /** + * Test for provider::get_customfields_data_contexts + */ + public function test_get_customfields_data_contexts() { + global $DB; + [ + 'cffields' => $cffields, + 'cfcats' => $cfcats, + 'courses' => $courses, + ] = $this->generate_test_data(); + + list($sql, $params) = $DB->get_in_or_equal([$courses[1]->id, $courses[2]->id], SQL_PARAMS_NAMED); + $r = provider::get_customfields_data_contexts('core_course', 'course', '=0', + $sql, $params); + $this->assertEqualsCanonicalizing([\context_course::instance($courses[1]->id)->id, + \context_course::instance($courses[2]->id)->id], + $r->get_contextids()); + } + + /** + * Test for provider::get_customfields_configuration_contexts() + */ + public function test_get_customfields_configuration_contexts() { + $this->generate_test_data(); + + $r = provider::get_customfields_configuration_contexts('core_course', 'course'); + $this->assertEquals([\context_system::instance()->id], $r->get_contextids()); + } + + /** + * Test for provider::export_customfields_data() + */ + public function test_export_customfields_data() { + global $USER, $DB; + $this->resetAfterTest(); + [ + 'cffields' => $cffields, + 'cfcats' => $cfcats, + 'courses' => $courses, + ] = $this->generate_test_data(); + + // Hack one of the fields so it has an invalid field type. + $invalidfieldid = $cffields[21]->get('id'); + $DB->update_record('customfield_field', ['id' => $invalidfieldid, 'type' => 'invalid']); + + $context = \context_course::instance($courses[1]->id); + $contextlist = new approved_contextlist($USER, 'core_customfield', [$context->id]); + provider::export_customfields_data($contextlist, 'core_course', 'course', '=0', '=:i', ['i' => $courses[1]->id]); + /** @var core_privacy\tests\request\content_writer $writer */ + $writer = writer::with_context($context); + + // Make sure that all and only data for the course1 was exported. + // There is no way to fetch all data from writer as array so we need to fetch one-by-one for each data id. + $invaldfieldischecked = false; + foreach ($DB->get_records('customfield_data', []) as $dbrecord) { + $data = $writer->get_data(['Custom fields data', $dbrecord->id]); + if ($dbrecord->instanceid == $courses[1]->id) { + $this->assertEquals($dbrecord->fieldid, $data->fieldid); + $this->assertNotEmpty($data->fieldtype); + $this->assertNotEmpty($data->fieldshortname); + $this->assertNotEmpty($data->fieldname); + $invaldfieldischecked = $invaldfieldischecked ?: ($data->fieldid == $invalidfieldid); + } else { + $this->assertEmpty($data); + } + } + + // Make sure field with was checked in this test. + $this->assertTrue($invaldfieldischecked); + } + + /** + * Test for provider::delete_customfields_data() + */ + public function test_delete_customfields_data() { + global $USER, $DB; + $this->resetAfterTest(); + [ + 'cffields' => $cffields, + 'cfcats' => $cfcats, + 'courses' => $courses, + ] = $this->generate_test_data(); + + $approvedcontexts = new approved_contextlist($USER, 'core_course', [\context_course::instance($courses[1]->id)->id]); + provider::delete_customfields_data($approvedcontexts, 'core_course', 'course'); + $this->assertEmpty($DB->get_records('customfield_data', ['instanceid' => $courses[1]->id])); + $this->assertNotEmpty($DB->get_records('customfield_data', ['instanceid' => $courses[2]->id])); + } + + /** + * Test for provider::delete_customfields_configuration() + */ + public function test_delete_customfields_configuration() { + global $USER, $DB; + $this->resetAfterTest(); + [ + 'cffields' => $cffields, + 'cfcats' => $cfcats, + 'courses' => $courses, + ] = $this->generate_test_data(); + + // Remember the list of fields in the category 2 before we delete it. + $catid1 = $cfcats[1]->get('id'); + $catid2 = $cfcats[2]->get('id'); + $fids2 = $DB->get_fieldset_select('customfield_field', 'id', 'categoryid=?', [$catid2]); + $this->assertNotEmpty($fids2); + list($fsql, $fparams) = $DB->get_in_or_equal($fids2, SQL_PARAMS_NAMED); + $this->assertNotEmpty($DB->get_records_select('customfield_data', 'fieldid ' . $fsql, $fparams)); + + // A little hack here, modify customfields configuration so they have different itemids. + $DB->update_record('customfield_category', ['id' => $catid2, 'itemid' => 1]); + $contextlist = new approved_contextlist($USER, 'core_course', [\context_system::instance()->id]); + provider::delete_customfields_configuration($contextlist, 'core_course', 'course', '=:i', ['i' => 1]); + + // Make sure everything for category $catid2 is gone but present for $catid1. + $this->assertEmpty($DB->get_records('customfield_category', ['id' => $catid2])); + $this->assertEmpty($DB->get_records_select('customfield_field', 'id ' . $fsql, $fparams)); + $this->assertEmpty($DB->get_records_select('customfield_data', 'fieldid ' . $fsql, $fparams)); + + $this->assertNotEmpty($DB->get_records('customfield_category', ['id' => $catid1])); + $fids1 = $DB->get_fieldset_select('customfield_field', 'id', 'categoryid=?', [$catid1]); + list($fsql1, $fparams1) = $DB->get_in_or_equal($fids1, SQL_PARAMS_NAMED); + $this->assertNotEmpty($DB->get_records_select('customfield_field', 'id ' . $fsql1, $fparams1)); + $this->assertNotEmpty($DB->get_records_select('customfield_data', 'fieldid ' . $fsql1, $fparams1)); + } + + /** + * Test for provider::delete_customfields_configuration_for_context() + */ + public function test_delete_customfields_configuration_for_context() { + global $USER, $DB; + $this->resetAfterTest(); + [ + 'cffields' => $cffields, + 'cfcats' => $cfcats, + 'courses' => $courses, + ] = $this->generate_test_data(); + + // Remember the list of fields in the category 2 before we delete it. + $catid1 = $cfcats[1]->get('id'); + $catid2 = $cfcats[2]->get('id'); + $fids2 = $DB->get_fieldset_select('customfield_field', 'id', 'categoryid=?', [$catid2]); + $this->assertNotEmpty($fids2); + list($fsql, $fparams) = $DB->get_in_or_equal($fids2, SQL_PARAMS_NAMED); + $this->assertNotEmpty($DB->get_records_select('customfield_data', 'fieldid ' . $fsql, $fparams)); + + // A little hack here, modify customfields configuration so they have different contexts. + $context = \context_user::instance($USER->id); + $DB->update_record('customfield_category', ['id' => $catid2, 'contextid' => $context->id]); + provider::delete_customfields_configuration_for_context('core_course', 'course', $context); + + // Make sure everything for category $catid2 is gone but present for $catid1. + $this->assertEmpty($DB->get_records('customfield_category', ['id' => $catid2])); + $this->assertEmpty($DB->get_records_select('customfield_field', 'id ' . $fsql, $fparams)); + $this->assertEmpty($DB->get_records_select('customfield_data', 'fieldid ' . $fsql, $fparams)); + + $this->assertNotEmpty($DB->get_records('customfield_category', ['id' => $catid1])); + $fids1 = $DB->get_fieldset_select('customfield_field', 'id', 'categoryid=?', [$catid1]); + list($fsql1, $fparams1) = $DB->get_in_or_equal($fids1, SQL_PARAMS_NAMED); + $this->assertNotEmpty($DB->get_records_select('customfield_field', 'id ' . $fsql1, $fparams1)); + $this->assertNotEmpty($DB->get_records_select('customfield_data', 'fieldid ' . $fsql1, $fparams1)); + } + + /** + * Test for provider::delete_customfields_data_for_context() + */ + public function test_delete_customfields_data_for_context() { + global $DB; + $this->resetAfterTest(); + [ + 'cffields' => $cffields, + 'cfcats' => $cfcats, + 'courses' => $courses, + ] = $this->generate_test_data(); + + provider::delete_customfields_data_for_context('core_course', 'course', + \context_course::instance($courses[1]->id)); + $fids2 = $DB->get_fieldset_select('customfield_field', 'id', '1=1', []); + list($fsql, $fparams) = $DB->get_in_or_equal($fids2, SQL_PARAMS_NAMED); + $fparams['course1'] = $courses[1]->id; + $fparams['course2'] = $courses[2]->id; + $this->assertEmpty($DB->get_records_select('customfield_data', 'instanceid = :course1 AND fieldid ' . $fsql, $fparams)); + $this->assertNotEmpty($DB->get_records_select('customfield_data', 'instanceid = :course2 AND fieldid ' . $fsql, $fparams)); + } +} diff --git a/upgrade.txt b/upgrade.txt new file mode 100644 index 0000000..8e61c85 --- /dev/null +++ b/upgrade.txt @@ -0,0 +1,13 @@ +This files describes API changes in /customfield/*, +Information provided here is intended especially for developers. + +=== 3.11 === +* Methods \core_customfield\handler::get_field_config_form() and \core_customfield\handler::setup_edit_page() are no + longer used. Components that define custom fields areas do not need to implement them. Field edit form opens in + the modal now. + +=== 4.0 === +The way the method customfield_multiselect\data_controller::get_value() was returning an array was causing multiple issues with core AJAX functionality returning course data. +Examples of somewhere this error was occuring is when trying to duplicate a course via 'manage courses and categories', or when trying to fetch a list of courses when adding a meta link enrolment method. +Issues also occured when using Edwiser Bridge. +The solution was to return a string of values using get_value, but preparing the data as an array when using the course edit form. From 12f7e354db380ba593174c410231742b13ed128b Mon Sep 17 00:00:00 2001 From: teruselearning <49330603+teruselearning@users.noreply.github.com> Date: Thu, 9 Feb 2023 14:32:06 +0700 Subject: [PATCH 2/2] addition of tasks to keep track of field changes --- field/multiselect/classes/data_controller.php | 37 ++++++------ .../classes/task/multiselect_field_change.php | 51 ++++++++++++++++ .../classes/task/multiselect_sync.php | 60 +++++++++++++++++++ field/multiselect/db/install.xml | 19 ++++++ field/multiselect/db/tasks.php | 25 ++++++++ field/multiselect/db/upgrade.php | 38 ++++++++++++ .../lang/en/customfield_multiselect.php | 2 + field/multiselect/version.php | 2 +- 8 files changed, 215 insertions(+), 19 deletions(-) create mode 100644 field/multiselect/classes/task/multiselect_field_change.php create mode 100644 field/multiselect/classes/task/multiselect_sync.php create mode 100644 field/multiselect/db/install.xml create mode 100644 field/multiselect/db/tasks.php create mode 100644 field/multiselect/db/upgrade.php diff --git a/field/multiselect/classes/data_controller.php b/field/multiselect/classes/data_controller.php index 6e7baae..7ee5275 100644 --- a/field/multiselect/classes/data_controller.php +++ b/field/multiselect/classes/data_controller.php @@ -86,7 +86,7 @@ public function get_default_value_string() { $defaultvaluesarray[] = intval($index); } } - return implode(",", $defaultvalues); + return implode(",", $defaultvaluesarray); } /** @@ -218,22 +218,23 @@ protected function is_empty($value): bool { * @return mixed|null value or null if empty */ public function export_value() { - $values = $this->get_value(); // This is a an array of indexes. - - if ($this->is_empty($values)) { - return null; - } - - $commasepoptionvalues = ""; - $options = field_controller::get_options_array($this->get_field()); - foreach ($values as $val) { - if (!empty($options[$val])) { - $commasepoptionvalues .= (empty($commasepoptionvalues) ? '' : ', ') . - format_string($options[$val], true, - ['context' => $this->get_field()->get_handler()->get_configuration_context()]); - } - } - return $commasepoptionvalues; + // $values = $this->get_value(); // This is a an array of indexes. + + // if ($this->is_empty($values)) { + // return null; + // } + + // $commasepoptionvalues = ""; + // $options = field_controller::get_options_array($this->get_field()); + // foreach ($values as $val) { + // if (!empty($options[$val])) { + // $commasepoptionvalues .= (empty($commasepoptionvalues) ? '' : ', ') . + // format_string($options[$val], true, + // ['context' => $this->get_field()->get_handler()->get_configuration_context()]); + // } + // } + // return $commasepoptionvalues; + // } + return ''; } - } diff --git a/field/multiselect/classes/task/multiselect_field_change.php b/field/multiselect/classes/task/multiselect_field_change.php new file mode 100644 index 0000000..cf2823a --- /dev/null +++ b/field/multiselect/classes/task/multiselect_field_change.php @@ -0,0 +1,51 @@ +dirroot . '/user/lib.php'; + + //Firstly get all cusotmfields with multiselect + $all_multiselect_fields = $DB->get_records('customfield_field',array('type'=>'multiselect')); + + foreach($all_multiselect_fields as $field){ + //Get all courses with data for this field + $five_mins_ago = time() - 300; + if($field->timemodified >= $five_mins_ago){ + $field_with_data = $DB->get_records('customfield_data',array('fieldid'=>$field->id)); + + foreach($field_with_data as $data){ + //Check if this has already been added + if($entry = $DB->get_record('customfield_multiselect',array('fieldid'=>$field->id, 'courseid'=>$data->instanceid))){ + $courseid = $data->instanceid; + $value = $entry->data; + $values = explode(",", $value); + + $configdata = $field->configdata; + $config_op = json_decode($configdata); + $field_options = $config_op->options; + $options = explode("\r\n", $field_options); + + $valuesaarr = array(); + + foreach($values as $multioption){ + $key = array_search($multioption, $options); + array_push($valuesaarr, $key); + } + $data->value = implode(",",$valuesaarr); + $DB->update_record('customfield_data', $data); + } + } + + } + } + } +} \ No newline at end of file diff --git a/field/multiselect/classes/task/multiselect_sync.php b/field/multiselect/classes/task/multiselect_sync.php new file mode 100644 index 0000000..33c4842 --- /dev/null +++ b/field/multiselect/classes/task/multiselect_sync.php @@ -0,0 +1,60 @@ +dirroot . '/user/lib.php'; + + $five_mins_ago = time() - 300; + $sql="SELECT * FROM {course} WHERE timemodified > ".$five_mins_ago; + $courses = $DB->get_records_sql($sql); + print_r($courses); + if(!empty($courses)){ + foreach($courses as $course){ + $DB->delete_records('customfield_multiselect', array('courseid' => $course->id)); + } + } + + + + + //Firstly get all cusotmfields with multiselect + $all_multiselect_fields = $DB->get_records('customfield_field',array('type'=>'multiselect')); + + foreach($all_multiselect_fields as $field){ + //Get all courses with data for this field + $field_with_data = $DB->get_records('customfield_data',array('fieldid'=>$field->id)); + foreach($field_with_data as $data){ + //Check if this has already been added + if(!$DB->get_record('customfield_multiselect',array('fieldid'=>$field->id, 'courseid'=>$data->instanceid))){ + $courseid = $data->instanceid; + $value = $data->value; + $values = explode(",", $value); + $configdata = $field->configdata; + $config_op = json_decode($configdata); + $field_options = $config_op->options; + $options = explode("\r\n", $field_options); + + $valuesaarr = array(); + + foreach($values as $multioption){ + array_push($valuesaarr, $options[$multioption]); + } + $data = new \stdclass(); + $data->courseid = $courseid; + $data->fieldid = $field->id; + $data->data = implode(",",$valuesaarr); + $DB->insert_record('customfield_multiselect', $data); + } + } + } + } +} \ No newline at end of file diff --git a/field/multiselect/db/install.xml b/field/multiselect/db/install.xml new file mode 100644 index 0000000..638967c --- /dev/null +++ b/field/multiselect/db/install.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + +
+
+
diff --git a/field/multiselect/db/tasks.php b/field/multiselect/db/tasks.php new file mode 100644 index 0000000..5282df9 --- /dev/null +++ b/field/multiselect/db/tasks.php @@ -0,0 +1,25 @@ + 'customfield_multiselect\task\multiselect_sync', + 'blocking' => 0, + 'minute' => '*', + 'hour' => '*', + 'day' => '*', + 'month' => '*', + 'dayofweek' => '*' + ), + array( + 'classname' => 'customfield_multiselect\task\multiselect_field_change', + 'blocking' => 0, + 'minute' => '/5', + 'hour' => '*', + 'day' => '*', + 'month' => '*', + 'dayofweek' => '*' + ), +); diff --git a/field/multiselect/db/upgrade.php b/field/multiselect/db/upgrade.php new file mode 100644 index 0000000..7b15300 --- /dev/null +++ b/field/multiselect/db/upgrade.php @@ -0,0 +1,38 @@ +get_manager(); + + if ($oldversion < 2023020803) { + + // Define table customfield_multiselect to be created. + $table = new xmldb_table('customfield_multiselect'); + + // Adding fields to table customfield_multiselect. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('courseid', XMLDB_TYPE_INTEGER, '5', null, null, null, null); + $table->add_field('fieldid', XMLDB_TYPE_INTEGER, '5', null, null, null, null); + $table->add_field('data', XMLDB_TYPE_CHAR, '1333', null, XMLDB_NOTNULL, null, null); + + // Adding keys to table customfield_multiselect. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + + // Conditionally launch create table for customfield_multiselect. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Multiselect savepoint reached. + upgrade_plugin_savepoint(true, 2023020803, 'customfield', 'multiselect'); + } + + + + return $result; +} + diff --git a/field/multiselect/lang/en/customfield_multiselect.php b/field/multiselect/lang/en/customfield_multiselect.php index b6ff26e..e2f669f 100644 --- a/field/multiselect/lang/en/customfield_multiselect.php +++ b/field/multiselect/lang/en/customfield_multiselect.php @@ -35,3 +35,5 @@ $string['pluginname'] = 'Multiselect menu'; $string['privacy:metadata'] = 'The Multiselect menu field type plugin doesn\'t store any personal data; it uses tables defined in core.'; $string['specificsettings'] = 'Multiselect menu field settings'; +$string['task'] = 'Create literal values'; +$string['field_task'] = 'Monitor field changes'; diff --git a/field/multiselect/version.php b/field/multiselect/version.php index 4f35c17..db9e0ed 100644 --- a/field/multiselect/version.php +++ b/field/multiselect/version.php @@ -26,7 +26,7 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'customfield_multiselect'; -$plugin->version = 2019052000; +$plugin->version = 2023020805; $plugin->requires = 2019051100; $plugin->release = '1.0.0'; $plugin->maturity = MATURITY_BETA; \ No newline at end of file