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"