MDL-57898 core_customfield: Custom fields API
authorToni Barberà Melià <toni@moodle.com>
Fri, 11 Jan 2019 10:41:32 +0000 (11:41 +0100)
committerMarina Glancy <marina@moodle.com>
Fri, 18 Jan 2019 08:02:30 +0000 (09:02 +0100)
This commit is part of work on Custom fields API,
to minimize commit history in moodle core the work of a team of developers was split
into several commits with different authors but the authorship of individual
lines of code may be different from the commit author.

29 files changed:
customfield/amd/build/form.min.js [new file with mode: 0644]
customfield/amd/src/form.js [new file with mode: 0644]
customfield/classes/category.php [new file with mode: 0644]
customfield/classes/category_controller.php [new file with mode: 0644]
customfield/classes/data.php [new file with mode: 0644]
customfield/classes/data_controller.php [new file with mode: 0644]
customfield/classes/event/category_deleted.php [new file with mode: 0644]
customfield/classes/event/field_deleted.php [new file with mode: 0644]
customfield/classes/field.php [new file with mode: 0644]
customfield/classes/field_controller.php [new file with mode: 0644]
customfield/field/checkbox/lang/en/customfield_checkbox.php [new file with mode: 0644]
customfield/field/checkbox/version.php [new file with mode: 0644]
customfield/field/date/version.php [new file with mode: 0644]
customfield/field/select/lang/en/customfield_select.php [new file with mode: 0644]
customfield/field/select/version.php [new file with mode: 0644]
customfield/field/text/lang/en/customfield_text.php [new file with mode: 0644]
customfield/field/text/version.php [new file with mode: 0644]
customfield/field/textarea/lang/en/customfield_textarea.php [new file with mode: 0644]
customfield/index.html [new file with mode: 0644]
customfield/templates/list.mustache [new file with mode: 0644]
customfield/tests/api_test.php [new file with mode: 0644]
customfield/tests/category_controller_test.php [new file with mode: 0644]
customfield/tests/data_controller_test.php [new file with mode: 0644]
lang/en/customfield.php [new file with mode: 0644]
lib/classes/plugininfo/customfield.php [new file with mode: 0644]
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
version.php

