diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c2c4b7..b6b2860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Breaking Change +- The `ComponentInterface` has a new method, `advancedValidations()` which returns an invokable `ValidationInterface` instance. +### Added +- Support for JSON Logic into the component validation process. ### Fixes - Fixed an issue where the `Currency` component failed validation when not required and given null values. +- Fixed an instance where the `$subject` parameter to `str_replace()` could have been null in `Textfield::processValidations()`, as this was deprecated in PHP 8.1. ## [v1.0.1] 2024-05-15 ### Changed diff --git a/docs/index.md b/docs/index.md index 37dc2da..068a743 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,7 +33,7 @@ There are a couple pieces to be aware of: - **Forms** are shown when you render a form definition. The form can be read-write or read-only (to display a submitted form). Forms produce submissions in the form of key:value JSON documents. ## Supported Features -Formiojs offers a lot of functionality. Dynamic Forms for Laravel has implemented a limited subset of all its available features. +Formiojs offers a lot of functionality. Dynamic Forms for Laravel has implemented a limited subset of all its available features, including JSON Logic support for complex conditions to show/hide fields, calculate values, and validate data. Most of the decisions not to include something were driven by what would give us a good minimum viable product. If there are missing features that you would like to see, please feel free to submit an issue to discuss including it. diff --git a/docs/upgrading.md b/docs/upgrading.md index 4e641da..b024ed6 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -1,5 +1,14 @@ # Upgrading +## v1.1.0 +This version adds a new `advancedValidations()` method to the `ComponentInterface`. + +```php +public function advancedValidations(): ?ValidationInterface; +``` + +This is implemented in the `BaseComponent`. However, if you have implemented this interface elsewhere, you should update your implementations. + ## v1.0.0 This version swaps to the Formiojs v5 release candidate and assumes Bootstrap v5 and FontAwesome 6 are in use. The package now assumes Laravel 11, Laravel Vite, and PHP 8.2+. diff --git a/src/Components/BaseComponent.php b/src/Components/BaseComponent.php index 06f2640..a402096 100644 --- a/src/Components/BaseComponent.php +++ b/src/Components/BaseComponent.php @@ -15,6 +15,8 @@ use Northwestern\SysDev\DynamicForms\Errors\ConditionalNotImplemented; use Northwestern\SysDev\DynamicForms\Errors\InvalidDefinitionError; use Northwestern\SysDev\DynamicForms\Errors\ValidationNotImplementedError; +use Northwestern\SysDev\DynamicForms\Validation\JSONValidation; +use Northwestern\SysDev\DynamicForms\Validation\ValidationInterface; /** * Implements common functionality for all components. @@ -282,6 +284,15 @@ public function validations(): array return $this->validations; } + public function advancedValidations(): ?ValidationInterface + { + if ($this->validation('json')) { + return new JSONValidation($this->validation('json')); + } + + return null; + } + public function additional(string $key): mixed { return Arr::get($this->additional, $key); diff --git a/src/Components/ComponentInterface.php b/src/Components/ComponentInterface.php index 15ae314..10ff3a0 100644 --- a/src/Components/ComponentInterface.php +++ b/src/Components/ComponentInterface.php @@ -5,6 +5,7 @@ use Illuminate\Contracts\Support\MessageBag; use Northwestern\SysDev\DynamicForms\Calculation\CalculationInterface; use Northwestern\SysDev\DynamicForms\Conditional\ConditionalInterface; +use Northwestern\SysDev\DynamicForms\Validation\ValidationInterface; interface ComponentInterface { @@ -140,6 +141,11 @@ public function validation(string $name): mixed; */ public function validations(): array; + /** + * Returns an invokable Validation instance. + */ + public function advancedValidations(): ?ValidationInterface; + /** * Get other settings that are not used directly by the library, but may be present in the component schema. */ diff --git a/src/Components/Inputs/Textfield.php b/src/Components/Inputs/Textfield.php index 9bbdaec..02810e2 100644 --- a/src/Components/Inputs/Textfield.php +++ b/src/Components/Inputs/Textfield.php @@ -27,9 +27,11 @@ protected function processValidations(string $fieldKey, string $fieldLabel, mixe $rules->add(new CheckWordCount(CheckWordCount::MODE_MAXIMUM, $this->validation('maxWords'))); } - // PHP needs the regexp armoured with slashes, so... - $pattern = sprintf('/%s/', str_replace('/', '\/', $this->validation('pattern'))); - $rules->addIfNotNull(['regex', $pattern], $this->validation('pattern')); + if ($this->validation('pattern')) { + // PHP needs the regexp armoured with slashes, so... + $pattern = sprintf('/%s/', str_replace('/', '\/', $this->validation('pattern'))); + $rules->add(['regex', $pattern], $this->validation('pattern')); + } return $validator->make( [$fieldKey => $submissionValue], diff --git a/src/Forms/ValidatedForm.php b/src/Forms/ValidatedForm.php index d3501d5..3051b5d 100644 --- a/src/Forms/ValidatedForm.php +++ b/src/Forms/ValidatedForm.php @@ -39,6 +39,9 @@ public function __construct(array $components, array $values) foreach ($this->flatComponents as $component) { $messageBag->merge($component->validate()); + if ($component->advancedValidations()) { + $messageBag->merge($component->advancedValidations()($component, $this->valuesWhileProcessingForm())); + } $transformedValues->put($component->key(), $component->submissionValue()); } diff --git a/src/Validation/JSONValidation.php b/src/Validation/JSONValidation.php new file mode 100644 index 0000000..8a9f77f --- /dev/null +++ b/src/Validation/JSONValidation.php @@ -0,0 +1,53 @@ +jsonLogic = JsonLogicHelpers::convertDataVars($jsonLogic); + } + + public function __invoke(ComponentInterface $component, array $submissionValues): MessageBag + { + $bag = new MessageBagImpl; + + if (! $this->isValidCustomValidation($this->jsonLogic)) { + throw new InvalidDefinitionError( + 'Custom JSON Logic validations must always use the "if" parameter. The first argument to the statement must be the "true" case, and the second should be the error to display if the validation fails.', + $component->key(), + ); + } + + $validationResult = JsonLogic::apply($this->jsonLogic, $submissionValues); + + if ($validationResult !== true) { + $bag->add($component->key(), $validationResult); + } + + return $bag; + } + + /** + * Custom JSON Logic validations must always use the "if" parameter. The first argument to the statement + * must be the "true" case, and the second should be the error to display if the validation fails. + * + * {@link https://help.form.io/developers/form-development/form-evaluations#custom-validation-1} + */ + protected function isValidCustomValidation(array $jsonLogic): bool + { + return isset($jsonLogic['if']) && + is_array($jsonLogic['if']) && + count($jsonLogic['if']) === 3; + } +} diff --git a/src/Validation/ValidationInterface.php b/src/Validation/ValidationInterface.php new file mode 100644 index 0000000..5cd85b0 --- /dev/null +++ b/src/Validation/ValidationInterface.php @@ -0,0 +1,11 @@ +assertEquals('foo', $component->defaultValue()); $this->assertNull($component->validation('required')); $this->assertEmpty($component->validations()); + $this->assertNull($component->advancedValidations()); $this->assertTrue($component->additional('disabled')); } diff --git a/tests/Validation/JSONValidationTest.php b/tests/Validation/JSONValidationTest.php new file mode 100644 index 0000000..431193e --- /dev/null +++ b/tests/Validation/JSONValidationTest.php @@ -0,0 +1,495 @@ +getComponent(validations: $jsonValidation); + + if (is_string($expected) && class_exists($expected)) { + $this->expectException($expected); + } + + $result = $component->advancedValidations()($component, $submissionValues); + + if (empty($expected)) { + $this->assertTrue($result->isEmpty(), 'Expected no validation errors, but some were found.'); + } else { + foreach ($expected as $field => $messages) { + $this->assertTrue( + $result->has($field), + "Expected validation error for field '{$field}'." + ); + foreach ($messages as $message) { + $this->assertContains( + $message, + $result->get($field), + "Expected message '{$message}' for field '{$field}'." + ); + } + } + + $this->assertCount(count($expected), $result->all()); + } + } + + public static function invokeDataProvider(): array + { + return [ + ...self::dataFuzzerProvider(), + 'should throw exception when "if" parameter is missing' => [ + 'jsonValidation' => json_decode('{ + "json": { + "===": [ + { + "var": "foo" + }, + "bar" + ] + } + }', true), + 'submissionValues' => ['foo' => 'foo'], + 'expected' => InvalidDefinitionError::class, + ], + 'should throw exception when "if" parameter has less than three arguments' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { "===": [ { "var": "foo" }, "bar" ] }, + true + ] + } + }', true), + 'submissionValues' => ['foo' => 'baz'], + 'expected' => InvalidDefinitionError::class, + ], + 'should throw exception when "if" parameter has more than three arguments' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { "===": [ { "var": "foo" }, "bar" ] }, + true, + "Values must be equal", + "Extra argument" + ] + } + }', true), + 'submissionValues' => ['foo' => 'baz'], + 'expected' => InvalidDefinitionError::class, + ], + 'should throw exception when "if" condition is not an array' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": "not an array" + } + }', true), + 'submissionValues' => ['foo' => 'baz'], + 'expected' => InvalidDefinitionError::class, + ], + 'should fail when values are not equal with custom message' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { "===": [ { "var": "foo" }, "bar" ] }, + true, + "Values must be equal" + ] + } + }', true), + 'submissionValues' => ['foo' => 'foo'], + 'expected' => [ + 'test' => [ + 'Values must be equal', + ], + ], + ], + 'should pass when values are equal' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { "===": [ { "var": "foo" }, "bar" ] }, + true, + "Values must be equal" + ] + } + }', true), + 'submissionValues' => ['foo' => 'bar'], + 'expected' => [], + ], + 'should fail when values are equal with custom message' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { "!==": [ { "var": "foo" }, "bar" ] }, + true, + "Values must not be equal" + ] + } + }', true), + 'submissionValues' => ['foo' => 'bar'], + 'expected' => [ + 'test' => [ + 'Values must not be equal', + ], + ], + ], + 'should pass when values are not equal' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { "!==": [ { "var": "foo" }, "bar" ] }, + true, + "Values must not be equal" + ] + } + }', true), + 'submissionValues' => ['foo' => 'baz'], + 'expected' => [], + ], + 'should fail when value is not in the list with custom message' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { "in": [ { "var": "foo" }, ["bar", "baz"] ] }, + true, + "Value must be either bar or baz" + ] + } + }', true), + 'submissionValues' => ['foo' => 'qux'], + 'expected' => [ + 'test' => [ + 'Value must be either bar or baz', + ], + ], + ], + 'should pass when value is in the list' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { "in": [ { "var": "foo" }, ["bar", "baz"] ] }, + true, + "Value must be either bar or baz" + ] + } + }', true), + 'submissionValues' => ['foo' => 'bar'], + 'expected' => [], + ], + 'should fail when value is not greater than the threshold with custom message' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { ">": [ { "var": "foo" }, 10 ] }, + true, + "Value must be greater than 10" + ] + } + }', true), + 'submissionValues' => ['foo' => 5], + 'expected' => [ + 'test' => [ + 'Value must be greater than 10', + ], + ], + ], + 'should pass when value is greater than the threshold' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { ">": [ { "var": "foo" }, 10 ] }, + true, + "Value must be greater than 10" + ] + } + }', true), + 'submissionValues' => ['foo' => 15], + 'expected' => [], + ], + 'should fail when value is not less than the threshold with custom message' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { "<": [ { "var": "foo" }, 20 ] }, + true, + "Value must be less than 20" + ] + } + }', true), + 'submissionValues' => ['foo' => 25], + 'expected' => [ + 'test' => [ + 'Value must be less than 20', + ], + ], + ], + 'should pass when value is less than the threshold' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { "<": [ { "var": "foo" }, 20 ] }, + true, + "Value must be less than 20" + ] + } + }', true), + 'submissionValues' => ['foo' => 15], + 'expected' => [], + ], + 'should fail multiple fields with custom messages' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { "===": [ { "var": "foo" }, "bar" ] }, + true, + "Foo must be equal to bar" + ] + } + }', true), + 'submissionValues' => ['foo' => 'baz'], + 'expected' => [ + 'test' => [ + 'Foo must be equal to bar', + ], + ], + ], + 'should handle nested conditions and fail with custom message' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { + "and": [ + { "===": [ { "var": "foo" }, "bar" ] }, + { ">": [ { "var": "baz" }, 10 ] } + ] + }, + true, + "Foo must be bar and baz must be greater than 10" + ] + } + }', true), + 'submissionValues' => ['foo' => 'bar', 'baz' => 5], + 'expected' => [ + 'test' => [ + 'Foo must be bar and baz must be greater than 10', + ], + ], + ], + 'should handle nested conditions and pass when all conditions are met' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { + "and": [ + { "===": [ { "var": "foo" }, "bar" ] }, + { ">": [ { "var": "baz" }, 10 ] } + ] + }, + true, + "Foo must be bar and baz must be greater than 10" + ] + } + }', true), + 'submissionValues' => ['foo' => 'bar', 'baz' => 15], + 'expected' => [], + ], + 'should fail when submissionValues is empty' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { "===": [ { "var": "foo" }, "bar" ] }, + true, + "Foo must be bar" + ] + } + }', true), + 'submissionValues' => [], + 'expected' => [ + 'test' => [ + 'Foo must be bar', + ], + ], + ], + 'should fail when submissionValues is null' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { "===": [ { "var": "foo" }, "bar" ] }, + true, + "Foo must be bar" + ] + } + }', true), + 'submissionValues' => ['foo' => null], + 'expected' => [ + 'test' => [ + 'Foo must be bar', + ], + ], + ], + 'should handle non-string values correctly' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { "===": [ { "var": "foo" }, 100 ] }, + true, + "Foo must be 100" + ] + } + }', true), + 'submissionValues' => ['foo' => '100'], + 'expected' => [ + 'test' => [ + 'Foo must be 100', + ], + ], + ], + 'should handle complex logical operators and fail with custom message' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { + "and": [ + { "===": [ { "var": "foo" }, "bar" ] }, + { "===": [ { "var": "baz" }, "qux" ] } + ] + }, + true, + "Foo must be bar and Baz must be qux" + ] + } + }', true), + 'submissionValues' => ['foo' => 'bar', 'baz' => 'not_qux'], + 'expected' => [ + 'test' => [ + 'Foo must be bar and Baz must be qux', + ], + ], + ], + 'should handle complex logical operators and pass when all conditions are met' => [ + 'jsonValidation' => json_decode('{ + "json": { + "if": [ + { + "and": [ + { "===": [ { "var": "foo" }, "bar" ] }, + { "===": [ { "var": "baz" }, "qux" ] } + ] + }, + true, + "Foo must be bar and Baz must be qux" + ] + } + }', true), + 'submissionValues' => ['foo' => 'bar', 'baz' => 'qux'], + 'expected' => [], + ], + ]; + } + + public static function dataFuzzerProvider(): array + { + $rule = json_decode('{ + "json": { + "if": [ + { + "<=": [ + { "var": "startsAt" }, + { "var": "endsAt" } + ] + }, + true, + "The absence end date cannot be earlier than the absence start date." + ] + } + }', true); + + return collect([ + // startsAt, endsAt, expectedToFail (bool) + 'should handle dates like the client-side code, fail with custom error message' => [ + '2024-10-05T05:00:00.000000Z', + '2024-10-01T05:00:00.000000Z', + self::DATE_EXPECT_FAIL, + ], + 'should handle dates like the client-side code, pass' => [ + '2024-10-01T05:00:00.000000Z', + '2024-10-05T05:00:00.000000Z', + self::DATE_EXPECT_PASS, + ], + ...collect(range(1, 12))->mapWithKeys(function (int $month) { + $month = str_pad($month, '1', '0', STR_PAD_LEFT); + + return [ + "should handle dates like the client-side code, pass with static low-end date vs 2024-{$month}-XX" => [ + '2024-01-01T05:00:00.000000Z', + "2024-{$month}-05T05:00:00.000000Z", + self::DATE_EXPECT_PASS, + ], + "should handle dates like the client-side code, fail with static high-end date vs 2024-{$month}-XX" => [ + "2024-{$month}-02T05:00:00.000000Z", + '2024-01-01T05:00:00.000000Z', + self::DATE_EXPECT_FAIL, + ], + ]; + })->all(), + 'should handle dates like the client-side code, pass with high month' => [ + '2024-01-01T05:00:00.000000Z', + '2024-10-05T05:00:00.000000Z', + self::DATE_EXPECT_PASS, + ], + ])->map(function (array $data) use ($rule): array { + [$startsAt, $endsAt, $expectedToFail] = $data; + + return [ + 'jsonValidation' => $rule, + 'submissionValues' => ['startsAt' => $startsAt, 'endsAt' => $endsAt], + 'expected' => $expectedToFail + ? ['test' => ['The absence end date cannot be earlier than the absence start date.']] + : [], + ]; + })->all(); + } + + private function getComponent( + array $validations = [], + ): ComponentInterface { + /** @var ComponentInterface $component */ + return new Textfield( + key: 'test', + label: 'Test', + errorLabel: null, + components: [], + validations: $validations, + hasMultipleValues: false, + conditional: null, + customConditional: null, + case: 'mixed', + calculateValue: null, + defaultValue: null, + additional: [], + ); + } +}