From 89dbe63dcf1400d362344b0018db8e95adc28a63 Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Fri, 9 Feb 2024 13:15:18 +0000 Subject: [PATCH] MDL-80858 customfield_number: new field type for numeric data. Provide field type to allow for number type data to be stored and presented within those components that already support custom fields. This is especially useful for reporting purposes. --- customfield/classes/data_controller.php | 11 +- .../field/number/classes/data_controller.php | 131 ++++++++++++++++ .../field/number/classes/field_controller.php | 106 +++++++++++++ .../field/number/classes/privacy/provider.php | 77 ++++++++++ .../number/lang/en/customfield_number.php | 36 +++++ .../number/tests/data_controller_test.php | 142 ++++++++++++++++++ .../number/tests/field_controller_test.php | 107 +++++++++++++ customfield/field/number/version.php | 30 ++++ lib/plugins.json | 1 + 9 files changed, 639 insertions(+), 2 deletions(-) create mode 100644 customfield/field/number/classes/data_controller.php create mode 100644 customfield/field/number/classes/field_controller.php create mode 100644 customfield/field/number/classes/privacy/provider.php create mode 100644 customfield/field/number/lang/en/customfield_number.php create mode 100644 customfield/field/number/tests/data_controller_test.php create mode 100644 customfield/field/number/tests/field_controller_test.php create mode 100644 customfield/field/number/version.php diff --git a/customfield/classes/data_controller.php b/customfield/classes/data_controller.php index 7ce5a3225f8..77f00594570 100644 --- a/customfield/classes/data_controller.php +++ b/customfield/classes/data_controller.php @@ -200,8 +200,15 @@ abstract class data_controller { if (!property_exists($datanew, $elementname)) { return; } - $value = $datanew->$elementname; - $this->data->set($this->datafield(), $value); + $datafieldvalue = $value = $datanew->{$elementname}; + + // For numeric datafields, persistent won't allow empty string, swap for null. + $datafield = $this->datafield(); + if ($datafield === 'intvalue' || $datafield === 'decvalue') { + $datafieldvalue = $datafieldvalue === '' ? null : $datafieldvalue; + } + + $this->data->set($datafield, $datafieldvalue); $this->data->set('value', $value); $this->save(); } diff --git a/customfield/field/number/classes/data_controller.php b/customfield/field/number/classes/data_controller.php new file mode 100644 index 00000000000..52be04a3007 --- /dev/null +++ b/customfield/field/number/classes/data_controller.php @@ -0,0 +1,131 @@ +. + +declare(strict_types=1); + +namespace customfield_number; + +use MoodleQuickForm; + +/** + * Data controller class + * + * @package customfield_number + * @copyright 2024 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class data_controller extends \core_customfield\data_controller { + + /** + * Return the name of the field where the information is stored + * + * @return string + */ + public function datafield(): string { + return 'decvalue'; + } + + /** + * Add form elements for editing the custom field instance + * + * @param MoodleQuickForm $mform + */ + public function instance_form_definition(MoodleQuickForm $mform): void { + $elementname = $this->get_form_element_name(); + + $mform->addElement('float', $elementname, $this->get_field()->get_formatted_name()); + if (!$this->get('id')) { + $mform->setDefault($elementname, $this->get_default_value()); + } + } + + /** + * Validate the data on the field instance form + * + * @param array $data + * @param array $files + * @return array + */ + public function instance_form_validation(array $data, array $files): array { + $errors = parent::instance_form_validation($data, $files); + + $elementname = $this->get_form_element_name(); + $elementvalue = $data[$elementname]; + + $minimumvalue = $this->get_field()->get_configdata_property('minimumvalue') ?? ''; + $maximumvalue = $this->get_field()->get_configdata_property('maximumvalue') ?? ''; + + // Early exit if element value isn't specified, or neither maximum/minimum are specified. + if ($elementvalue === '' || ($minimumvalue === '' && $maximumvalue === '')) { + return $errors; + } + + $elementvaluefloat = (float) $elementvalue; + $minimumvaluefloat = (float) $minimumvalue; + $maximumvaluefloat = (float) $maximumvalue; + + $decimalplaces = (int) $this->get_field()->get_configdata_property('decimalplaces'); + + // Value must be greater than minimum. If maximum is set, value must not exceed it. + if ($minimumvalue !== '' && $elementvaluefloat < $minimumvaluefloat) { + $errors[$elementname] = get_string('minimumvalueerror', 'customfield_number', + format_float($minimumvaluefloat, $decimalplaces)); + } else if ($maximumvalue !== '' && $elementvaluefloat > $maximumvaluefloat) { + $errors[$elementname] = get_string('maximumvalueerror', 'customfield_number', + format_float($maximumvaluefloat, $decimalplaces)); + } + + return $errors; + } + + /** + * Checks if the value is empty + * + * @param mixed $value + * @return bool + */ + protected function is_empty($value): bool { + return (string) $value === ''; + } + + /** + * Returns the default value in non human-readable format + * + * @return float|null + */ + public function get_default_value(): ?float { + $defaultvalue = $this->get_field()->get_configdata_property('defaultvalue'); + if ($this->is_empty($defaultvalue)) { + return null; + } + return (float) $defaultvalue; + } + + /** + * Returns value in a human-readable format + * + * @return string|null + */ + public function export_value(): ?string { + $value = $this->get_value(); + if ($this->is_empty($value)) { + return null; + } + + $decimalplaces = (int) $this->get_field()->get_configdata_property('decimalplaces'); + return format_float((float) $value, $decimalplaces); + } +} diff --git a/customfield/field/number/classes/field_controller.php b/customfield/field/number/classes/field_controller.php new file mode 100644 index 00000000000..0bd0413b593 --- /dev/null +++ b/customfield/field/number/classes/field_controller.php @@ -0,0 +1,106 @@ +. + +declare(strict_types=1); + +namespace customfield_number; + +use MoodleQuickForm; + +/** + * Field controller class + * + * @package customfield_number + * @copyright 2024 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class field_controller extends \core_customfield\field_controller { + + /** + * Add form elements for editing the custom field definition + * + * @param MoodleQuickForm $mform + */ + public function config_form_definition(MoodleQuickForm $mform): void { + $mform->addElement('header', 'specificsettings', get_string('specificsettings', 'customfield_number')); + $mform->setExpanded('specificsettings'); + + // Default value. + $mform->addElement('float', 'configdata[defaultvalue]', get_string('defaultvalue', 'core_customfield')); + if ($this->get_configdata_property('defaultvalue') === null) { + $mform->setDefault('configdata[defaultvalue]', ''); + } + + // Minimum value. + $mform->addElement('float', 'configdata[minimumvalue]', get_string('minimumvalue', 'customfield_number')); + if ($this->get_configdata_property('minimumvalue') === null) { + $mform->setDefault('configdata[minimumvalue]', ''); + } + + // Maximum value. + $mform->addElement('float', 'configdata[maximumvalue]', get_string('maximumvalue', 'customfield_number')); + if ($this->get_configdata_property('maximumvalue') === null) { + $mform->setDefault('configdata[maximumvalue]', ''); + } + + // Decimal places. + $mform->addElement('text', 'configdata[decimalplaces]', get_string('decimalplaces', 'customfield_number')); + if ($this->get_configdata_property('decimalplaces') === null) { + $mform->setDefault('configdata[decimalplaces]', 0); + } + $mform->setType('configdata[decimalplaces]', PARAM_INT); + } + + /** + * Validate the data on the field configuration form + * + * @param array $data + * @param array $files + * @return array + */ + public function config_form_validation(array $data, $files = []): array { + $errors = parent::config_form_validation($data, $files); + + // Each of these configuration fields are optional. + $defaultvalue = $data['configdata']['defaultvalue'] ?? ''; + $minimumvalue = $data['configdata']['minimumvalue'] ?? ''; + $maximumvalue = $data['configdata']['maximumvalue'] ?? ''; + + // Early exit if neither maximum/minimum are specified. + if ($minimumvalue === '' && $maximumvalue === '') { + return $errors; + } + + $minimumvaluefloat = (float) $minimumvalue; + $maximumvaluefloat = (float) $maximumvalue; + + // If maximum is set, it must be greater than minimum. + if ($maximumvalue !== '' && $minimumvaluefloat >= $maximumvaluefloat) { + $errors['configdata[minimumvalue]'] = get_string('minimumvalueconfigerror', 'customfield_number'); + } + + // If default value is set, it must be in range of minimum and maximum. + if ($defaultvalue !== '') { + $defaultvaluefloat = (float) $defaultvalue; + + if ($defaultvaluefloat < $minimumvaluefloat || ($maximumvalue !== '' && $defaultvaluefloat > $maximumvaluefloat)) { + $errors['configdata[defaultvalue]'] = get_string('defaultvalueconfigerror', 'customfield_number'); + } + } + + return $errors; + } +} diff --git a/customfield/field/number/classes/privacy/provider.php b/customfield/field/number/classes/privacy/provider.php new file mode 100644 index 00000000000..686b18ef3b0 --- /dev/null +++ b/customfield/field/number/classes/privacy/provider.php @@ -0,0 +1,77 @@ +. + +declare(strict_types=1); + +namespace customfield_number\privacy; + +use core_customfield\data_controller; +use core_customfield\privacy\customfield_provider; +use core_privacy\local\metadata\null_provider; +use core_privacy\local\request\writer; +use stdClass; + +/** + * Plugin privacy provider + * + * @package customfield_number + * @copyright 2024 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements null_provider, customfield_provider { + + /** + * Plugin language string identifier to explain why this plugin stores no data + * + * @return string + */ + public static function get_reason(): string { + return 'privacy:metadata'; + } + + /** + * Preprocesses data object that is going to be exported + * + * @param data_controller $data + * @param stdClass $exportdata + * @param array $subcontext + */ + public static function export_customfield_data(data_controller $data, stdClass $exportdata, array $subcontext): void { + writer::with_context($data->get_context())->export_data($subcontext, $exportdata); + } + + /** + * Callback to clean up any related files prior to data record deletion + * + * @param string $select + * @param array $params + * @param int[] $contextids + */ + public static function before_delete_data(string $select, array $params, array $contextids): void { + + } + + /** + * Callback to clean up any related field prior to field record deletion + * + * @param string $select + * @param array $params + * @param int[] $contextids + */ + public static function before_delete_fields(string $select, array $params, array $contextids): void { + + } +} diff --git a/customfield/field/number/lang/en/customfield_number.php b/customfield/field/number/lang/en/customfield_number.php new file mode 100644 index 00000000000..aff194f18c8 --- /dev/null +++ b/customfield/field/number/lang/en/customfield_number.php @@ -0,0 +1,36 @@ +. + +/** + * Plugin language strings + * + * @package customfield_number + * @copyright 2024 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die; + +$string['decimalplaces'] = 'Decimal places'; +$string['defaultvalueconfigerror'] = 'Default value must be between minimum and maximum'; +$string['maximumvalue'] = 'Maximum value'; +$string['maximumvalueerror'] = 'Value must be less than or equal to {$a}'; +$string['minimumvalue'] = 'Minimum value'; +$string['minimumvalueconfigerror'] = 'Minimum value must be less than maximum'; +$string['minimumvalueerror'] = 'Value must be greater than or equal to {$a}'; +$string['pluginname'] = 'Number'; +$string['privacy:metadata'] = 'The number custom field plugin does not store any personal data'; +$string['specificsettings'] = 'Number field settings'; diff --git a/customfield/field/number/tests/data_controller_test.php b/customfield/field/number/tests/data_controller_test.php new file mode 100644 index 00000000000..5026bc0b81b --- /dev/null +++ b/customfield/field/number/tests/data_controller_test.php @@ -0,0 +1,142 @@ +. + +declare(strict_types=1); + +namespace customfield_number; + +use advanced_testcase; +use core_customfield_generator; +use core_customfield_test_instance_form; + +/** + * Tests for the data controller + * + * @package customfield_number + * @covers \customfield_number\data_controller + * @copyright 2024 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class data_controller_test extends advanced_testcase { + + /** + * Test that using base field controller returns our number type + */ + public function test_create(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + + /** @var core_customfield_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); + + $category = $generator->create_category(); + $field = $generator->create_field(['categoryid' => $category->get('id'), 'type' => 'number']); + $data = $generator->add_instance_data($field, (int) $course->id, 1); + + $this->assertInstanceOf(data_controller::class, \core_customfield\data_controller::create($data->get('id'))); + $this->assertInstanceOf(data_controller::class, \core_customfield\data_controller::create(0, $data->to_record())); + $this->assertInstanceOf(data_controller::class, \core_customfield\data_controller::create(0, null, $field)); + } + + /** + * Test validation of field instance form + */ + public function test_form_validation(): void { + global $CFG; + + require_once("{$CFG->dirroot}/customfield/tests/fixtures/test_instance_form.php"); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + + /** @var core_customfield_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); + + $category = $generator->create_category(); + $field = $generator->create_field(['categoryid' => $category->get('id'), 'type' => 'number', 'configdata' => [ + 'minimumvalue' => 5, + 'maximumvalue' => 10, + ]]); + + $data = \core_customfield\data_controller::create(0, null, $field); + + // Less than minimum value. + $formdata = array_merge((array) $course, ['customfield_' . $field->get('shortname') => 2]); + $this->assertEquals([ + 'customfield_' . $field->get('shortname') => 'Value must be greater than or equal to 5', + ], $data->instance_form_validation($formdata, [])); + + // Greater than maximum value. + $formdata = array_merge((array) $course, ['customfield_' . $field->get('shortname') => 12]); + $this->assertEquals([ + 'customfield_' . $field->get('shortname') => 'Value must be less than or equal to 10', + ], $data->instance_form_validation($formdata, [])); + } + + /** + * Test submitting field instance form + */ + public function test_form_save(): void { + global $CFG; + + require_once("{$CFG->dirroot}/customfield/tests/fixtures/test_instance_form.php"); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + + /** @var core_customfield_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); + + $category = $generator->create_category(); + $field = $generator->create_field(['categoryid' => $category->get('id'), 'type' => 'number']); + + $formdata = array_merge((array) $course, ['customfield_' . $field->get('shortname') => 42]); + core_customfield_test_instance_form::mock_submit($formdata); + + $form = new core_customfield_test_instance_form('POST', ['handler' => $category->get_handler(), 'instance' => $course]); + $this->assertTrue($form->is_validated()); + + $formsubmission = $form->get_data(); + $this->assertEquals(42.0, $formsubmission->{'customfield_' . $field->get('shortname')}); + $category->get_handler()->instance_form_save($formsubmission); + } + + /** + * Test exporting instance + */ + public function test_export_value(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + + /** @var core_customfield_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); + + $category = $generator->create_category(); + $field = $generator->create_field(['categoryid' => $category->get('id'), 'type' => 'number']); + $data = $generator->add_instance_data($field, (int) $course->id, 42); + + $result = \core_customfield\data_controller::create($data->get('id'))->export_value(); + $this->assertEquals(42.0, $result); + } +} diff --git a/customfield/field/number/tests/field_controller_test.php b/customfield/field/number/tests/field_controller_test.php new file mode 100644 index 00000000000..1787d8f8383 --- /dev/null +++ b/customfield/field/number/tests/field_controller_test.php @@ -0,0 +1,107 @@ +. + +declare(strict_types=1); + +namespace customfield_number; + +use advanced_testcase; +use core_customfield_generator; +use core_customfield\field_config_form; + +/** + * Tests for the field controller + * + * @package customfield_number + * @covers \customfield_number\field_controller + * @copyright 2024 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class field_controller_test extends advanced_testcase { + + /** + * Test that using base field controller returns our number type + */ + public function test_create(): void { + $this->resetAfterTest(); + + /** @var core_customfield_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); + + $category = $generator->create_category(); + $field = $generator->create_field(['categoryid' => $category->get('id'), 'type' => 'number']); + + $this->assertInstanceOf(field_controller::class, \core_customfield\field_controller::create((int) $field->get('id'))); + $this->assertInstanceOf(field_controller::class, \core_customfield\field_controller::create(0, $field->to_record())); + } + + /** + * Data provider for {@see test_form_definition} + * + * @return array[] + */ + public static function form_definition_provider(): array { + return [ + 'Defaults' => ['', '', '', true], + 'Minimum greater than maximum' => ['', 12, 10, false], + 'Default value less than minimum' => [1, 10, 12, false], + 'Default value greater than maximum' => [13, 10, 12, false], + 'Valid' => [11, 10, 12, true], + ]; + } + + /** + * Test submitting field definition form + * + * @param float|string $defaultvalue + * @param float|string $minimumvalue + * @param float|string $maximumvalue + * @param bool $expected + * + * @dataProvider form_definition_provider + */ + public function test_form_definition( + float|string $defaultvalue, + float|string $minimumvalue, + float|string $maximumvalue, + bool $expected, + ): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + /** @var core_customfield_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); + + $category = $generator->create_category(); + $field = $generator->create_field(['categoryid' => $category->get('id'), 'type' => 'number']); + + $submitdata = (array) $field->to_record(); + $submitdata['configdata'] = array_merge($field->get('configdata'), [ + 'defaultvalue' => $defaultvalue, + 'minimumvalue' => $minimumvalue, + 'maximumvalue' => $maximumvalue, + ]); + + $formdata = field_config_form::mock_ajax_submit($submitdata); + $form = new field_config_form(null, null, 'post', '', null, true, $formdata, true); + + $form->set_data_for_dynamic_submission(); + $this->assertEquals($expected, $form->is_validated()); + if ($expected) { + $form->process_dynamic_submission(); + } + } +} diff --git a/customfield/field/number/version.php b/customfield/field/number/version.php new file mode 100644 index 00000000000..45b4af97c80 --- /dev/null +++ b/customfield/field/number/version.php @@ -0,0 +1,30 @@ +. + +/** + * Plugin version details + * + * @package customfield_number + * @copyright 2024 Paul Holden + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die; + +$plugin->component = 'customfield_number'; +$plugin->version = 2024042200; +$plugin->requires = 2024041600; +$plugin->maturity = MATURITY_STABLE; diff --git a/lib/plugins.json b/lib/plugins.json index e3b44ae6ad5..28fe9144e58 100644 --- a/lib/plugins.json +++ b/lib/plugins.json @@ -144,6 +144,7 @@ "customfield": [ "checkbox", "date", + "number", "select", "text", "textarea"