diff --git a/customfield/amd/build/form.min.js b/customfield/amd/build/form.min.js
new file mode 100644 (file)
index 0000000..5fc10f1
Binary files /dev/null and b/customfield/amd/build/form.min.js differ
diff --git a/customfield/amd/src/form.js b/customfield/amd/src/form.js
new file mode 100644 (file)
index 0000000..fa5b257
--- /dev/null
@@ -0,0 +1,202 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * This module depends on the real jquery - and returns the non-global version of it.
+ *
+ * @module     core_customfield/form
+ * @package    core_customfield
+ * @copyright  2018 Toni Barbera
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/str', 'core/notification', 'core/ajax', 'core/templates', 'core/sortable_list'], function(
+        $, Str, Notification, Ajax, Templates, SortableList) {
+
+    /**
+     * Display confirmation dialogue
+     *
+     * @param {Number} id
+     * @param {String} type
+     * @param {String} component
+     * @param {String} area
+     * @param {Number} itemid
+     */
+    var confirmDelete = function(id, type, component, area, itemid) {
+        Str.get_strings([
+            {'key': 'confirm'},
+            {'key': 'confirmdelete' + type, component: 'core_customfield'},
+            {'key': 'yes'},
+            {'key': 'no'},
+        ]).done(function(s) {
+            Notification.confirm(s[0], s[1], s[2], s[3], function() {
+                var func = (type === 'field') ? 'core_customfield_delete_field' : 'core_customfield_delete_category';
+                Ajax.call([
+                    {methodname: func, args: {id: id}},
+                    {methodname: 'core_customfield_reload_template', args: {component: component, area: area, itemid: itemid}}
+                ])[1].then(function(response) {
+                    return Templates.render('core_customfield/list', response);
+                }).then(function(html, js) {
+                    Templates.replaceNodeContents($('[data-region="list-page"]'), html, js);
+                    return null;
+                }).fail(Notification.exception);
+            });
+        }).fail(Notification.exception);
+    };
+
+    /**
+     * Creates a new custom fields category with default name and updates the list
+     *
+     * @param {String} component
+     * @param {String} area
+     * @param {Number} itemid
+     */
+    var createNewCategory = function(component, area, itemid) {
+        var promises = 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}}
+            ]),
+            categoryid;
+
+        promises[0].then(function(response) {
+            categoryid = response;
+            return null;
+        }).fail(Notification.exception);
+
+        promises[1].then(function(response) {
+            return Templates.render('core_customfield/list', response);
+        }).then(function(html, js) {
+            Templates.replaceNodeContents($('[data-region="list-page"]'), html, js);
+            window.location.href = '#category-' + categoryid;
+            return null;
+        }).fail(Notification.exception);
+    };
+
+    return {
+        /**
+         * Initialise the custom fields manager
+         */
+        init: function() {
+            var mainlist = $('#customfield_catlist'),
+                component = mainlist.attr('data-component'),
+                area = mainlist.attr('data-area'),
+                itemid = mainlist.attr('data-itemid');
+            $("[data-role=deletefield]").on('click', function(e) {
+                confirmDelete($(this).attr('data-id'), 'field', component, area, itemid);
+                e.preventDefault();
+            });
+            $("[data-role=deletecategory]").on('click', function(e) {
+                confirmDelete($(this).attr('data-id'), 'category', component, area, itemid);
+                e.preventDefault();
+            });
+            $('[data-role=addnewcategory]').on('click', function() {
+                createNewCategory(component, area, itemid);
+            });
+
+            var categoryName = function(element) {
+                return element
+                    .closest('[data-category-id]')
+                    .find('[data-inplaceeditable][data-itemtype=category][data-component=core_customfield]')
+                    .attr('data-value');
+            };
+
+            // Sort category.
+            var sortCat = new SortableList(
+                '#customfield_catlist .categorieslist',
+                {moveHandlerSelector: '.movecategory [data-drag-type=move]'}
+            );
+
+            sortCat.getElementName = function(el) {
+                return $.Deferred().resolve(categoryName(el));
+            };
+
+            $('[data-category-id]').on('sortablelist-drop', function(evt, info) {
+                if (info.positionChanged) {
+                    var promises = Ajax.call([
+                        {
+                            methodname: 'core_customfield_move_category',
+                            args: {
+                                id: info.element.data('category-id'),
+                                beforeid: info.targetNextElement.data('category-id')
+                            }
+
+                        },
+                    ]);
+                    promises[0].fail(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 = function(parentElement, afterElement) {
+                if (!afterElement.length) {
+                    return Str.get_string('totopofcategory', 'customfield', categoryName(parentElement));
+                } else if (afterElement.attr('data-field-name')) {
+                    return Str.get_string('afterfield', 'customfield', afterElement.attr('data-field-name'));
+                } else {
+                    return $.Deferred().resolve('');
+                }
+            };
+
+            $('[data-field-name]').on('sortablelist-drop', function(evt, info) {
+                evt.stopPropagation(); // Important for nested lists to prevent multiple targets.
+                if (info.positionChanged) {
+                    var promises = 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'))
+                            },
+                        },
+                    ]);
+                    promises[0].fail(Notification.exception);
+                }
+            }).on('sortablelist-drag', function(evt) {
+                evt.stopPropagation(); // Important for nested lists to prevent multiple targets.
+                // Refreshing fields tables.
+                Str.get_string('therearenofields', 'core_customfield').then(function(s) {
+                    $('#customfield_catlist .categorieslist').children().each(function() {
+                        var fields = $(this).find($('.field')),
+                            nofields = $(this).find($('.nofields'));
+                        if (!fields.length && !nofields.length) {
+                            $(this).find('tbody').append(
+                                '<tr class="nofields"><td colspan="5">' + s + '</td></tr>'
+                            );
+                        }
+                        if (fields.length && nofields.length) {
+                            nofields.remove();
+                        }
+                    });
+                    return null;
+                }).fail(Notification.exception);
+            });
+
+            $('[data-category-id], [data-field-name]').on('sortablelist-dragstart',
+                function(evt, info) {
+                    setTimeout(function() {
+                        $('.sortable-list-is-dragged').width(info.element.width());
+                    }, 501);
+                }
+            );
+
+        }
+    };
+});
diff --git a/customfield/classes/category.php b/customfield/classes/category.php
new file mode 100644 (file)
index 0000000..0a7dff9
--- /dev/null
@@ -0,0 +1,87 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Customfield category persistent class
+ *
+ * @package   core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @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 <toni@moodle.com>
+ * @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/customfield/classes/category_controller.php b/customfield/classes/category_controller.php
new file mode 100644 (file)
index 0000000..ee2e3fc
--- /dev/null
@@ -0,0 +1,231 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Customfield catecory controller class
+ *
+ * @package   core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @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 <toni@moodle.com>
+ * @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/customfield/classes/data.php b/customfield/classes/data.php
new file mode 100644 (file)
index 0000000..8551940
--- /dev/null
@@ -0,0 +1,107 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Data persistent class
+ *
+ * @package   core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @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 <toni@moodle.com>
+ * @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/customfield/classes/data_controller.php b/customfield/classes/data_controller.php
new file mode 100644 (file)
index 0000000..dd13c4d
--- /dev/null
@@ -0,0 +1,365 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Customfield component data controller abstract class
+ *
+ * @package   core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield;
+
+use core_customfield\output\field_data;
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * 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.
+ *
+ * Custom field plugins must define a class
+ * \{pluginname}\data_controller extends \core_customfield\data_controller
+ *
+ * @package core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class data_controller {
+    /**
+     * Data persistent
+     *
+     * @var data
+     */
+    protected $data;
+
+    /**
+     * Field that this data belongs to.
+     *
+     * @var field_controller
+     */
+    protected $field;
+
+    /**
+     * data_controller constructor.
+     *
+     * @param int $id
+     * @param \stdClass|null $record
+     */
+    public function __construct(int $id, \stdClass $record) {
+        $this->data = new data($id, $record);
+    }
+
+    /**
+     * 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
+     *
+     * 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 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();
+        }
+
+        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');
+        }
+        $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;
+    }
+
+    /**
+     * Returns the name of the field to be used on HTML forms.
+     *
+     * @return string
+     */
+    protected function get_form_element_name(): string {
+        return 'customfield_' . $this->get_field()->get('shortname');
+    }
+
+    /**
+     * Persistent getter parser.
+     *
+     * @param string $property
+     * @return mixed
+     */
+    final public function get($property) {
+        return $this->data->get($property);
+    }
+
+    /**
+     * Persistent setter parser.
+     *
+     * @param string $property
+     * @param mixed $value
+     * @return data
+     */
+    final public function set($property, $value) {
+        return $this->data->set($property, $value);
+    }
+
+    /**
+     * Return the name of the field in the db table {customfield_data} where the data is stored
+     *
+     * 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)
+     *
+     * @return string
+     */
+    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
+     */
+    public function instance_form_save(\stdClass $datanew) {
+        $elementname = $this->get_form_element_name();
+        if (!property_exists($datanew, $elementname)) {
+            return;
+        }
+        $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 mixed
+     */
+    public function get_value() {
+        if (!$this->get('id')) {
+            return $this->get_default_value();
+        }
+        return $this->get($this->datafield());
+    }
+
+    /**
+     * Return the context of the field
+     *
+     * @return \context
+     */
+    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();
+        }
+    }
+
+    /**
+     * Add a field to the instance edit form.
+     *
+     * @param \MoodleQuickForm $mform
+     */
+    public abstract function instance_form_definition(\MoodleQuickForm $mform);
+
+    /**
+     * 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() {
+        $value = $this->get_value();
+
+        if ($this->is_empty($value)) {
+            return null;
+        }
+
+        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()]);
+        }
+    }
+
+    /**
+     * Persistent to_record parser.
+     *
+     * @return \stdClass
+     */
+    final public function to_record() {
+        return $this->data->to_record();
+    }
+}
diff --git a/customfield/classes/event/category_deleted.php b/customfield/classes/event/category_deleted.php
new file mode 100644 (file)
index 0000000..ce6064d
--- /dev/null
@@ -0,0 +1,84 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Custom field category created event.
+ *
+ * @package    core_customfield
+ * @copyright  2018 Toni Barbera <toni@moodle.com>
+ * @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 <toni@moodle.com>
+ * @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/customfield/classes/event/field_deleted.php b/customfield/classes/event/field_deleted.php
new file mode 100644 (file)
index 0000000..f3c935f
--- /dev/null
@@ -0,0 +1,87 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Custom field updated event.
+ *
+ * @package    core_customfield
+ * @copyright  2018 Toni Barbera <toni@moodle.com>
+ * @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 <toni@moodle.com>
+ * @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/customfield/classes/field.php b/customfield/classes/field.php
new file mode 100644 (file)
index 0000000..d30cf37
--- /dev/null
@@ -0,0 +1,97 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Field persistent class
+ *
+ * @package   core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @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 <toni@moodle.com>
+ * @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/customfield/classes/field_controller.php b/customfield/classes/field_controller.php
new file mode 100644 (file)
index 0000000..f54026a
--- /dev/null
@@ -0,0 +1,252 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Field controller abstract class
+ *
+ * @package   core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_customfield;
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * Base class for custom fields controllers
+ *
+ * This class is a wrapper around the persistent field class that allows to define the field
+ * configuration
+ *
+ * Custom field plugins must define a class
+ * \{pluginname}\field_controller extends \core_customfield\field_controller
+ *
+ * @package core_customfield
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class field_controller {
+
+    /**
+     * Field persistent class
+     *
+     * @var field
+     */
+    protected $field;
+
+    /**
+     * Category of the field.
+     *
+     * @var category_controller
+     */
+    protected $category;
+
+    /**
+     * Constructor.
+     *
+     * @param int $id
+     * @param \stdClass|null $record
+     */
+    public function __construct(int $id = 0, \stdClass $record = null) {
+        $this->field = new field($id, $record);
+    }
+
+    /**
+     * Creates an instance of field_controller
+     *
+     * 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 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');
+        }
+
+        $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;
+    }
+
+    /**
+     * 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
+     */
+    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);
+    }
+
+    /**
+     * Persistent setter parser.
+     *
+     * @param string $property
+     * @param mixed $value
+     * @return field
+     */
+    final public function set($property, $value) {
+        return $this->field->set($property, $value);
+    }
+
+    /**
+     * Delete a field and all associated data
+     *
+     * 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 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;
+        }
+        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]);
+    }
+}
diff --git a/customfield/field/checkbox/lang/en/customfield_checkbox.php b/customfield/field/checkbox/lang/en/customfield_checkbox.php
new file mode 100644 (file)
index 0000000..91bd63e
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Customfield checkbox plugin
+ * @package   customfield_checkbox
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['checkedbydefault'] = 'Checked by default';
+$string['errorconfigunique'] = 'Checkbox field can not be defined as unique';
+$string['pluginname'] = 'Checkbox';
+$string['privacy:metadata'] = 'Checkbox field type plugin does not store any personal data, it uses tables defined in core';
+$string['specificsettings'] = 'Settings for the checkbox field';
diff --git a/customfield/field/checkbox/version.php b/customfield/field/checkbox/version.php
new file mode 100644 (file)
index 0000000..fb33942
--- /dev/null
@@ -0,0 +1,28 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Customfield checkbox plugin
+ * @package   customfield_checkbox
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->component = 'customfield_checkbox';
+$plugin->version   = 2018120300;
+$plugin->requires  = 2018122000;
diff --git a/customfield/field/date/version.php b/customfield/field/date/version.php
new file mode 100644 (file)
index 0000000..130fece
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Customfield date plugin
+ *
+ * @package   customfield_date
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->component = 'customfield_date';
+$plugin->version   = 2018120300;
+$plugin->requires  = 2018122000;
+
diff --git a/customfield/field/select/lang/en/customfield_select.php b/customfield/field/select/lang/en/customfield_select.php
new file mode 100644 (file)
index 0000000..7e66d98
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Customfield text field plugin strings
+ *
+ * @package   customfield_select
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['errordefaultvaluenotinlist'] = 'Default value must be one of the options from the list above';
+$string['errornotenoughoptions'] = 'Please provide at least two options separated with a newline';
+$string['invalidoption'] = 'Invalid option selected';
+$string['menuoptions'] = 'Menu options (one per line)';
+$string['pluginname'] = 'Dropdown menu';
+$string['privacy:metadata'] = 'Dropdown menu field type plugin does not store any personal data, it uses tables defined in core';
+$string['specificsettings'] = 'Settings for the dropdown menu field';
diff --git a/customfield/field/select/version.php b/customfield/field/select/version.php
new file mode 100644 (file)
index 0000000..997926c
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Customfield Select Type
+ *
+ * @package   customfield_select
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->component = 'customfield_select';
+$plugin->version   = 2018120300;
+$plugin->requires  = 2018122000;
diff --git a/customfield/field/text/lang/en/customfield_text.php b/customfield/field/text/lang/en/customfield_text.php
new file mode 100644 (file)
index 0000000..78962d7
--- /dev/null
@@ -0,0 +1,44 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Customfield text plugin
+ *
+ * @package   customfield_text
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['displaysize'] = 'Form input size';
+$string['errorconfigdisplaysize'] = 'Form input size must be between 1 and 200 characters';
+$string['errorconfiglinkplaceholder'] = 'Link must contain placeholder $$';
+$string['errorconfiglinksyntax'] = 'Link must be a valid URL starting with either http:// or https://';
+$string['errorconfigmaxlen'] = 'Maximum length must be between 1 and 1333';
+$string['errormaxlength'] = 'This field maximum length is {$a}';
+$string['islink'] = 'Link field';
+$string['islink_help'] = 'To transform the text into a link, enter a URL containing $$, where $$ will be replaced with the text. For example, to transform a Twitter ID to a link, enter http://twitter.com/$$.';
+$string['ispassword'] = 'Password field';
+$string['linktarget'] = 'Link target';
+$string['maxlength'] = 'Maximum length';
+$string['newwindow'] = 'New window';
+$string['none'] = 'None';
+$string['pluginname'] = 'Text field';
+$string['privacy:metadata'] = 'Text field field type plugin does not store any personal data, it uses tables defined in core';
+$string['sameframe'] = 'Same frame';
+$string['samewindow'] = 'Same window';
+$string['specificsettings'] = 'Settings for the text field';
diff --git a/customfield/field/text/version.php b/customfield/field/text/version.php
new file mode 100644 (file)
index 0000000..05f82a3
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Customfield text plugin
+ *
+ * @package   customfield_text
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->component = 'customfield_text';
+$plugin->version   = 2019010400;
+$plugin->requires  = 2018122000;
diff --git a/customfield/field/textarea/lang/en/customfield_textarea.php b/customfield/field/textarea/lang/en/customfield_textarea.php
new file mode 100644 (file)
index 0000000..d4bb571
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Customfield textarea plugin
+ *
+ * @package   customfield_textarea
+ * @copyright 2018 Toni Barbera <toni@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['pluginname'] = 'Text area';
+$string['privacy:metadata'] = 'Text area field type plugin does not store any personal data, it uses tables defined in core';
+$string['specificsettings'] = 'Settings for the text area field';
diff --git a/customfield/index.html b/customfield/index.html
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/customfield/templates/list.mustache b/customfield/templates/list.mustache
new file mode 100644 (file)
index 0000000..109da60
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+  }}
+{{!
+  @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": 0,
+            "nameeditable": "Empty category",
+            "addfieldmenu": "Add field",
+            "fields": [] }
+      ],
+      "singleselect" : "select"
+  }
+}}
+
+{{{alert}}}
+
+<div data-region="list-page" id="customfield_catlist" data-component="{{component}}" data-area="{{area}}" data-itemid="{{itemid}}">
+    <div class="row">
+        <div class="col align-self-end">
+            {{#usescategories}}
+            <a tabindex="0" role="button" class="btn btn-secondary float-right" data-role="addnewcategory">{{#str}}addnewcategory, core_customfield{{/str}}</a>
+            {{/usescategories}}
+        </div>
+    </div>
+
+    {{^categories}}
+        {{{nocategories}}}
+    {{/categories}}
+
+    <div class="categorieslist">
+    {{#categories}}
+        <div data-category-id="{{id}}" id="category-{{id}}" class="mt-2">
+        <div class="row justify-content-between align-items-end">
+            <div class="col-6 categoryinstance">
+                {{#usescategories}}
+                    <h3>
+                        <span class="movecategory">
+                        {{> core/drag_handle}}</span>{{{nameeditable}}}
+                        <a href="#" data-role="deletecategory" data-id="{{id}}">{{#pix}}
+                            t/delete, core, {{#str}} delete, moodle {{/str}} {{/pix}}</a>
+                    </h3>
+                {{/usescategories}}
+            </div>
+            <div class="col-auto text-right">
+                {{{addfieldmenu}}}
+            </div>
+        </div>
+        <div>
+            <table class="generaltable fullwidth fieldslist">
+                <thead>
+                <tr>
+                    <th scope="col" class="col-5">{{#str}} customfield, core_customfield {{/str}}</th>
+                    <th scope="col" class="col-2">{{#str}} shortname, core_customfield {{/str}}</th>
+                    <th scope="col" class="col-2">{{#str}} type, core_customfield {{/str}}</th>
+                    <th scope="col" class="text-right">{{#str}} action, core_customfield {{/str}}</th>
+                </tr>
+                </thead>
+                <tbody>
+                {{#fields}}
+                    <tr data-field-name="{{name}}" data-field-id="{{id}}"  class="field">
+                        <td class="col-5"><span class="movefield">{{> core/drag_handle}}</span>{{{name}}}</td>
+                        <td class="col-2">{{{shortname}}}</td>
+                        <td class="col-2">{{{type}}}</td>
+                        <td class="col-5 text-right ">
+                            <a href="{{editfieldurl}}" data-role="editfield">{{#pix}}
+                                t/edit, core, {{#str}} edit, moodle {{/str}} {{/pix}}</a>
+                            <a href="#" data-id="{{id}}" data-role="deletefield">{{#pix}}
+                                t/delete, core, {{#str}} delete, moodle {{/str}} {{/pix}}</a>
+                        </td>
+                    </tr>
+                {{/fields}}
+                {{^fields}}
+                    <tr class="nofields"><td colspan="4">{{# str }} therearenofields, core_customfield {{/ str }}</td></tr>
+                {{/fields}}
+                </tbody>
+            </table>
+        </div>
+        </div>
+    {{/categories}}
+    </div>
+</div>
+
+{{#js}}
+    require(['core_customfield/form'], function(s) {
+        s.init();
+    });
+{{/js}}
diff --git a/customfield/tests/api_test.php b/customfield/tests/api_test.php
new file mode 100644 (file)
index 0000000..cbe087a
--- /dev/null
@@ -0,0 +1,261 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Tests for class \core_customfield\api.
+ *
+ * @package    core_customfield
+ * @category   test
+ * @copyright  2018 Toni Barbera <toni@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_customfield\api;
+use \core_customfield\category_controller;
+
+/**
+ * Functional test for class \core_customfield\api
+ *
+ * @package    core_customfield
+ * @copyright  2018 Toni Barbera <toni@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_customfield_api_testcase extends advanced_testcase {
+
+    /**
+     * Tests set up.
+     */
+    public function setUp() {
+        $this->resetAfterTest();
+    }
+
+    /**
+     * 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 sring $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() {
+        // 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() {
+        // 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() {
+        $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() {
+        $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() {
+        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/customfield/tests/category_controller_test.php b/customfield/tests/category_controller_test.php
new file mode 100644 (file)
index 0000000..bafe5a8
--- /dev/null
@@ -0,0 +1,253 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Tests for class \core_customfield\category_controller.
+ *
+ * @package    core_customfield
+ * @category   test
+ * @copyright  2018 Toni Barbera <toni@moodle.com>
+ * @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;
+
+/**
+ * Functional test for class \core_customfield\category_controller.
+ * @package    core_customfield
+ * @copyright  2018 Toni Barbera <toni@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_customfield_category_controller_testcase extends advanced_testcase {
+
+    /**
+     * Tests set up.
+     */
+    public function setUp() {
+        $this->resetAfterTest();
+    }
+
+    /**
+     * Get generator
+     * @return core_customfield_generator
+     */
+    protected function get_generator(): core_customfield_generator {
+        return $this->getDataGenerator()->get_plugin_generator('core_customfield');
+    }
+
+    public function test_constructor() {
+        $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;
+        $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() {
+
+        // 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() {
+        // 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() {
+        // 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/customfield/tests/data_controller_test.php b/customfield/tests/data_controller_test.php
new file mode 100644 (file)
index 0000000..9fd4d86
--- /dev/null
@@ -0,0 +1,183 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Tests for class data_controller.
+ *
+ * @package    core_customfield
+ * @category   test
+ * @copyright  2018 Toni Barbera <toni@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_customfield\data_controller;
+
+/**
+ * Functional test for class data_controller.
+ * @copyright  2018 Toni Barbera <toni@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_customfield_data_controller_testcase extends advanced_testcase {
+
+    /**
+     * Tests set up.
+     */
+    public function setUp() {
+        $this->resetAfterTest();
+    }
+
+    /**
+     * 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;
+        // 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;
+        // 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 not 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));
+        }
+    }
+}
\ No newline at end of file
diff --git a/lang/en/customfield.php b/lang/en/customfield.php
new file mode 100644 (file)
index 0000000..73d3608
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Strings for component 'core_customfield', language 'en'
+ *
+ * @package   core
+ * @copyright 2018 Toni Barbera  {@link http://moodle.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['action'] = 'Action';
+$string['addingnewcustomfield'] = 'Adding a new {$a}';
+$string['addnewcategory'] = 'Add a new category';
+$string['afterfield'] = 'After field {$a}';
+$string['categorynotfound'] = 'Category not found';
+$string['checked'] = 'Checked';
+$string['commonsettings'] = 'Common settings';
+$string['componentsettings'] = 'Component settings';
+$string['confirmdeletecategory'] = 'Are you sure you want to delete this category? All fields inside this category will also be deleted and all data associated with them. This action can not be undone.';
+$string['confirmdeletefield'] = 'Are you sure you want to delete this field? All associated data will also be deleted. This operation can not be undone.';
+$string['createnewcustomfield'] = 'Add a new custom field';
+$string['customfield'] = 'Custom field';
+$string['customfielddata'] = 'Custom fields data';
+$string['customfields'] = 'Custom fields';
+$string['defaultvalue'] = 'Default value';
+$string['description'] = 'Description';
+$string['description_help'] = 'Description will be displayed in the form under the field';
+$string['edit'] = 'Edit';
+$string['editcategoryname'] = 'Edit category name';
+$string['editingfield'] = 'Updating {$a}';
+$string['errorfieldtypenotfound'] = 'Field type {$a} not found';
+$string['erroruniquevalues'] = 'This value is already used.';
+$string['eventcategorycreated'] = 'Custom field category created';
+$string['eventcategorydeleted'] = 'Custom field category deleted';
+$string['eventcategoryupdated'] = 'Custom field category updated';
+$string['eventfieldcreated'] = 'Custom field created';
+$string['eventfielddeleted'] = 'Custom field deleted';
+$string['eventfieldupdated'] = 'Custom field updated';
+$string['fieldname'] = 'Name';
+$string['fieldnotfound'] = 'Field not found';
+$string['fieldshortname'] = 'Short name';
+$string['formfieldcheckshortname'] = 'Short name already exists';
+$string['invalidshortnameerror'] = 'Short name can only contain lowercase latin letters, digits and an underscore sign';
+$string['isdataunique'] = 'Unique data';
+$string['isdataunique_help'] = 'Should the data be unique?';
+$string['isfieldrequired'] = 'Required';
+$string['isfieldrequired_help'] = 'Is this field required?';
+$string['link'] = 'Link';
+$string['linktarget'] = 'Link target';
+$string['modify'] = 'Modify';
+$string['movecategory'] = 'Move "{$a}"';
+$string['movefield'] = 'Move "{$a}"';
+$string['no'] = 'No';
+$string['nocategories'] = 'There are no custom fields and categories.';
+$string['nopermissionconfigure'] = 'You don\'t have permission to configure fields here.';
+$string['notchecked'] = 'Not checked';
+$string['otherfields'] = 'Other fields';
+$string['otherfieldsn'] = 'Other fields {$a}';
+$string['privacy:metadata:customfield_data'] = 'Represents custom field data saved to a context';
+$string['privacy:metadata:customfield_data:charvalue'] = 'Data value, when it is a char';
+$string['privacy:metadata:customfield_data:contextid'] = 'The ID of the context where the data was saved';
+$string['privacy:metadata:customfield_data:decvalue'] = 'Data value, when it is a decimal ';
+$string['privacy:metadata:customfield_data:fieldid'] = 'Field definition ID';
+$string['privacy:metadata:customfield_data:instanceid'] = 'Instance ID related to the data';
+$string['privacy:metadata:customfield_data:intvalue'] = 'Data value, when it is an integer';
+$string['privacy:metadata:customfield_data:shortcharvalue'] = 'Data value, when it is a short char';
+$string['privacy:metadata:customfield_data:timecreated'] = 'Time when data was created';
+$string['privacy:metadata:customfield_data:timemodified'] = 'Time when data was last modified';
+$string['privacy:metadata:customfield_data:value'] = 'Data value, when it is a text';
+$string['privacy:metadata:customfield_data:valueformat'] = 'The format of the value, when it is a text';
+$string['privacy:metadata:customfieldpluginsummary'] = 'Fields for various components';
+$string['privacy:metadata:filepurpose'] = 'File attached to the custom field data';
+$string['shortname'] = 'Short name';
+$string['shortname_help'] = 'Custom field short name is required, must be unique and can only contain latin letters, digits and undescore sign. It is not displayed to the users but may be used for synchronisation with external systems, in web services and APIs';
+$string['showdate'] = 'Show date';
+$string['specificsettings'] = 'Specific settings';
+$string['therearenofields'] = 'There are no fields in this category';
+$string['totopofcategory'] = 'To the top of category {$a}';
+$string['type'] = 'Type';
+$string['unknownhandler'] = 'Unable to find handler for custom fields for component {$a->component} and area {$a->area}';
+$string['yes'] = 'Yes';
diff --git a/lib/classes/plugininfo/customfield.php b/lib/classes/plugininfo/customfield.php
new file mode 100644 (file)
index 0000000..f830b61
--- /dev/null
@@ -0,0 +1,132 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Defines classes used for plugin info.
+ *
+ * @package    core
+ * @copyright  2018 Toni Barbera {@link http://www.moodle.org}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core\plugininfo;
+
+use moodle_url;
+use admin_settingpage;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class for admin tool plugins
+ */
+class customfield extends base {
+
+    /**
+     * Allow uninstall
+     * @return bool
+     */
+    public function is_uninstall_allowed() {
+        return true;
+    }
+
+    /**
+     * Return URL used for management of plugins of this type.
+     * @return moodle_url
+     */
+    public static function get_manage_url() {
+        return new moodle_url('/admin/settings.php', array('section' => 'managecustomfields'));
+    }
+
+    /**
+     * Enabled plugins
+     * @return array|null
+     */
+    public static function get_enabled_plugins() {
+        global $DB;
+
+        // Get all available plugins.
+        $plugins = \core_plugin_manager::instance()->get_installed_plugins('customfield');
+        if (!$plugins) {
+            return array();
+        }
+
+        // Check they are enabled using get_config (which is cached and hopefully fast).
+        $enabled = array();
+        foreach ($plugins as $plugin => $version) {
+            $disabled = get_config('customfield_' . $plugin, 'disabled');
+            if (empty($disabled)) {
+                $enabled[$plugin] = $plugin;
+            }
+        }
+
+        return $enabled;
+    }
+
+    /**
+     * Pre-uninstall hook.
+     *
+     * This is intended for disabling of plugin, some DB table purging, etc.
+     *
+     * NOTE: to be called from uninstall_plugin() only.
+     * @private
+     */
+    public function uninstall_cleanup() {
+        global $DB;
+        $DB->delete_records_select('customfield_data',
+            'fieldid IN (SELECT f.id FROM {customfield_field} f WHERE f.type = ?)', [$this->name]);
+        $DB->delete_records('customfield_field', ['type' => $this->name]);
+        parent::uninstall_cleanup();
+    }
+
+    /**
+     * Setting section name
+     *
+     * @return null|string
+     */
+    public function get_settings_section_name() {
+        return 'customfieldsetting' . $this->name;
+    }
+
+    /**
+     * Load the global settings for a particular availability plugin (if there are any)
+     *
+     * @param \part_of_admin_tree $adminroot
+     * @param string $parentnodename
+     * @param bool $hassiteconfig
+     */
+    public function load_settings(\part_of_admin_tree $adminroot, $parentnodename, $hassiteconfig) {
+        global $CFG, $USER, $DB, $OUTPUT, $PAGE; // In case settings.php wants to refer to them.
+        $ADMIN = $adminroot; // May be used in settings.php.
+        $plugininfo = $this; // Also can be used inside settings.php
+        $availability = $this; // Also to be used inside settings.php.
+
+        if (!$this->is_installed_and_upgraded()) {
+            return;
+        }
+
+        if (!$hassiteconfig or !file_exists($this->full_path('settings.php'))) {
+            return;
+        }
+
+        $section = $this->get_settings_section_name();
+
+        $settings = new admin_settingpage($section, $this->displayname, 'moodle/site:config', $this->is_enabled() === false);
+        include($this->full_path('settings.php')); // This may also set $settings to null.
+
+        if ($settings) {
+            $ADMIN->add($parentnodename, $settings);
+        }
+    }
+}
index 7de20fc..f25945e 100644 (file)
         <INDEX NAME="uniqueuserfavouriteitem" UNIQUE="true" FIELDS="component, itemtype, itemid, contextid, userid"/>
       </INDEXES>
     </TABLE>
+    <TABLE NAME="customfield_category" COMMENT="core_customfield category table">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="name" TYPE="char" LENGTH="400" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="description" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="descriptionformat" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="sortorder" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="component" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="area" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="itemid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="contextid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="contextid" TYPE="foreign" FIELDS="contextid" REFTABLE="context" REFFIELDS="id"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="component_area_itemid" UNIQUE="false" FIELDS="component, area, itemid, sortorder"/>
+      </INDEXES>
+    </TABLE>
+    <TABLE NAME="customfield_field" COMMENT="core_customfield field table">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="shortname" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="name" TYPE="char" LENGTH="400" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="type" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="description" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="descriptionformat" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="sortorder" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="categoryid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="configdata" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="categoryid" TYPE="foreign" FIELDS="categoryid" REFTABLE="customfield_category" REFFIELDS="id"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="categoryid_sortorder" UNIQUE="false" FIELDS="categoryid, sortorder"/>
+      </INDEXES>
+    </TABLE>
+    <TABLE NAME="customfield_data" COMMENT="core_customfield data table">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="fieldid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="instanceid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="intvalue" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="decvalue" TYPE="number" LENGTH="10" NOTNULL="false" SEQUENCE="false" DECIMALS="5"/>
+        <FIELD NAME="shortcharvalue" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="charvalue" TYPE="char" LENGTH="1333" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="value" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="valueformat" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="contextid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="fieldid" TYPE="foreign" FIELDS="fieldid" REFTABLE="customfield_field" REFFIELDS="id"/>
+        <KEY NAME="contextid" TYPE="foreign" FIELDS="contextid" REFTABLE="context" REFFIELDS="id"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="instanceid-fieldid" UNIQUE="true" FIELDS="instanceid, fieldid"/>
+        <INDEX NAME="fieldid-intvalue" UNIQUE="false" FIELDS="fieldid, intvalue"/>
+        <INDEX NAME="fieldid-shortcharvalue" UNIQUE="false" FIELDS="fieldid, shortcharvalue"/>
+        <INDEX NAME="fieldid-decvalue" UNIQUE="false" FIELDS="fieldid, decvalue"/>
+      </INDEXES>
+    </TABLE>
   </TABLES>
 </XMLDB>
\ No newline at end of file
index 066f226..e69b247 100644 (file)
@@ -2433,6 +2433,54 @@ $functions = array(
         'type'        => 'read',
         'services'    => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
+    'core_customfield_delete_field' => array(
+        'classname'   => 'core_customfield_external',
+        'methodname'  => 'delete_field',
+        'classpath'   => 'customfield/externallib.php',
+        'description' => 'Deletes an entry',
+        'type'        => 'write',
+        'ajax'        => 'true'
+    ),
+    'core_customfield_reload_template' => array(
+        'classname'   => 'core_customfield_external',
+        'methodname'  => 'reload_template',
+        'classpath'   => 'customfield/externallib.php',
+        'description' => 'Reloads template',
+        'type'        => 'read',
+        'ajax'        => 'true'
+    ),
+    'core_customfield_create_category' => array(
+        'classname'   => 'core_customfield_external',
+        'methodname'  => 'create_category',
+        'classpath'   => 'customfield/externallib.php',
+        'description' => 'Creates a new category',
+        'type'        => 'write',
+        'ajax'        => 'true'
+    ),
+    'core_customfield_delete_category' => array(
+        'classname'   => 'core_customfield_external',
+        'methodname'  => 'delete_category',
+        'classpath'   => 'customfield/externallib.php',
+        'description' => 'Deletes a category',
+        'type'        => 'write',
+        'ajax'        => 'true'
+    ),
+    'core_customfield_move_field'   => array(
+        'classname'   => 'core_customfield_external',
+        'methodname'  => 'move_field',
+        'classpath'   => 'customfield/externallib.php',
+        'description' => 'Drag and drop',
+        'type'        => 'write',
+        'ajax'        => 'true'
+    ),
+    'core_customfield_move_category' => array(
+        'classname'   => 'core_customfield_external',
+        'methodname'  => 'move_category',
+        'classpath'   => 'customfield/externallib.php',
+        'description' => 'Drag and drop categories',
+        'type'        => 'write',
+        'ajax'        => 'true'
+    )
 );
 
 $services = array(
index bf81f99..10a72d2 100644 (file)
@@ -2562,5 +2562,99 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2019011501.00);
     }
 
+    if ($oldversion < 2019011801.00) {
+
+        // Define table customfield_category to be created.
+        $table = new xmldb_table('customfield_category');
+
+        // Adding fields to table customfield_category.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('name', XMLDB_TYPE_CHAR, '400', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('description', XMLDB_TYPE_TEXT, null, null, null, null, null);
+        $table->add_field('descriptionformat', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+        $table->add_field('sortorder', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('component', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('area', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('itemid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+        $table->add_field('contextid', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+
+        // Adding keys to table customfield_category.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+        $table->add_key('contextid', XMLDB_KEY_FOREIGN, ['contextid'], 'context', ['id']);
+
+        // Adding indexes to table customfield_category.
+        $table->add_index('component_area_itemid', XMLDB_INDEX_NOTUNIQUE, ['component', 'area', 'itemid', 'sortorder']);
+
+        // Conditionally launch create table for customfield_category.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Define table customfield_field to be created.
+        $table = new xmldb_table('customfield_field');
+
+        // Adding fields to table customfield_field.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('shortname', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('name', XMLDB_TYPE_CHAR, '400', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('type', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('description', XMLDB_TYPE_TEXT, null, null, null, null, null);
+        $table->add_field('descriptionformat', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+        $table->add_field('sortorder', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+        $table->add_field('categoryid', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+        $table->add_field('configdata', XMLDB_TYPE_TEXT, null, null, null, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+
+        // Adding keys to table customfield_field.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+        $table->add_key('categoryid', XMLDB_KEY_FOREIGN, ['categoryid'], 'customfield_category', ['id']);
+
+        // Adding indexes to table customfield_field.
+        $table->add_index('categoryid_sortorder', XMLDB_INDEX_NOTUNIQUE, ['categoryid', 'sortorder']);
+
+        // Conditionally launch create table for customfield_field.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Define table customfield_data to be created.
+        $table = new xmldb_table('customfield_data');
+
+        // Adding fields to table customfield_data.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('fieldid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('instanceid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('intvalue', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+        $table->add_field('decvalue', XMLDB_TYPE_NUMBER, '10, 5', null, null, null, null);
+        $table->add_field('shortcharvalue', XMLDB_TYPE_CHAR, '255', null, null, null, null);
+        $table->add_field('charvalue', XMLDB_TYPE_CHAR, '1333', null, null, null, null);
+        $table->add_field('value', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+        $table->add_field('valueformat', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('contextid', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+
+        // Adding keys to table customfield_data.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+        $table->add_key('fieldid', XMLDB_KEY_FOREIGN, ['fieldid'], 'customfield_field', ['id']);
+        $table->add_key('contextid', XMLDB_KEY_FOREIGN, ['contextid'], 'context', ['id']);
+
+        // Adding indexes to table customfield_data.
+        $table->add_index('instanceid-fieldid', XMLDB_INDEX_UNIQUE, ['instanceid', 'fieldid']);
+        $table->add_index('fieldid-intvalue', XMLDB_INDEX_NOTUNIQUE, ['fieldid', 'intvalue']);
+        $table->add_index('fieldid-shortcharvalue', XMLDB_INDEX_NOTUNIQUE, ['fieldid', 'shortcharvalue']);
+        $table->add_index('fieldid-decvalue', XMLDB_INDEX_NOTUNIQUE, ['fieldid', 'decvalue']);
+
+        // Conditionally launch create table for customfield_data.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        upgrade_main_savepoint(true, 2019011801.00);
+    }
+
     return true;
 }
index 6ef405f..29ea4c5 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2019011800.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2019011801.00;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.