From 26cc5a906c3c019d2766d7c37b8a3e3aee901470 Mon Sep 17 00:00:00 2001 From: Evan Schleret Date: Tue, 30 Jun 2026 18:14:16 +0200 Subject: [PATCH] feat(formforge): v2.0.0 release --- composer.json | 3 +- composer.lock | 141 +++- config/formforge.php | 4 + lang/en/messages.php | 10 + lang/en/validation.php | 1 + lang/fr/messages.php | 10 + lang/fr/validation.php | 1 + src/Definition/ApiBlueprint.php | 11 +- src/Definition/FieldBlueprint.php | 250 +++++- src/Definition/FieldType.php | 56 +- src/Definition/FormBlueprint.php | 76 +- src/Exceptions/FormNotFoundException.php | 5 + src/FormForgeServiceProvider.php | 15 + src/FormInstance.php | 123 ++- src/FormManager.php | 36 + .../Controllers/FormSubmissionController.php | 118 ++- .../Resources/FormDefinitionHttpResource.php | 29 + src/Management/FormMutationService.php | 204 ++++- src/Persistence/FormDefinitionRepository.php | 17 + src/ScopedFormManager.php | 86 +++ src/Submissions/SubmissionService.php | 58 +- src/Submissions/SubmissionValidator.php | 488 +++++++++++- src/Support/DefaultFormPublicLinkResolver.php | 40 + src/Support/FormPublicLinkResolver.php | 12 + src/Support/FormSchemaExportableFields.php | 476 ++++++++++++ src/Support/FormSchemaLayout.php | 514 +++++++++++-- src/Support/RichTextSanitizer.php | 25 + tests/Feature/FormForgeTest.php | 714 ++++++++++++++++-- tests/Feature/HttpApiTest.php | 323 +++++++- tests/TestCase.php | 2 + 30 files changed, 3594 insertions(+), 254 deletions(-) create mode 100644 src/Support/DefaultFormPublicLinkResolver.php create mode 100644 src/Support/FormPublicLinkResolver.php create mode 100644 src/Support/FormSchemaExportableFields.php create mode 100644 src/Support/RichTextSanitizer.php diff --git a/composer.json b/composer.json index 5f2989c..254d0b5 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,8 @@ "illuminate/filesystem": "^12.0 || ^13.0", "illuminate/http": "^12.0 || ^13.0", "illuminate/support": "^12.0 || ^13.0", - "illuminate/validation": "^12.0 || ^13.0" + "illuminate/validation": "^12.0 || ^13.0", + "mews/purifier": "^3.4" }, "require-dev": { "orchestra/testbench": "^10.0 || ^11.0", diff --git a/composer.lock b/composer.lock index bf6af04..90af989 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3152241a7ad8eaaf8514f68a46192519", + "content-hash": "d8f8596820be3f9948996d4f0f06b827", "packages": [ { "name": "brick/math", @@ -508,6 +508,67 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + }, + "time": "2025-10-17T16:34:55+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -1954,6 +2015,84 @@ ], "time": "2026-03-08T20:05:35+00:00" }, + { + "name": "mews/purifier", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/mewebstudio/Purifier.git", + "reference": "b2705cc6c832ce7229373418e191d71b6c037841" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mewebstudio/Purifier/zipball/b2705cc6c832ce7229373418e191d71b6c037841", + "reference": "b2705cc6c832ce7229373418e191d71b6c037841", + "shasum": "" + }, + "require": { + "ezyang/htmlpurifier": "^4.16.0", + "illuminate/config": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/filesystem": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^7.2|^8.0" + }, + "require-dev": { + "graham-campbell/testbench": "^3.2|^5.5.1|^6.1", + "mockery/mockery": "^1.3.3", + "phpunit/phpunit": "^8.0|^9.0|^10.0" + }, + "suggest": { + "laravel/framework": "To test the Laravel bindings", + "laravel/lumen-framework": "To test the Lumen bindings" + }, + "type": "package", + "extra": { + "laravel": { + "aliases": { + "Purifier": "Mews\\Purifier\\Facades\\Purifier" + }, + "providers": [ + "Mews\\Purifier\\PurifierServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Mews\\Purifier\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Muharrem ERİN", + "email": "me@mewebstudio.com", + "homepage": "https://github.com/mewebstudio", + "role": "Developer" + } + ], + "description": "Laravel 5/6/7/8/9/10 HtmlPurifier Package", + "homepage": "https://github.com/mewebstudio/purifier", + "keywords": [ + "Laravel Purifier", + "Laravel Security", + "Purifier", + "htmlpurifier", + "laravel HtmlPurifier", + "security", + "xss" + ], + "support": { + "issues": "https://github.com/mewebstudio/Purifier/issues", + "source": "https://github.com/mewebstudio/Purifier/tree/3.4.4" + }, + "time": "2026-04-15T16:41:08+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", diff --git a/config/formforge.php b/config/formforge.php index 69bf1a8..db1225a 100644 --- a/config/formforge.php +++ b/config/formforge.php @@ -311,6 +311,10 @@ 'key' => env('FORMFORGE_HTTP_FILE_URL_KEY', 'url'), ], ], + 'public_link' => [ + 'resolver' => \EvanSchleret\FormForge\Support\DefaultFormPublicLinkResolver::class, + 'base_url' => env('FORMFORGE_HTTP_PUBLIC_LINK_BASE_URL', null), + ], 'idempotency' => [ 'ttl_minutes' => env('FORMFORGE_HTTP_IDEMPOTENCY_TTL', 1440), ], diff --git a/lang/en/messages.php b/lang/en/messages.php index 81610ef..f94c169 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -6,6 +6,10 @@ 'route_param_required' => 'Route parameter [:name] is required.', 'route_param_numeric' => 'Route parameter [:name] must be numeric.', 'form_not_published' => 'This form is not published yet.', + 'form_not_available_yet' => 'This form is not available yet.', + 'form_paused' => 'This form is paused.', + 'form_response_limit_reached' => 'This form is closed.', + 'form_submission_code_invalid' => 'The submission code is invalid.', 'resolve_form_schema_not_found' => 'Form schema not found.', 'field_required' => 'The field field is required.', 'upload_staged_mode_required' => 'Upload staging endpoint requires uploads.mode=staged.', @@ -51,4 +55,10 @@ 'privacy_action_unsupported' => 'Unsupported GDPR action [:action]. Allowed values: none, anonymize, delete.', 'privacy_after_days_numeric' => 'GDPR [after_days] must be numeric or null.', 'privacy_after_days_min' => 'GDPR [after_days] must be greater than or equal to 0.', + 'address_line1' => 'Address line 1', + 'address_line2' => 'Address line 2', + 'address_city' => 'City', + 'address_state' => 'State', + 'address_zip' => 'Zip', + 'address_country' => 'Country', ]; diff --git a/lang/en/validation.php b/lang/en/validation.php index a1ecbca..8747872 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -7,6 +7,7 @@ 'unknown_field_identifier' => 'Unknown field identifier.', 'unknown_fields' => 'Payload contains unknown fields: :fields.', 'conflicting_payload_keys' => 'Payload contains conflicting field keys: :keys.', + 'field_validation_failed' => 'The value does not satisfy the validation rules.', 'form_not_found' => 'Form [:key] was not found.', 'form_version_not_found' => 'Form [:key] with version [:version] was not found.', 'immutable_version' => 'Form [:key] version [:version] is immutable and cannot be changed.', diff --git a/lang/fr/messages.php b/lang/fr/messages.php index 20fd2db..3385327 100644 --- a/lang/fr/messages.php +++ b/lang/fr/messages.php @@ -6,6 +6,10 @@ 'route_param_required' => 'Le paramètre de route [:name] est requis.', 'route_param_numeric' => 'Le paramètre de route [:name] doit être numérique.', 'form_not_published' => 'Ce formulaire n’est pas encore publié.', + 'form_not_available_yet' => 'Ce formulaire n’est pas encore disponible.', + 'form_paused' => 'Ce formulaire est en pause.', + 'form_response_limit_reached' => 'Ce formulaire est fermé.', + 'form_submission_code_invalid' => 'Le code de soumission est invalide.', 'resolve_form_schema_not_found' => 'Schéma du formulaire introuvable.', 'field_required' => 'Le champ field est requis.', 'upload_staged_mode_required' => 'Le point de terminaison de staging upload requiert uploads.mode=staged.', @@ -51,4 +55,10 @@ 'privacy_action_unsupported' => 'Action RGPD non supportée [:action]. Valeurs autorisées : none, anonymize, delete.', 'privacy_after_days_numeric' => 'RGPD [after_days] doit être numérique ou null.', 'privacy_after_days_min' => 'RGPD [after_days] doit être supérieur ou égal à 0.', + 'address_line1' => "Ligne d'adresse 1", + 'address_line2' => "Ligne d'adresse 2", + 'address_city' => 'Ville', + 'address_state' => 'État', + 'address_zip' => 'Code postal', + 'address_country' => 'Pays', ]; diff --git a/lang/fr/validation.php b/lang/fr/validation.php index 78a9012..9cea78d 100644 --- a/lang/fr/validation.php +++ b/lang/fr/validation.php @@ -7,6 +7,7 @@ 'unknown_field_identifier' => 'Identifiant de champ inconnu.', 'unknown_fields' => 'La charge utile contient des champs inconnus : :fields.', 'conflicting_payload_keys' => 'La charge utile contient des clés de champs en conflit : :keys.', + 'field_validation_failed' => 'La valeur ne respecte pas les règles de validation.', 'form_not_found' => 'Le formulaire [:key] est introuvable.', 'form_version_not_found' => 'Le formulaire [:key] avec la version [:version] est introuvable.', 'immutable_version' => 'Le formulaire [:key] version [:version] est immuable et ne peut pas être modifié.', diff --git a/src/Definition/ApiBlueprint.php b/src/Definition/ApiBlueprint.php index eeadc7f..2f2e6d1 100644 --- a/src/Definition/ApiBlueprint.php +++ b/src/Definition/ApiBlueprint.php @@ -111,8 +111,9 @@ public function toArray(): array private function normalizeConfig(array $config): array { $normalized = []; + $knownEndpoints = ['schema', 'submission', 'upload']; - foreach (['schema', 'submission', 'upload'] as $endpoint) { + foreach ($knownEndpoints as $endpoint) { $endpointConfig = $config[$endpoint] ?? []; if (! is_array($endpointConfig)) { @@ -142,6 +143,14 @@ private function normalizeConfig(array $config): array } } + foreach ($config as $key => $value) { + if (in_array($key, $knownEndpoints, true)) { + continue; + } + + $normalized[$key] = $value; + } + return $normalized; } diff --git a/src/Definition/FieldBlueprint.php b/src/Definition/FieldBlueprint.php index bb4a074..d3b34fa 100644 --- a/src/Definition/FieldBlueprint.php +++ b/src/Definition/FieldBlueprint.php @@ -6,6 +6,7 @@ use BadMethodCallException; use EvanSchleret\FormForge\Exceptions\InvalidFieldDefinitionException; +use EvanSchleret\FormForge\Support\RichTextSanitizer; class FieldBlueprint { @@ -13,6 +14,10 @@ class FieldBlueprint private string $type; + private ?string $temporalMode = null; + + private ?int $hourCycle = null; + private string $name; private ?string $fieldKey = null; @@ -45,8 +50,14 @@ class FieldBlueprint private bool $disabled = false; + private ?string $consentLabel = null; + + private ?string $display = null; + private array $accept = []; + private array $addressFields = []; + private ?int $maxSize = null; private ?int $maxFiles = null; @@ -59,6 +70,7 @@ class FieldBlueprint public function __construct(?FormBlueprint $form, string $type, string $name) { + $type = FieldType::normalize($type); FieldType::assert($type); $name = trim($name); @@ -70,18 +82,29 @@ public function __construct(?FormBlueprint $form, string $type, string $name) $this->form = $form; $this->type = $type; $this->name = $name; + + if ($type === FieldType::TEMPORAL) { + $this->temporalMode = 'date'; + } + + if ($type === FieldType::ADDRESS) { + $this->addressFields = $this->defaultAddressFields(); + } } public static function fromSchemaArray(array $schema): self { + $rawType = (string) ($schema['type'] ?? ''); $field = new self( null, - (string) ($schema['type'] ?? ''), + $rawType, (string) ($schema['name'] ?? ''), ); $field->fieldKey = isset($schema['field_key']) ? trim((string) $schema['field_key']) : null; - $field->label = isset($schema['label']) ? trim((string) $schema['label']) : null; + $field->label = isset($schema['label']) + ? self::nullIfEmpty(self::sanitizeRichText($schema['label'])) + : null; $field->required = (bool) ($schema['required'] ?? false); $field->default = $schema['default'] ?? null; $field->placeholder = isset($schema['placeholder']) ? (string) $schema['placeholder'] : null; @@ -94,7 +117,28 @@ public static function fromSchemaArray(array $schema): self $field->options = is_array($schema['options'] ?? null) ? $field->normalizeOptions($schema['options']) : []; $field->multiple = (bool) ($schema['multiple'] ?? false); $field->disabled = (bool) ($schema['disabled'] ?? false); + $field->consentLabel = isset($schema['consent_label']) ? trim((string) $schema['consent_label']) : null; + $field->display = isset($schema['display']) ? trim((string) $schema['display']) : null; + if (array_key_exists('temporal_mode', $schema) && is_string($schema['temporal_mode']) && FieldType::normalize($rawType) === FieldType::TEMPORAL) { + $field->temporalMode = self::normalizeTemporalMode($schema['temporal_mode']); + } + + if ($field->temporalMode === null && FieldType::temporalMode($rawType) !== null) { + $field->temporalMode = FieldType::temporalMode($rawType); + } + + if (array_key_exists('hour_cycle', $schema) && is_int($schema['hour_cycle']) && in_array($schema['hour_cycle'], [12, 24], true)) { + $field->hourCycle = $schema['hour_cycle']; + } + + if ($field->temporalMode === 'time' && $field->hourCycle === null) { + $field->hourCycle = 24; + } + $field->accept = array_values(array_filter(array_map('strval', (array) ($schema['accept'] ?? [])), static fn (string $value): bool => trim($value) !== '')); + if (array_key_exists('address_fields', $schema) && is_array($schema['address_fields'])) { + $field->addressFields = $field->normalizeAddressFields($schema['address_fields']); + } $field->maxSize = isset($schema['max_size']) ? (int) $schema['max_size'] : null; $field->maxFiles = isset($schema['max_files']) ? (int) $schema['max_files'] : null; @@ -128,6 +172,11 @@ public function type(): string return $this->type; } + public function temporalMode(): ?string + { + return $this->temporalMode; + } + public function name(): string { return $this->name; @@ -148,7 +197,7 @@ public function fieldKey(?string $fieldKey): self public function label(string $label): self { - $label = trim($label); + $label = self::sanitizeRichText($label); if ($label === '') { throw new InvalidFieldDefinitionException("Field label for [{$this->name}] cannot be empty."); @@ -270,6 +319,49 @@ public function disabled(bool $disabled = true): self return $this; } + public function consentLabel(string $consentLabel): self + { + $consentLabel = trim($consentLabel); + + if ($consentLabel === '') { + throw new InvalidFieldDefinitionException("consentLabel cannot be empty on field [{$this->name}]."); + } + + $this->consentLabel = $consentLabel; + + return $this; + } + + public function display(string $display): self + { + $display = trim($display); + $this->display = $display === '' ? null : $display; + + return $this; + } + + public function temporalModeValue(string $mode): self + { + $this->temporalMode = self::normalizeTemporalMode($mode); + + if ($this->temporalMode === 'time' && $this->hourCycle === null) { + $this->hourCycle = 24; + } + + return $this; + } + + public function hourCycle(int $hourCycle): self + { + if (! in_array($hourCycle, [12, 24], true)) { + throw new InvalidFieldDefinitionException("hourCycle must be 12 or 24 on field [{$this->name}]."); + } + + $this->hourCycle = $hourCycle; + + return $this; + } + public function accept(string|array $accept): self { $values = is_array($accept) ? $accept : [$accept]; @@ -414,6 +506,33 @@ public function visibilityValue(): ?string return $this->visibility; } + public function consentLabelValue(): ?string + { + return $this->consentLabel; + } + + public function displayValue(): ?string + { + return $this->display; + } + + public function hourCycleValue(): ?int + { + return $this->hourCycle; + } + + public function addressFieldsValue(): array + { + return $this->addressFields; + } + + public function addressFields(array $addressFields): self + { + $this->addressFields = $this->normalizeAddressFields($addressFields); + + return $this; + } + public function toSchemaArray(string $formKey, string $version): array { $schema = [ @@ -455,10 +574,30 @@ public function toSchemaArray(string $formKey, string $version): array $schema['disabled'] = true; } + if ($this->consentLabel !== null) { + $schema['consent_label'] = $this->consentLabel; + } + + if ($this->display !== null) { + $schema['display'] = $this->display; + } + + if ($this->temporalMode !== null) { + $schema['temporal_mode'] = $this->temporalMode; + } + + if ($this->hourCycle !== null && $this->temporalMode === 'time') { + $schema['hour_cycle'] = $this->hourCycle; + } + if ($this->options !== []) { $schema['options'] = $this->options; } + if ($this->addressFields !== []) { + $schema['address_fields'] = $this->addressFields; + } + if ($this->type === FieldType::FILE) { if ($this->accept !== []) { $schema['accept'] = $this->accept; @@ -533,6 +672,16 @@ private static function normalizeRuleList(mixed $rules): array return array_values(array_unique($normalized)); } + private static function sanitizeRichText(mixed $value): string + { + return RichTextSanitizer::sanitize(is_string($value) ? $value : (string) $value); + } + + private static function nullIfEmpty(string $value): ?string + { + return trim($value) === '' ? null : $value; + } + private function mergeArrays(array $left, array $right): array { foreach ($right as $key => $value) { @@ -589,6 +738,54 @@ private function normalizeOptions(array $options): array return $normalized; } + private static function normalizeTemporalMode(string $mode): string + { + return in_array($mode, ['date', 'time'], true) + ? $mode + : 'date'; + } + + private function normalizeAddressFields(array $addressFields): array + { + $normalized = []; + + foreach ($addressFields as $addressField) { + if (! is_array($addressField)) { + continue; + } + + $key = trim((string) ($addressField['key'] ?? '')); + $hasLabel = array_key_exists('label', $addressField) + && (is_string($addressField['label']) || $addressField['label'] === null); + $label = $hasLabel ? trim((string) $addressField['label']) : ''; + + if ($key === '') { + continue; + } + + $normalized[] = [ + 'key' => $key, + 'label' => $label, + 'visible' => (bool) ($addressField['visible'] ?? true), + 'required' => (bool) ($addressField['required'] ?? false), + ]; + } + + return $normalized; + } + + private function defaultAddressFields(): array + { + return [ + ['key' => 'line1', 'label' => trans('formforge::messages.address_line1'), 'visible' => true, 'required' => true], + ['key' => 'line2', 'label' => trans('formforge::messages.address_line2'), 'visible' => false, 'required' => false], + ['key' => 'city', 'label' => trans('formforge::messages.address_city'), 'visible' => true, 'required' => true], + ['key' => 'state', 'label' => trans('formforge::messages.address_state'), 'visible' => false, 'required' => false], + ['key' => 'zip', 'label' => trans('formforge::messages.address_zip'), 'visible' => true, 'required' => true], + ['key' => 'country', 'label' => trans('formforge::messages.address_country'), 'visible' => true, 'required' => true], + ]; + } + private function resolveRules(): array { if ($this->replaceRules !== []) { @@ -610,54 +807,53 @@ private function autoRules(): array { $rules = []; - if ($this->required) { + if ($this->required && $this->type !== FieldType::CONSENT && $this->type !== FieldType::ADDRESS) { $rules[] = 'required'; } switch ($this->type) { case FieldType::TEXT: - case FieldType::TEXTAREA: - $rules[] = 'string'; - break; - - case FieldType::EMAIL: $rules[] = 'string'; - $rules[] = 'email'; break; case FieldType::NUMBER: $rules[] = 'numeric'; break; - case FieldType::SELECT: - case FieldType::SELECT_MENU: case FieldType::RADIO: break; - case FieldType::CHECKBOX: - case FieldType::SWITCH: + case FieldType::CONSENT: $rules[] = 'boolean'; + if ($this->required) { + $rules[] = 'accepted'; + } break; case FieldType::CHECKBOX_GROUP: $rules[] = 'array'; break; - case FieldType::DATE: - $rules[] = 'date_format:Y-m-d'; + case FieldType::ADDRESS: + $rules[] = 'array'; break; - case FieldType::TIME: - $rules[] = 'date_format:H:i:s'; + case FieldType::TEMPORAL: + if ($this->temporalMode === 'date') { + $rules[] = 'date_format:Y-m-d'; + } elseif ($this->temporalMode === 'time') { + $rules[] = 'date_format:H:i:s'; + } else { + $rules[] = 'date_format:Y-m-d'; + } break; - case FieldType::DATETIME: - $rules[] = 'date'; + case FieldType::DATE: + $rules[] = 'date_format:Y-m-d'; break; - case FieldType::DATE_RANGE: - case FieldType::DATETIME_RANGE: - $rules[] = 'array'; + case FieldType::TIME: + $rules[] = 'date_format:H:i:s'; break; case FieldType::FILE: @@ -666,7 +862,9 @@ private function autoRules(): array } if ($this->min !== null) { - if ($this->type === FieldType::DATE || $this->type === FieldType::DATETIME) { + if ($this->type === FieldType::TEMPORAL && $this->temporalMode === 'date') { + $rules[] = 'after_or_equal:' . $this->min; + } elseif ($this->type === FieldType::DATE) { $rules[] = 'after_or_equal:' . $this->min; } else { $rules[] = 'min:' . $this->min; @@ -674,7 +872,9 @@ private function autoRules(): array } if ($this->max !== null) { - if ($this->type === FieldType::DATE || $this->type === FieldType::DATETIME) { + if ($this->type === FieldType::TEMPORAL && $this->temporalMode === 'date') { + $rules[] = 'before_or_equal:' . $this->max; + } elseif ($this->type === FieldType::DATE) { $rules[] = 'before_or_equal:' . $this->max; } else { $rules[] = 'max:' . $this->max; diff --git a/src/Definition/FieldType.php b/src/Definition/FieldType.php index 961703d..48b570b 100644 --- a/src/Definition/FieldType.php +++ b/src/Definition/FieldType.php @@ -9,69 +9,75 @@ final class FieldType { public const TEXT = 'text'; - public const TEXTAREA = 'textarea'; - public const EMAIL = 'email'; public const NUMBER = 'number'; - public const SELECT = 'select'; - public const SELECT_MENU = 'select_menu'; public const RADIO = 'radio'; - public const CHECKBOX = 'checkbox'; + public const CONSENT = 'consent'; public const CHECKBOX_GROUP = 'checkbox_group'; - public const SWITCH = 'switch'; + public const TEMPORAL = 'temporal'; public const DATE = 'date'; public const TIME = 'time'; - public const DATETIME = 'datetime'; - public const DATE_RANGE = 'date_range'; - public const DATETIME_RANGE = 'datetime_range'; public const FILE = 'file'; + public const ADDRESS = 'address'; public static function all(): array { return [ self::TEXT, - self::TEXTAREA, - self::EMAIL, self::NUMBER, - self::SELECT, - self::SELECT_MENU, self::RADIO, - self::CHECKBOX, + self::CONSENT, self::CHECKBOX_GROUP, - self::SWITCH, - self::DATE, - self::TIME, - self::DATETIME, - self::DATE_RANGE, - self::DATETIME_RANGE, + self::TEMPORAL, self::FILE, + self::ADDRESS, ]; } public static function assert(string $type): void { - if (! in_array($type, self::all(), true)) { + $normalized = self::normalize($type); + + if (! in_array($normalized, self::all(), true)) { throw new InvalidFieldDefinitionException("Unsupported field type [{$type}]."); } } + public static function normalize(string $type): string + { + return match ($type) { + self::DATE, + self::TIME => self::TEMPORAL, + default => $type, + }; + } + + public static function temporalMode(string $type): ?string + { + return match ($type) { + self::DATE => 'date', + self::TIME => 'time', + default => null, + }; + } + public static function isOptionBased(string $type): bool { - return in_array($type, [self::SELECT, self::SELECT_MENU, self::RADIO, self::CHECKBOX_GROUP], true); + return in_array($type, [self::RADIO, self::CHECKBOX_GROUP], true); } public static function isBoolean(string $type): bool { - return in_array($type, [self::CHECKBOX, self::SWITCH], true); + return $type === self::CONSENT; } public static function isRange(string $type): bool { - return in_array($type, [self::DATE_RANGE, self::DATETIME_RANGE], true); + return false; } public static function isDateLike(string $type): bool { - return in_array($type, [self::DATE, self::DATETIME], true); + return in_array($type, [self::DATE, self::TIME], true); } public static function isFile(string $type): bool diff --git a/src/Definition/FormBlueprint.php b/src/Definition/FormBlueprint.php index 3f929cf..774169e 100644 --- a/src/Definition/FormBlueprint.php +++ b/src/Definition/FormBlueprint.php @@ -16,6 +16,8 @@ class FormBlueprint private ?string $version = null; + private int $schemaVersion = FormSchemaLayout::LATEST_SCHEMA_VERSION; + private array $fields = []; private array $api = []; @@ -48,6 +50,12 @@ public static function fromSchemaArray(array $schema): self $form->version((string) $schema['version']); } + if (array_key_exists('schema_version', $schema)) { + $form->schemaVersion((int) $schema['schema_version']); + } else { + $form->schemaVersion(1); + } + if (isset($schema['category'])) { $form->category((string) $schema['category']); } @@ -114,6 +122,17 @@ public function version(string $version): self return $this; } + public function schemaVersion(int $schemaVersion): self + { + if ($schemaVersion <= 0) { + throw new InvalidFieldDefinitionException('Form schema version must be positive.'); + } + + $this->schemaVersion = $schemaVersion; + + return $this; + } + public function category(string $category): self { $category = trim($category); @@ -146,79 +165,44 @@ public function text(string $name): FieldBlueprint return $this->addField(FieldType::TEXT, $name); } - public function textarea(string $name): FieldBlueprint - { - return $this->addField(FieldType::TEXTAREA, $name); - } - - public function email(string $name): FieldBlueprint - { - return $this->addField(FieldType::EMAIL, $name); - } - public function number(string $name): FieldBlueprint { return $this->addField(FieldType::NUMBER, $name); } - public function select(string $name): FieldBlueprint - { - return $this->addField(FieldType::SELECT, $name); - } - - public function selectMenu(string $name): FieldBlueprint - { - return $this->addField(FieldType::SELECT_MENU, $name); - } - public function radio(string $name): FieldBlueprint { return $this->addField(FieldType::RADIO, $name); } - public function checkbox(string $name): FieldBlueprint - { - return $this->addField(FieldType::CHECKBOX, $name); - } - public function checkboxGroup(string $name): FieldBlueprint { return $this->addField(FieldType::CHECKBOX_GROUP, $name); } - public function switch(string $name): FieldBlueprint + public function temporal(string $name, string $mode = 'date'): FieldBlueprint { - return $this->addField(FieldType::SWITCH, $name); + return $this->addField(FieldType::TEMPORAL, $name)->temporalModeValue($mode); } public function date(string $name): FieldBlueprint { - return $this->addField(FieldType::DATE, $name); + return $this->temporal($name, 'date'); } public function time(string $name): FieldBlueprint { - return $this->addField(FieldType::TIME, $name); + return $this->temporal($name, 'time'); } - public function datetime(string $name): FieldBlueprint - { - return $this->addField(FieldType::DATETIME, $name); - } - - public function dateRange(string $name): FieldBlueprint - { - return $this->addField(FieldType::DATE_RANGE, $name); - } - - public function datetimeRange(string $name): FieldBlueprint + public function file(string $name): FieldBlueprint { - return $this->addField(FieldType::DATETIME_RANGE, $name); + return $this->addField(FieldType::FILE, $name); } - public function file(string $name): FieldBlueprint + public function address(string $name): FieldBlueprint { - return $this->addField(FieldType::FILE, $name); + return $this->addField(FieldType::ADDRESS, $name); } public function api(callable|array $config): self @@ -270,6 +254,11 @@ public function versionValue(): ?string return $this->version; } + public function schemaVersionValue(): int + { + return $this->schemaVersion; + } + public function titleValue(): string { return $this->title ?? $this->key; @@ -344,6 +333,7 @@ public function toSchemaArray(): array $schema = [ 'key' => $this->key, 'version' => $this->version, + 'schema_version' => $this->schemaVersion, 'title' => $this->titleValue(), 'fields' => $fields, ]; diff --git a/src/Exceptions/FormNotFoundException.php b/src/Exceptions/FormNotFoundException.php index bbb780f..8dd9f20 100644 --- a/src/Exceptions/FormNotFoundException.php +++ b/src/Exceptions/FormNotFoundException.php @@ -14,4 +14,9 @@ public static function forKey(string $key, ?string $version = null): self return new self(trans('formforge::validation.form_version_not_found', ['key' => $key, 'version' => $version])); } + + public static function forUuid(string $formUuid): self + { + return new self(trans('formforge::validation.form_not_found', ['key' => $formUuid])); + } } diff --git a/src/FormForgeServiceProvider.php b/src/FormForgeServiceProvider.php index 7bae6cd..1b7e628 100644 --- a/src/FormForgeServiceProvider.php +++ b/src/FormForgeServiceProvider.php @@ -46,6 +46,8 @@ use EvanSchleret\FormForge\Submissions\DraftStateService; use EvanSchleret\FormForge\Submissions\StagedUploadService; use EvanSchleret\FormForge\Submissions\UploadManager; +use EvanSchleret\FormForge\Support\DefaultFormPublicLinkResolver; +use EvanSchleret\FormForge\Support\FormPublicLinkResolver; use Illuminate\Support\ServiceProvider; class FormForgeServiceProvider extends ServiceProvider @@ -95,6 +97,19 @@ public function register(): void ), ); $this->app->singleton(HttpOptionsResolver::class, static fn (): HttpOptionsResolver => new HttpOptionsResolver()); + $this->app->singleton(FormPublicLinkResolver::class, function (): FormPublicLinkResolver { + $configured = config('formforge.http.public_link.resolver', DefaultFormPublicLinkResolver::class); + + if (! is_string($configured) || trim($configured) === '') { + $configured = DefaultFormPublicLinkResolver::class; + } + + if (! class_exists($configured) || ! is_subclass_of($configured, FormPublicLinkResolver::class)) { + $configured = DefaultFormPublicLinkResolver::class; + } + + return $this->app->make($configured); + }); $this->app->singleton(ScopedRouteManager::class, static fn (): ScopedRouteManager => new ScopedRouteManager()); $this->app->singleton(ScopedRouteAuthorizer::class, static fn (): ScopedRouteAuthorizer => new ScopedRouteAuthorizer()); $this->app->singleton(SubmissionHttpResource::class, static fn (): SubmissionHttpResource => new SubmissionHttpResource()); diff --git a/src/FormInstance.php b/src/FormInstance.php index 195af91..bcded81 100644 --- a/src/FormInstance.php +++ b/src/FormInstance.php @@ -5,9 +5,11 @@ namespace EvanSchleret\FormForge; use EvanSchleret\FormForge\Models\FormSubmission; +use EvanSchleret\FormForge\Support\FormPublicLinkResolver; use EvanSchleret\FormForge\Submissions\SubmissionService; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; class FormInstance { @@ -27,6 +29,11 @@ public function version(): string return (string) ($this->schema['version'] ?? ''); } + public function schemaVersion(): int + { + return (int) ($this->schema['schema_version'] ?? 1); + } + public function title(): string { return (string) ($this->schema['title'] ?? ''); @@ -78,9 +85,98 @@ public function isPublished(): bool return (bool) config('formforge.forms.default_published', true); } + public function publishAt(): ?Carbon + { + return $this->resolveDateTime($this->schema['publish_at'] ?? null); + } + + public function pauseAt(): ?Carbon + { + return $this->resolveDateTime($this->schema['pause_at'] ?? null); + } + + public function responseLimit(): ?int + { + $value = $this->schema['response_limit'] ?? null; + + if (is_int($value) && $value > 0) { + return $value; + } + + if (is_string($value)) { + $value = trim($value); + + if ($value !== '' && ctype_digit($value)) { + $limit = (int) $value; + + return $limit > 0 ? $limit : null; + } + } + + return null; + } + + public function submissionCodeRequired(): bool + { + return (bool) ($this->schema['submission_code_required'] ?? false); + } + + public function publicUrl(): ?string + { + $request = request(); + + if (! $request instanceof Request) { + return null; + } + + $configured = config('formforge.http.public_link.resolver'); + + if (! is_string($configured) || trim($configured) === '') { + return null; + } + + if (! class_exists($configured) || ! is_subclass_of($configured, FormPublicLinkResolver::class)) { + return null; + } + + $resolver = app($configured); + + if (! $resolver instanceof FormPublicLinkResolver) { + return null; + } + + return $resolver->resolve([ + 'key' => $this->key(), + 'version' => $this->version(), + 'schema' => $this->schema, + ], $request); + } + + private function resolveDateTime(mixed $value): ?Carbon + { + if (! is_string($value)) { + return null; + } + + $value = trim($value); + + if ($value === '') { + return null; + } + + try { + return Carbon::parse($value); + } catch (\Throwable) { + return null; + } + } + public function toArray(): array { - return $this->schema; + $schema = $this->schema; + $schema['public_url'] = $this->publicUrl(); + + return $schema; } public function toJson(int $options = 0): string @@ -113,6 +209,31 @@ public function validateField(string $field, mixed $value, ?string $locale = nul return $this->submissionService->validateFieldWithLocale($this->schema, $field, $value, $locale); } + public function exportableFields(): array + { + return $this->submissionService->exportableFields($this->schema); + } + + public function flattenExportableFields(): array + { + return $this->submissionService->flattenExportableFields($this->schema); + } + + public function resolveExportableField(string $identifier): ?array + { + return $this->submissionService->resolveExportableField($this->schema, $identifier); + } + + public function validateExportableHeaders(array $headers): array + { + return $this->submissionService->validateExportableHeaders($this->schema, $headers); + } + + public function mapExportableRow(array $row, bool $strict = true): array + { + return $this->submissionService->mapExportableRow($this->schema, $row, $strict); + } + public function describeFields(): array { return $this->submissionService->describeFields($this->schema); diff --git a/src/FormManager.php b/src/FormManager.php index f12d321..968ec2d 100644 --- a/src/FormManager.php +++ b/src/FormManager.php @@ -182,6 +182,17 @@ public function exportSubmissionsToPath( return $this->submissionExports->exportToPath($path, $formKey, $format, $filters, null, $withHeader); } + public function latestByUuid(string $formUuid): FormInstance + { + $persisted = $this->repository->latestByUuid($formUuid); + + if ($persisted !== null) { + return $this->newInstance((array) $persisted->schema); + } + + throw FormNotFoundException::forUuid($formUuid); + } + public function validateField(string $formKey, string $field, mixed $value, ?string $version = null, ?string $locale = null): array { return $this->get($formKey, $version)->validateField($field, $value, $locale); @@ -192,6 +203,31 @@ public function describeFields(string $formKey, ?string $version = null): array return $this->get($formKey, $version)->describeFields(); } + public function exportableFields(string $formKey, ?string $version = null): array + { + return $this->get($formKey, $version)->exportableFields(); + } + + public function flattenExportableFields(string $formKey, ?string $version = null): array + { + return $this->get($formKey, $version)->flattenExportableFields(); + } + + public function resolveExportableField(string $formKey, string $identifier, ?string $version = null): ?array + { + return $this->get($formKey, $version)->resolveExportableField($identifier); + } + + public function validateExportableHeaders(string $formKey, array $headers, ?string $version = null): array + { + return $this->get($formKey, $version)->validateExportableHeaders($headers); + } + + public function mapExportableRow(string $formKey, array $row, ?string $version = null, bool $strict = true): array + { + return $this->get($formKey, $version)->mapExportableRow($row, $strict); + } + public function resolveField(string $formKey, string $identifier, ?string $version = null): ?array { return $this->get($formKey, $version)->resolveField($identifier); diff --git a/src/Http/Controllers/FormSubmissionController.php b/src/Http/Controllers/FormSubmissionController.php index 037a97b..229b73c 100644 --- a/src/Http/Controllers/FormSubmissionController.php +++ b/src/Http/Controllers/FormSubmissionController.php @@ -8,15 +8,26 @@ use EvanSchleret\FormForge\Exceptions\FormNotFoundException; use EvanSchleret\FormForge\FormInstance; use EvanSchleret\FormForge\FormManager; +use EvanSchleret\FormForge\Ownership\OwnershipReference; +use EvanSchleret\FormForge\Persistence\FormDefinitionRepository; use EvanSchleret\FormForge\Http\Resources\SubmissionHttpResource; +use EvanSchleret\FormForge\Submissions\SubmissionReadService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Hash; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class FormSubmissionController { - public function submitLatest(Request $request, FormManager $forms, SubmissionHttpResource $resources): JsonResponse + public function submitLatest( + Request $request, + FormManager $forms, + SubmissionHttpResource $resources, + FormDefinitionRepository $definitions, + SubmissionReadService $submissions, + ): JsonResponse { $key = $this->routeRequired($request, 'key'); $version = $request->input('version'); @@ -25,21 +36,29 @@ public function submitLatest(Request $request, FormManager $forms, SubmissionHtt $version = null; } - return $this->submit($request, $forms, $resources, $key, $version); + return $this->submit($request, $forms, $resources, $definitions, $submissions, $key, $version); } - public function submitVersion(Request $request, FormManager $forms, SubmissionHttpResource $resources): JsonResponse + public function submitVersion( + Request $request, + FormManager $forms, + SubmissionHttpResource $resources, + FormDefinitionRepository $definitions, + SubmissionReadService $submissions, + ): JsonResponse { $key = $this->routeRequired($request, 'key'); $version = $this->routeRequired($request, 'version'); - return $this->submit($request, $forms, $resources, $key, $version); + return $this->submit($request, $forms, $resources, $definitions, $submissions, $key, $version); } private function submit( Request $request, FormManager $forms, SubmissionHttpResource $resources, + FormDefinitionRepository $definitions, + SubmissionReadService $submissions, string $key, ?string $version, ): JsonResponse @@ -51,15 +70,16 @@ private function submit( $form = $forms->get($key, $version); } + $submissionCode = $this->resolveSubmissionCode($request); $isTest = $this->resolveTestMode($request); - $this->assertSubmissionAllowed($form, $isTest); + $this->assertSubmissionAllowed($form, $definitions, $submissions, $request, $submissionCode, $isTest); $submission = $form->submit( payload: $this->extractPayload($request), submittedBy: $request->user(), request: $request, isTest: $isTest, - submissionMeta: $this->extractSubmissionMeta($request, $isTest), + submissionMeta: $this->extractSubmissionMeta($request, $isTest, $submissionCode), ); } catch (FormNotFoundException $exception) { throw new NotFoundHttpException($exception->getMessage(), $exception); @@ -89,6 +109,8 @@ private function extractPayload(Request $request): array } } + unset($merged['submission_code']); + $flag = trim((string) config('formforge.submissions.testing.flag', '_formforge_test')); if ($flag !== '') { @@ -100,6 +122,7 @@ private function extractPayload(Request $request): array $all = $request->all(); unset($all['version'], $all['payload'], $all['meta']); + unset($all['submission_code']); $flag = trim((string) config('formforge.submissions.testing.flag', '_formforge_test')); @@ -153,8 +176,60 @@ private function resolveTestMode(Request $request): bool return $resolved && $enabled; } - private function assertSubmissionAllowed(FormInstance $form, bool $isTest): void + private function assertSubmissionAllowed( + FormInstance $form, + FormDefinitionRepository $definitions, + SubmissionReadService $submissions, + Request $request, + ?string $submissionCode, + bool $isTest, + ): void { + $owner = $request->attributes->get('formforge.ownership.reference'); + $ownerReference = $owner instanceof OwnershipReference ? $owner : null; + $definition = $definitions->find($form->key(), $form->version(), null, null, true, $ownerReference); + + $now = Carbon::now(); + + $publishAt = $form->publishAt(); + if ($publishAt instanceof Carbon && $now->lt($publishAt)) { + throw ValidationException::withMessages([ + 'form' => [trans('formforge::messages.form_not_available_yet')], + ]); + } + + $pauseAt = $form->pauseAt(); + if ($pauseAt instanceof Carbon && $now->greaterThanOrEqualTo($pauseAt)) { + throw ValidationException::withMessages([ + 'form' => [trans('formforge::messages.form_paused')], + ]); + } + + $responseLimit = $form->responseLimit(); + if ($responseLimit !== null) { + $currentResponses = $submissions->queryForForm($form->key(), ['is_test' => false], $ownerReference)->count(); + + if ($currentResponses >= $responseLimit) { + throw ValidationException::withMessages([ + 'form' => [trans('formforge::messages.form_response_limit_reached')], + ]); + } + } + + if ($form->submissionCodeRequired()) { + $codeHash = null; + + if ($definition !== null && is_array($definition->meta ?? null)) { + $codeHash = $definition->meta['submission_code_hash'] ?? null; + } + + if (! is_string($codeHash) || trim($codeHash) === '' || $submissionCode === null || ! Hash::check($submissionCode, $codeHash)) { + throw ValidationException::withMessages([ + 'form' => [trans('formforge::messages.form_submission_code_invalid')], + ]); + } + } + $requiresPublished = (bool) config('formforge.http.submission.require_published', true); if (! $requiresPublished || $form->isPublished()) { @@ -172,7 +247,7 @@ private function assertSubmissionAllowed(FormInstance $form, bool $isTest): void ]); } - private function extractSubmissionMeta(Request $request, bool $isTest): array + private function extractSubmissionMeta(Request $request, bool $isTest, ?string $submissionCode = null): array { $meta = $request->input('meta'); @@ -180,12 +255,39 @@ private function extractSubmissionMeta(Request $request, bool $isTest): array $meta = []; } + unset($meta['submission_code']); + + if ($submissionCode !== null && $submissionCode !== '') { + $meta['submission_code_present'] = true; + } + $meta['mode'] = $isTest ? 'test' : 'live'; $meta['channel'] = 'http'; return $meta; } + private function resolveSubmissionCode(Request $request): ?string + { + $meta = $request->input('meta'); + + if (is_array($meta) && array_key_exists('submission_code', $meta)) { + $value = trim((string) $meta['submission_code']); + + if ($value !== '') { + return $value; + } + } + + if (! $request->has('submission_code')) { + return null; + } + + $value = trim((string) $request->input('submission_code')); + + return $value === '' ? null : $value; + } + private function toBoolean(mixed $value): ?bool { if (is_bool($value)) { diff --git a/src/Http/Resources/FormDefinitionHttpResource.php b/src/Http/Resources/FormDefinitionHttpResource.php index c925d31..71fd564 100644 --- a/src/Http/Resources/FormDefinitionHttpResource.php +++ b/src/Http/Resources/FormDefinitionHttpResource.php @@ -6,6 +6,7 @@ use EvanSchleret\FormForge\Models\FormCategory; use EvanSchleret\FormForge\Models\FormDefinition; +use EvanSchleret\FormForge\Support\FormPublicLinkResolver; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -57,6 +58,7 @@ public function toArray(Request $request): array 'schema_hash' => (string) $definition->schema_hash, 'schema' => $schema, 'meta' => $meta, + 'public_url' => $this->resolvePublicUrl($definition, $request), 'created_by_type' => $definition->created_by_type, 'created_by_id' => $definition->created_by_id, 'updated_by_type' => $definition->updated_by_type, @@ -79,4 +81,31 @@ private function normalizedOptionalString(mixed $value): ?string return $normalized === '' ? null : $normalized; } + + private function resolvePublicUrl(FormDefinition $definition, Request $request): ?string + { + $configured = config('formforge.http.public_link.resolver'); + + if (! is_string($configured) || trim($configured) === '') { + return null; + } + + if (! class_exists($configured) || ! is_subclass_of($configured, FormPublicLinkResolver::class)) { + return null; + } + + $resolver = app($configured); + + if (! $resolver instanceof FormPublicLinkResolver) { + return null; + } + + return $resolver->resolve([ + 'key' => (string) $definition->key, + 'owner_type' => $definition->owner_type, + 'owner_id' => $definition->owner_id, + 'schema' => $definition->schema, + 'meta' => $definition->meta, + ], $request); + } } diff --git a/src/Management/FormMutationService.php b/src/Management/FormMutationService.php index c78e8b4..c9c096f 100644 --- a/src/Management/FormMutationService.php +++ b/src/Management/FormMutationService.php @@ -20,6 +20,7 @@ use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; class FormMutationService @@ -43,6 +44,10 @@ public function create(array $input, ?Model $actor = null, ?OwnershipReference $ : null; $api = $this->normalizedArray($input['api'] ?? []); $meta = $this->normalizedArray($input['meta'] ?? []); + $settingsLocked = $this->resolveSettingsLocked($input); + [$meta, $submissionCodeRequired] = $this->resolveSubmissionCodeMeta($input, $meta, null, $settingsLocked); + [$publishAt, $pauseAt, $responseLimit] = $this->resolveLifecycleSettings($input, null, $settingsLocked); + $meta = $this->sanitizeSettingsMeta($meta, $settingsLocked); $key = $this->buildGeneratedKey(); $versionNumber = 1; $formUuid = $key; @@ -57,6 +62,11 @@ public function create(array $input, ?Model $actor = null, ?OwnershipReference $ drafts: $drafts, category: $categoryModel?->key, api: $api, + publishAt: $publishAt, + pauseAt: $pauseAt, + responseLimit: $responseLimit, + submissionCodeRequired: $submissionCodeRequired, + settingsLocked: $settingsLocked, published: false, ); @@ -113,6 +123,10 @@ public function patch(string $key, array $input, ?Model $actor = null, ?Ownershi $meta = array_key_exists('meta', $input) ? $this->normalizedArray($input['meta']) : $this->normalizedArray($latest->meta ?? []); + $settingsLocked = $this->resolveSettingsLocked($input, $currentSchema); + [$meta, $submissionCodeRequired] = $this->resolveSubmissionCodeMeta($input, $meta, $currentSchema, $settingsLocked); + [$publishAt, $pauseAt, $responseLimit] = $this->resolveLifecycleSettings($input, $currentSchema, $settingsLocked); + $meta = $this->sanitizeSettingsMeta($meta, $settingsLocked); $schema = $this->normalizeSchema( key: $key, @@ -124,6 +138,11 @@ public function patch(string $key, array $input, ?Model $actor = null, ?Ownershi drafts: $drafts, category: $categoryModel?->key, api: $api, + publishAt: $publishAt, + pauseAt: $pauseAt, + responseLimit: $responseLimit, + submissionCodeRequired: $submissionCodeRequired, + settingsLocked: $settingsLocked, published: false, ); @@ -154,6 +173,7 @@ public function publish(string $key, ?Model $actor = null, ?OwnershipReference $ $title = trim((string) ($currentSchema['title'] ?? '')); $pages = $this->normalizedArray($currentSchema['pages'] ?? []); $fields = $currentSchema['fields'] ?? []; + $settingsLocked = $this->resolveSettingsLocked([], $currentSchema); if (! $this->isPublishable($title, $pages, $fields)) { throw new FormForgeException('Form cannot be published: title, at least one page, and one field are required.'); @@ -173,10 +193,17 @@ public function publish(string $key, ?Model $actor = null, ?OwnershipReference $ drafts: $this->normalizedArray($currentSchema['drafts'] ?? []), category: $categoryModel?->key, api: $this->normalizedArray($currentSchema['api'] ?? []), + publishAt: $currentSchema['publish_at'] ?? null, + pauseAt: $currentSchema['pause_at'] ?? null, + responseLimit: $currentSchema['response_limit'] ?? null, + submissionCodeRequired: (bool) ($currentSchema['submission_code_required'] ?? false), + settingsLocked: $settingsLocked, published: true, ); - return DB::transaction(function () use ($latest, $schema, $nextVersion, $actor, $categoryModel, $effectiveOwner): FormDefinition { + $meta = $this->sanitizeSettingsMeta($this->normalizedArray($latest->meta ?? []), $settingsLocked); + + return DB::transaction(function () use ($latest, $schema, $nextVersion, $actor, $categoryModel, $effectiveOwner, $meta): FormDefinition { return $this->createRevision( key: (string) $latest->key, schema: $schema, @@ -184,7 +211,7 @@ public function publish(string $key, ?Model $actor = null, ?OwnershipReference $ versionNumber: $nextVersion, published: true, actor: $actor, - meta: $this->normalizedArray($latest->meta ?? []), + meta: $meta, category: $categoryModel, owner: $effectiveOwner, ); @@ -202,6 +229,7 @@ public function unpublish(string $key, ?Model $actor = null, ?OwnershipReference $currentSchema = is_array($latest->schema) ? $latest->schema : []; $effectiveOwner = $owner ?? $this->ownership->fromModel($latest); $nextVersion = $this->repository->nextVersionNumber($key, true, $effectiveOwner); + $settingsLocked = $this->resolveSettingsLocked([], $currentSchema); $categoryModel = $this->resolveCurrentCategoryModel($latest, $currentSchema, $effectiveOwner); $schema = $this->normalizeSchema( @@ -214,10 +242,17 @@ public function unpublish(string $key, ?Model $actor = null, ?OwnershipReference drafts: $this->normalizedArray($currentSchema['drafts'] ?? []), category: $categoryModel?->key, api: $this->normalizedArray($currentSchema['api'] ?? []), + publishAt: $currentSchema['publish_at'] ?? null, + pauseAt: $currentSchema['pause_at'] ?? null, + responseLimit: $currentSchema['response_limit'] ?? null, + submissionCodeRequired: (bool) ($currentSchema['submission_code_required'] ?? false), + settingsLocked: $settingsLocked, published: false, ); - return DB::transaction(function () use ($latest, $schema, $nextVersion, $actor, $categoryModel, $effectiveOwner): FormDefinition { + $meta = $this->sanitizeSettingsMeta($this->normalizedArray($latest->meta ?? []), $settingsLocked); + + return DB::transaction(function () use ($latest, $schema, $nextVersion, $actor, $categoryModel, $effectiveOwner, $meta): FormDefinition { return $this->createRevision( key: (string) $latest->key, schema: $schema, @@ -225,7 +260,7 @@ public function unpublish(string $key, ?Model $actor = null, ?OwnershipReference versionNumber: $nextVersion, published: false, actor: $actor, - meta: $this->normalizedArray($latest->meta ?? []), + meta: $meta, category: $categoryModel, owner: $effectiveOwner, ); @@ -477,6 +512,11 @@ private function normalizeSchema( mixed $drafts, ?string $category, array $api, + mixed $publishAt, + mixed $pauseAt, + mixed $responseLimit, + bool $submissionCodeRequired, + bool $settingsLocked, bool $published, ): array { if ($title === '') { @@ -518,6 +558,7 @@ private function normalizeSchema( $schema = [ 'key' => $key, 'version' => (string) $versionNumber, + 'schema_version' => FormSchemaLayout::LATEST_SCHEMA_VERSION, 'title' => $title, 'fields' => $normalizedFields, 'is_published' => $published, @@ -542,6 +583,18 @@ private function normalizeSchema( $normalized = FormSchemaLayout::normalize($normalized); $normalized['is_published'] = $published; + if ($settingsLocked) { + $normalized['publish_at'] = null; + $normalized['pause_at'] = null; + $normalized['response_limit'] = null; + $normalized['submission_code_required'] = false; + } else { + $normalized['publish_at'] = $this->normalizedDateTime($publishAt); + $normalized['pause_at'] = $this->normalizedDateTime($pauseAt); + $normalized['response_limit'] = $this->normalizedResponseLimit($responseLimit); + $normalized['submission_code_required'] = $submissionCodeRequired; + } + return $normalized; } @@ -673,6 +726,149 @@ private function normalizedArray(mixed $value): array return is_array($value) ? $value : []; } + private function normalizedDateTime(mixed $value): ?string + { + if ($value === null) { + return null; + } + + if (! is_string($value)) { + $value = (string) $value; + } + + $value = trim($value); + + if ($value === '') { + return null; + } + + try { + return Carbon::parse($value)->toIso8601String(); + } catch (\Throwable) { + throw new FormForgeException('Invalid form date value provided.'); + } + } + + private function normalizedResponseLimit(mixed $value): ?int + { + if ($value === null) { + return null; + } + + if (is_string($value)) { + $value = trim($value); + + if ($value === '') { + return null; + } + } + + if (! is_numeric($value)) { + throw new FormForgeException('Response limit must be numeric.'); + } + + $limit = (int) $value; + + if ($limit <= 0) { + throw new FormForgeException('Response limit must be greater than zero.'); + } + + return $limit; + } + + private function resolveLifecycleSettings(array $input, ?array $currentSchema = null, bool $settingsLocked = false): array + { + if ($settingsLocked) { + return [null, null, null]; + } + + $publishAt = array_key_exists('publish_at', $input) + ? $input['publish_at'] + : ($currentSchema['publish_at'] ?? null); + + $pauseAt = array_key_exists('pause_at', $input) + ? $input['pause_at'] + : ($currentSchema['pause_at'] ?? null); + + $responseLimit = array_key_exists('response_limit', $input) + ? $input['response_limit'] + : ($currentSchema['response_limit'] ?? null); + + return [$publishAt, $pauseAt, $responseLimit]; + } + + /** + * @return array{0: array, 1: bool} + */ + private function resolveSubmissionCodeMeta(array $input, array $meta, ?array $currentSchema = null, bool $settingsLocked = false): array + { + if ($settingsLocked) { + unset($meta['submission_code_hash']); + + return [$meta, false]; + } + + $requiredOverride = array_key_exists('submission_code_required', $input) + ? (bool) $input['submission_code_required'] + : null; + + if (array_key_exists('submission_code', $input)) { + $submissionCode = trim((string) $input['submission_code']); + + if ($submissionCode === '') { + unset($meta['submission_code_hash']); + + return [$meta, false]; + } + + $meta['submission_code_hash'] = Hash::make($submissionCode); + + return [$meta, true]; + } + + if ($requiredOverride === false) { + unset($meta['submission_code_hash']); + + return [$meta, false]; + } + + $required = (bool) ($currentSchema['submission_code_required'] ?? false); + + if (! $required && is_string($meta['submission_code_hash'] ?? null) && trim((string) $meta['submission_code_hash']) !== '') { + $required = true; + } + + return [$meta, $required]; + } + + private function sanitizeSettingsMeta(array $meta, bool $settingsLocked): array + { + if ($settingsLocked) { + unset($meta['submission_code_hash']); + } + + return $meta; + } + + private function resolveSettingsLocked(array $input, ?array $currentSchema = null): bool + { + $inputApi = array_key_exists('api', $input) + ? $this->normalizedArray($input['api']) + : []; + $currentApi = $currentSchema !== null + ? $this->normalizedArray($currentSchema['api'] ?? []) + : []; + + return $this->settingsLockedFromApi($inputApi) || $this->settingsLockedFromApi($currentApi); + } + + private function settingsLockedFromApi(array $api): bool + { + $formforge = $this->normalizedArray($api['formforge'] ?? []); + + return ($formforge['settings_locked'] ?? null) === true; + } + private function normalizedOptionalString(mixed $value): ?string { if (! is_string($value)) { diff --git a/src/Persistence/FormDefinitionRepository.php b/src/Persistence/FormDefinitionRepository.php index b30a528..437db8a 100644 --- a/src/Persistence/FormDefinitionRepository.php +++ b/src/Persistence/FormDefinitionRepository.php @@ -126,6 +126,23 @@ public function latestActive(string $key, bool $includeDeleted = false, ?Ownersh ->first(); } + public function latestByUuid(string $formUuid, bool $includeDeleted = false, ?OwnershipReference $owner = null): ?FormDefinition + { + $query = $this->modelClass()::query() + ->where('form_uuid', $formUuid) + ->where('is_active', true); + $this->ownership()->applyScope($query, $owner); + + if ($includeDeleted) { + $query->withTrashed(); + } + + return $query + ->orderByDesc('version_number') + ->orderByDesc('id') + ->first(); + } + public function all(?string $category = null, ?bool $published = null, bool $includeDeleted = false, ?OwnershipReference $owner = null): Collection { $query = $this->modelClass()::query() diff --git a/src/ScopedFormManager.php b/src/ScopedFormManager.php index 537da1d..ab33cad 100644 --- a/src/ScopedFormManager.php +++ b/src/ScopedFormManager.php @@ -44,6 +44,17 @@ public function latestActive(string $key, bool $includeDeleted = false): ?FormDe return $this->repository->latestActive($key, $includeDeleted, $this->owner); } + public function latestByUuid(string $formUuid, bool $includeDeleted = false): FormDefinition + { + $definition = $this->repository->latestByUuid($formUuid, $includeDeleted, $this->owner); + + if ($definition instanceof FormDefinition) { + return $definition; + } + + throw \EvanSchleret\FormForge\Exceptions\FormNotFoundException::forUuid($formUuid); + } + public function versions(string $key, bool $includeDeleted = false): array { return $this->repository->versions($key, $includeDeleted, $this->owner); @@ -149,6 +160,81 @@ public function describeFields(string $formKey, ?string $version = null): array return $this->submissionService->describeFields($schema); } + public function exportableFields(string $formKey, ?string $version = null): array + { + $definition = $version === null + ? $this->latestActive($formKey) + : $this->find($formKey, $version); + + if (! $definition instanceof FormDefinition) { + throw \EvanSchleret\FormForge\Exceptions\FormNotFoundException::forKey($formKey, $version); + } + + $schema = is_array($definition->schema) ? $definition->schema : []; + + return $this->submissionService->exportableFields($schema); + } + + public function flattenExportableFields(string $formKey, ?string $version = null): array + { + $definition = $version === null + ? $this->latestActive($formKey) + : $this->find($formKey, $version); + + if (! $definition instanceof FormDefinition) { + throw \EvanSchleret\FormForge\Exceptions\FormNotFoundException::forKey($formKey, $version); + } + + $schema = is_array($definition->schema) ? $definition->schema : []; + + return $this->submissionService->flattenExportableFields($schema); + } + + public function resolveExportableField(string $formKey, string $identifier, ?string $version = null): ?array + { + $definition = $version === null + ? $this->latestActive($formKey) + : $this->find($formKey, $version); + + if (! $definition instanceof FormDefinition) { + throw \EvanSchleret\FormForge\Exceptions\FormNotFoundException::forKey($formKey, $version); + } + + $schema = is_array($definition->schema) ? $definition->schema : []; + + return $this->submissionService->resolveExportableField($schema, $identifier); + } + + public function validateExportableHeaders(string $formKey, array $headers, ?string $version = null): array + { + $definition = $version === null + ? $this->latestActive($formKey) + : $this->find($formKey, $version); + + if (! $definition instanceof FormDefinition) { + throw \EvanSchleret\FormForge\Exceptions\FormNotFoundException::forKey($formKey, $version); + } + + $schema = is_array($definition->schema) ? $definition->schema : []; + + return $this->submissionService->validateExportableHeaders($schema, $headers); + } + + public function mapExportableRow(string $formKey, array $row, ?string $version = null, bool $strict = true): array + { + $definition = $version === null + ? $this->latestActive($formKey) + : $this->find($formKey, $version); + + if (! $definition instanceof FormDefinition) { + throw \EvanSchleret\FormForge\Exceptions\FormNotFoundException::forKey($formKey, $version); + } + + $schema = is_array($definition->schema) ? $definition->schema : []; + + return $this->submissionService->mapExportableRow($schema, $row, $strict); + } + public function resolveField(string $formKey, string $identifier, ?string $version = null): ?array { $definition = $version === null diff --git a/src/Submissions/SubmissionService.php b/src/Submissions/SubmissionService.php index fe208c1..e850bd3 100644 --- a/src/Submissions/SubmissionService.php +++ b/src/Submissions/SubmissionService.php @@ -105,6 +105,31 @@ public function validateFieldWithLocale(array $schema, string $field, mixed $val return $this->validator->validateField($effectiveSchema, $field, $value, $locale); } + public function exportableFields(array $schema): array + { + return $this->validator->exportableFields($schema); + } + + public function flattenExportableFields(array $schema): array + { + return $this->validator->flattenExportableFields($schema); + } + + public function resolveExportableField(array $schema, string $identifier): ?array + { + return $this->validator->resolveExportableField($schema, $identifier); + } + + public function validateExportableHeaders(array $schema, array $headers): array + { + return $this->validator->validateExportableHeaders($schema, $headers); + } + + public function mapExportableRow(array $schema, array $row, bool $strict = true): array + { + return $this->validator->mapExportableRow($schema, $row, $strict); + } + public function describeFields(array $schema): array { $effectiveSchema = FormSchemaLayout::resolve($schema); @@ -156,7 +181,7 @@ private function normalizePayload(array $schema, array $fields, array $validated if (! $hasValue) { if (array_key_exists('default', $field) && $field['default'] !== null) { - $payload[$name] = $this->normalizeValue($type, $field['default']); + $payload[$name] = $this->normalizeValue($field, $field['default']); } continue; @@ -191,42 +216,37 @@ private function normalizePayload(array $schema, array $fields, array $validated continue; } - $payload[$name] = $this->normalizeValue($type, $value); + $payload[$name] = $this->normalizeValue($field, $value); } return [$payload, $files]; } - private function normalizeValue(string $type, mixed $value): mixed + private function normalizeValue(array $field, mixed $value): mixed { + $type = FieldType::normalize((string) ($field['type'] ?? '')); + $temporalMode = (string) ($field['temporal_mode'] ?? FieldType::temporalMode((string) ($field['type'] ?? '')) ?? 'date'); + return match ($type) { FieldType::TEXT, - FieldType::TEXTAREA, - FieldType::EMAIL => (string) $value, + FieldType::RADIO => (string) $value, FieldType::NUMBER => $this->normalizeNumber($value), - FieldType::CHECKBOX, - FieldType::SWITCH => (bool) $value, + FieldType::CONSENT => (bool) $value, FieldType::CHECKBOX_GROUP => array_values(is_array($value) ? $value : [$value]), + FieldType::TEMPORAL => match ($temporalMode) { + 'date' => Carbon::parse((string) $value)->format('Y-m-d'), + 'time' => Carbon::parse((string) $value)->format('H:i:s'), + default => $value, + }, + FieldType::DATE => Carbon::parse((string) $value)->format('Y-m-d'), FieldType::TIME => Carbon::parse((string) $value)->format('H:i:s'), - FieldType::DATETIME => Carbon::parse((string) $value)->toIso8601String(), - - FieldType::DATE_RANGE => [ - 'start' => Carbon::parse((string) Arr::get($value, 'start'))->format('Y-m-d'), - 'end' => Carbon::parse((string) Arr::get($value, 'end'))->format('Y-m-d'), - ], - - FieldType::DATETIME_RANGE => [ - 'start' => Carbon::parse((string) Arr::get($value, 'start'))->toIso8601String(), - 'end' => Carbon::parse((string) Arr::get($value, 'end'))->toIso8601String(), - ], - default => $value, }; } diff --git a/src/Submissions/SubmissionValidator.php b/src/Submissions/SubmissionValidator.php index 6cdbeca..d83c343 100644 --- a/src/Submissions/SubmissionValidator.php +++ b/src/Submissions/SubmissionValidator.php @@ -4,11 +4,14 @@ namespace EvanSchleret\FormForge\Submissions; +use Closure; use EvanSchleret\FormForge\Definition\FieldType; use EvanSchleret\FormForge\Exceptions\FormForgeException; use EvanSchleret\FormForge\Exceptions\UnknownFieldsException; +use EvanSchleret\FormForge\Support\FormSchemaExportableFields; use EvanSchleret\FormForge\Support\ValidationLocaleResolver; use Illuminate\Support\Arr; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; @@ -19,6 +22,31 @@ public function __construct( ) { } + public function exportableFields(array $schema): array + { + return FormSchemaExportableFields::exportableFields($schema); + } + + public function flattenExportableFields(array $schema): array + { + return FormSchemaExportableFields::flattenExportableFields($schema); + } + + public function resolveExportableField(array $schema, string $identifier): ?array + { + return FormSchemaExportableFields::resolveExportableField($schema, $identifier); + } + + public function validateExportableHeaders(array $schema, array $headers): array + { + return FormSchemaExportableFields::validateExportableHeaders($schema, $headers); + } + + public function mapExportableRow(array $schema, array $row, bool $strict = true): array + { + return FormSchemaExportableFields::mapExportableRow($schema, $row, $strict); + } + public function describeFields(array $schema): array { $fields = Arr::get($schema, 'fields', []); @@ -53,9 +81,11 @@ public function describeFields(array $schema): array 'id' => $id, 'label' => $label, 'type' => $type, + 'temporal_mode' => $this->nullableTrimmed($field['temporal_mode'] ?? null), 'required' => (bool) ($field['required'] ?? false), 'rules' => $this->normalizeRules($field['rules'] ?? []), 'options' => $this->normalizeOptions($field['options'] ?? []), + 'meta' => is_array($field['meta'] ?? null) ? $field['meta'] : [], 'default' => $field['default'] ?? null, 'lookup_keys' => $this->lookupKeys($name, $fieldKey, $key, $id), ]; @@ -373,7 +403,7 @@ private function compileRules(array $fields): array } $name = (string) ($field['name'] ?? ''); - $type = (string) ($field['type'] ?? ''); + $type = FieldType::normalize((string) ($field['type'] ?? '')); if ($name === '' || $type === '') { continue; @@ -381,8 +411,10 @@ private function compileRules(array $fields): array $baseRules = $this->normalizeRules($field['rules'] ?? []); - if (FieldType::isRange($type)) { - $this->compileRangeRules($rules, $name, $type, $field, $baseRules); + $temporalMode = (string) ($field['temporal_mode'] ?? FieldType::temporalMode((string) ($field['type'] ?? '')) ?? 'date'); + + if ($type === FieldType::ADDRESS) { + $this->compileAddressRules($rules, $name, $field, $baseRules); continue; } @@ -396,37 +428,390 @@ private function compileRules(array $fields): array continue; } + if ($this->supportsMetaValidation($type, $temporalMode)) { + $baseRules = array_merge($baseRules, $this->compileMetaValidationRules($field, $temporalMode)); + } + $rules[$name] = $baseRules; } return $rules; } - private function compileRangeRules(array &$rules, string $name, string $type, array $field, array $baseRules): void + private function compileAddressRules(array &$rules, string $name, array $field, array $baseRules): void { if (! in_array('array', $baseRules, true)) { $baseRules[] = 'array'; } + if ($this->supportsMetaValidation((string) ($field['type'] ?? ''))) { + $baseRules = array_merge($baseRules, $this->compileMetaValidationRules($field)); + } + $rules[$name] = $baseRules; - $required = (bool) ($field['required'] ?? false); - $format = $type === FieldType::DATE_RANGE ? 'date_format:Y-m-d' : 'date'; + $addressFields = $this->normalizeAddressFields($field['address_fields'] ?? null); + + foreach ($addressFields as $addressField) { + if (! ($addressField['visible'] ?? true)) { + continue; + } + + $subKey = trim((string) ($addressField['key'] ?? '')); + + if ($subKey === '') { + continue; + } + + $subRules = ((bool) ($addressField['required'] ?? false)) + ? ['required', 'string'] + : ['nullable', 'string']; + + $rules[$name . '.' . $subKey] = $subRules; + } + } + + private function supportsMetaValidation(string $type, ?string $temporalMode = null): bool + { + if ($type === FieldType::TEXT || $type === FieldType::ADDRESS) { + return true; + } + + if ($type !== FieldType::TEMPORAL || ! is_string($temporalMode)) { + return false; + } + + return in_array($temporalMode, ['date', 'time'], true); + } + + private function compileMetaValidationRules(array $field, ?string $temporalMode = null): array + { + $validation = $this->metaValidationConfig($field); + + if ($validation === null) { + return []; + } + + $fieldType = (string) ($field['type'] ?? ''); + + return [function (string $attribute, mixed $value, Closure $fail) use ($validation, $fieldType, $field, $temporalMode): void { + if ($fieldType !== FieldType::ADDRESS && $fieldType !== FieldType::TEMPORAL && ! is_string($value)) { + return; + } + + $results = []; + + foreach ($validation['rules'] as $rule) { + $expectedValue = $value; + + if ($fieldType === FieldType::ADDRESS) { + $expectedValue = $this->addressRuleValue($value, $rule['target'] ?? null); + } elseif ($fieldType === FieldType::TEMPORAL) { + $result = $this->evaluateTemporalMetaValidationRule($value, $rule, (string) ($temporalMode ?? 'date')); + + if ($result !== null) { + $results[] = $result; + } + + continue; + } + + $result = $this->evaluateMetaValidationRule($expectedValue, $rule); + + if ($result !== null) { + $results[] = $result; + } + } + + if ($results === []) { + return; + } + + $matched = $validation['match'] === 'any' + ? in_array(true, $results, true) + : ! in_array(false, $results, true); + + if (! $matched) { + $fail(trans('formforge::validation.field_validation_failed')); + } + }]; + } + + private function metaValidationConfig(array $field): ?array + { + $meta = $field['meta'] ?? null; + + if (! is_array($meta)) { + return null; + } + + $validation = $meta['validation'] ?? null; + + if (! is_array($validation)) { + return null; + } + + $match = $validation['match'] ?? null; + if (! in_array($match, ['all', 'any'], true)) { + return null; + } + + $rules = $validation['rules'] ?? null; + if (! is_array($rules) || $rules === []) { + return null; + } + + $normalizedRules = []; + + foreach ($rules as $rule) { + if (! is_array($rule)) { + continue; + } + + $operator = trim((string) ($rule['operator'] ?? '')); + $validationKey = trim((string) ($rule['validation_key'] ?? '')); + + if ($validationKey === '' || ! in_array($operator, ['min', 'max', 'after', 'before', 'between', 'not_between', 'regex', 'eq', 'neq', 'contains', 'not_contains'], true)) { + continue; + } + + $normalizedRules[] = [ + 'validation_key' => $validationKey, + 'target' => isset($rule['target']) ? $this->nullableTrimmed($rule['target']) : null, + 'operator' => $operator, + 'value' => $rule['value'] ?? null, + 'unit' => $rule['unit'] ?? null, + ]; + } + + if ($normalizedRules === []) { + return null; + } + + return [ + 'match' => $match, + 'rules' => $normalizedRules, + ]; + } + + private function evaluateMetaValidationRule(mixed $value, array $rule): ?bool + { + $operator = (string) ($rule['operator'] ?? ''); + $expected = $rule['value'] ?? null; + + if (! is_string($value)) { + return null; + } + + if ($operator === 'min') { + return $this->stringLength($value) >= $this->ruleNumberValue($expected, 0); + } + + if ($operator === 'max') { + return $this->stringLength($value) <= $this->ruleNumberValue($expected, 0); + } + + if ($operator === 'eq') { + return $value === $this->ruleStringValue($expected); + } + + if ($operator === 'neq') { + return $value !== $this->ruleStringValue($expected); + } + + if ($operator === 'contains') { + return str_contains($value, $this->ruleStringValue($expected)); + } + + if ($operator === 'not_contains') { + return ! str_contains($value, $this->ruleStringValue($expected)); + } + + if ($operator === 'regex') { + $pattern = $this->compileRegexPattern($this->ruleStringValue($expected)); + + if ($pattern === null) { + return null; + } + + $result = @preg_match($pattern, $value); + + if ($result === false) { + return null; + } + + return $result === 1; + } + + return null; + } + + private function evaluateTemporalMetaValidationRule(mixed $value, array $rule, string $temporalMode): ?bool + { + if (! is_string($value)) { + return null; + } + + $operator = (string) ($rule['operator'] ?? ''); + $expected = $rule['value'] ?? null; + + $actualComparable = $this->temporalComparableValue($value, $temporalMode); + + if ($actualComparable === null) { + return null; + } + + if ($operator === 'between' || $operator === 'not_between') { + $range = $this->temporalValidationRangeValue($expected); + + if ($range === null) { + return null; + } + + $startComparable = $this->temporalComparableValue($range['start'], $temporalMode); + $endComparable = $this->temporalComparableValue($range['end'], $temporalMode); + + if ($startComparable === null || $endComparable === null) { + return null; + } + + $isBetween = $actualComparable >= $startComparable && $actualComparable <= $endComparable; + + return $operator === 'between' ? $isBetween : ! $isBetween; + } + + $expectedComparable = $this->temporalComparableValue($this->ruleStringValue($expected), $temporalMode); + + if ($expectedComparable === null) { + return null; + } + + if ($operator === 'after') { + return $actualComparable > $expectedComparable; + } + + if ($operator === 'before') { + return $actualComparable < $expectedComparable; + } + + if ($operator === 'min') { + return $actualComparable >= $expectedComparable; + } + + if ($operator === 'max') { + return $actualComparable <= $expectedComparable; + } + + if ($operator === 'eq') { + return $actualComparable === $expectedComparable; + } + + if ($operator === 'neq') { + return $actualComparable !== $expectedComparable; + } + + return null; + } + + private function addressRuleValue(mixed $value, mixed $target): mixed + { + if (! is_array($value) || ! is_string($target) || $target === '') { + return null; + } + + return $value[$target] ?? null; + } + + private function temporalValidationRangeValue(mixed $value): ?array + { + if (is_array($value)) { + return [ + 'start' => isset($value['start']) ? $this->ruleStringValue($value['start']) : '', + 'end' => isset($value['end']) ? $this->ruleStringValue($value['end']) : '', + ]; + } + + return null; + } - $leafRules = []; + private function temporalComparableValue(string $value, string $temporalMode): ?int + { + $trimmed = trim($value); - if ($required) { - $leafRules[] = 'required'; + if ($trimmed === '') { + return null; } - $leafRules[] = $format; + if ($temporalMode === 'time') { + if (! preg_match('/^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/', $trimmed, $matches)) { + return null; + } + + $hours = (int) ($matches[1] ?? 0); + $minutes = (int) ($matches[2] ?? 0); + $seconds = (int) ($matches[3] ?? 0); + $milliseconds = (int) str_pad((string) ($matches[4] ?? '0'), 3, '0'); + + return ((($hours * 60) + $minutes) * 60 + $seconds) * 1000 + $milliseconds; + } - $rules[$name . '.start'] = $leafRules; + if (! preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $trimmed, $matches)) { + return null; + } - $endRules = [...$leafRules]; - $endRules[] = 'after_or_equal:' . $name . '.start'; + try { + $date = Carbon::createFromFormat('Y-m-d', sprintf('%04d-%02d-%02d', (int) $matches[1], (int) $matches[2], (int) $matches[3]), 'UTC'); - $rules[$name . '.end'] = $endRules; + return ($date->getTimestamp() * 1000) + intdiv((int) $date->format('u'), 1000); + } catch (\Throwable) { + return null; + } + } + + private function ruleStringValue(mixed $value): string + { + if (is_string($value)) { + return $value; + } + + if (is_int($value) || is_float($value) || is_bool($value)) { + return (string) $value; + } + + return ''; + } + + private function ruleNumberValue(mixed $value, int|float $fallback): int|float + { + if (is_int($value) || is_float($value)) { + return $value; + } + + if (is_string($value)) { + $trimmed = trim($value); + + if ($trimmed !== '' && is_numeric($trimmed)) { + return str_contains($trimmed, '.') ? (float) $trimmed : (int) $trimmed; + } + } + + return $fallback; + } + + private function stringLength(string $value): int + { + return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); + } + + private function compileRegexPattern(string $pattern): ?string + { + foreach (['~', '#', '%', '!', '@', '/'] as $delimiter) { + if (! str_contains($pattern, $delimiter)) { + return $delimiter . $pattern . $delimiter . 'u'; + } + } + + return null; } private function compileOptionRules(array &$rules, string $name, string $type, array $field, array $baseRules): void @@ -578,6 +963,52 @@ private function normalizeOptions(mixed $options): array return array_values($options); } + private function normalizeAddressFields(mixed $addressFields): array + { + $defaults = [ + ['key' => 'line1', 'label' => trans('formforge::messages.address_line1'), 'visible' => true, 'required' => false], + ['key' => 'line2', 'label' => trans('formforge::messages.address_line2'), 'visible' => true, 'required' => false], + ['key' => 'city', 'label' => trans('formforge::messages.address_city'), 'visible' => true, 'required' => false], + ['key' => 'state', 'label' => trans('formforge::messages.address_state'), 'visible' => true, 'required' => false], + ['key' => 'zip', 'label' => trans('formforge::messages.address_zip'), 'visible' => true, 'required' => false], + ['key' => 'country', 'label' => trans('formforge::messages.address_country'), 'visible' => true, 'required' => false], + ]; + + if (! is_array($addressFields) || $addressFields === []) { + return $defaults; + } + + $normalized = []; + + foreach ($addressFields as $index => $addressField) { + if (! is_array($addressField)) { + continue; + } + + $key = trim((string) ($addressField['key'] ?? '')); + $hasLabel = array_key_exists('label', $addressField) + && (is_string($addressField['label']) || $addressField['label'] === null); + $label = $hasLabel ? trim((string) $addressField['label']) : ''; + + if ($key === '') { + $key = (string) ($defaults[$index]['key'] ?? "field_{$index}"); + } + + if (! $hasLabel) { + $label = (string) ($defaults[$index]['label'] ?? $key); + } + + $normalized[] = [ + 'key' => $key, + 'label' => $label, + 'visible' => (bool) ($addressField['visible'] ?? true), + 'required' => (bool) ($addressField['required'] ?? false), + ]; + } + + return $normalized === [] ? $defaults : $normalized; + } + private function lookupKeys(string $name, ?string $fieldKey, ?string $key, ?string $id): array { $values = [$name, $fieldKey, $key, $id]; @@ -606,10 +1037,18 @@ private function sanitizeNullishOptionalPayload(array $fields, array $payload): $name = (string) ($field['name'] ?? ''); $required = (bool) ($field['required'] ?? false); + if ((string) ($field['type'] ?? '') === FieldType::ADDRESS) { + $required = $this->hasRequiredAddressField($field); + } + if ($name === '' || $required || ! array_key_exists($name, $payload)) { continue; } + if ($this->hasRequiredAddressField($field) && $this->isNullishOptionalValue($payload[$name])) { + continue; + } + if ($this->isNullishOptionalValue($payload[$name])) { unset($payload[$name]); } @@ -618,6 +1057,27 @@ private function sanitizeNullishOptionalPayload(array $fields, array $payload): return $payload; } + private function hasRequiredAddressField(array $field): bool + { + if ((string) ($field['type'] ?? '') !== FieldType::ADDRESS) { + return false; + } + + $addressFields = $this->normalizeAddressFields($field['address_fields'] ?? null); + + foreach ($addressFields as $addressField) { + if (! ($addressField['visible'] ?? true)) { + continue; + } + + if ((bool) ($addressField['required'] ?? false)) { + return true; + } + } + + return false; + } + private function isNullishOptionalValue(mixed $value): bool { if ($value === null) { diff --git a/src/Support/DefaultFormPublicLinkResolver.php b/src/Support/DefaultFormPublicLinkResolver.php new file mode 100644 index 0000000..2979996 --- /dev/null +++ b/src/Support/DefaultFormPublicLinkResolver.php @@ -0,0 +1,40 @@ +resolveBaseUrl($request); + $prefix = trim((string) config('formforge.http.prefix', 'api/formforge/v1'), '/'); + $path = trim($prefix . '/forms/' . $key, '/'); + + return rtrim($baseUrl, '/') . '/' . $path; + } + + private function resolveBaseUrl(Request $request): string + { + $configured = config('formforge.http.public_link.base_url'); + + if (is_string($configured)) { + $configured = trim($configured); + + if ($configured !== '') { + return rtrim($configured, '/'); + } + } + + return rtrim($request->getSchemeAndHttpHost(), '/'); + } +} diff --git a/src/Support/FormPublicLinkResolver.php b/src/Support/FormPublicLinkResolver.php new file mode 100644 index 0000000..42d5f4f --- /dev/null +++ b/src/Support/FormPublicLinkResolver.php @@ -0,0 +1,12 @@ + $unknown === [] && $missing === [], + 'headers' => $normalizedHeaders, + 'expected' => array_values(array_map(static fn (array $field): string => (string) ($field['path'] ?? ''), $fields)), + 'matched' => array_values($matched), + 'resolved' => $resolved, + 'missing' => $missing, + 'unknown' => $unknown, + ]; + } + + public static function mapExportableRow(array $schema, array $row, bool $strict = true): array + { + $fields = self::flattenExportableFields($schema); + $lookup = self::lookupIndex($fields); + $payload = []; + $unknown = []; + $seen = []; + + foreach ($row as $header => $value) { + if (! is_scalar($header)) { + continue; + } + + $normalized = self::normalizeIdentifier((string) $header); + + if ($normalized === '' || ! isset($lookup[$normalized])) { + $unknown[] = $normalized; + continue; + } + + $field = $lookup[$normalized]; + $fieldId = (string) ($field['id'] ?? $normalized); + $seen[$fieldId] ??= []; + $seen[$fieldId][] = $normalized; + + if (($field['composite'] ?? false) === true) { + $fieldName = (string) ($field['field_name'] ?? ''); + $subfield = $field['subfield'] ?? null; + $subKey = is_array($subfield) ? (string) ($subfield['key'] ?? '') : ''; + + if ($fieldName === '' || $subKey === '') { + continue; + } + + if (! isset($payload[$fieldName]) || ! is_array($payload[$fieldName])) { + $payload[$fieldName] = []; + } + + $payload[$fieldName][$subKey] = $value; + continue; + } + + $fieldName = (string) ($field['field_name'] ?? ''); + + if ($fieldName === '') { + continue; + } + + $payload[$fieldName] = $value; + } + + $conflicts = []; + + foreach ($seen as $fieldId => $keys) { + $keys = array_values(array_unique($keys)); + + if (count($keys) <= 1) { + continue; + } + + $conflicts[] = implode(' vs ', $keys); + } + + if ($conflicts !== []) { + throw new FormForgeException(trans('formforge::validation.conflicting_payload_keys', [ + 'keys' => implode(', ', $conflicts), + ])); + } + + if ($strict && $unknown !== []) { + throw UnknownFieldsException::fromFields(array_values(array_unique($unknown))); + } + + return $payload; + } + + /** + * @return array> + */ + private static function flattenField(array $field): array + { + $type = FieldType::normalize((string) ($field['type'] ?? '')); + $name = trim((string) ($field['name'] ?? '')); + + if ($name === '' || $type === '' || (bool) ($field['disabled'] ?? false)) { + return []; + } + + if ($type === FieldType::ADDRESS) { + return self::flattenAddressField($field); + } + + return [self::buildSimpleDescriptor($field, $type, $name)]; + } + + /** + * @return array> + */ + private static function flattenAddressField(array $field): array + { + $fieldType = FieldType::normalize((string) ($field['type'] ?? '')); + $name = trim((string) ($field['name'] ?? '')); + + if ($name === '') { + return []; + } + + $addressFields = $field['address_fields'] ?? []; + + if (! is_array($addressFields)) { + $addressFields = []; + } + + $flattened = []; + + foreach ($addressFields as $subfield) { + if (! is_array($subfield)) { + continue; + } + + if (! (bool) ($subfield['visible'] ?? true)) { + continue; + } + + $subKey = trim((string) ($subfield['key'] ?? '')); + + if ($subKey === '') { + continue; + } + + $flattened[] = self::buildAddressDescriptor($field, $fieldType, $name, $subfield, $subKey); + } + + return $flattened; + } + + /** + * @return array + */ + private static function buildSimpleDescriptor(array $field, string $type, string $name): array + { + $fieldKey = trim((string) ($field['field_key'] ?? '')); + $label = self::normalizeLabel($field['label'] ?? null, $name); + $rules = self::normalizeRuleList($field['rules'] ?? []); + + return [ + 'id' => $fieldKey !== '' ? $fieldKey : $name, + 'path' => $name, + 'label' => $label, + 'type' => $type, + 'field_key' => $fieldKey !== '' ? $fieldKey : null, + 'field_name' => $name, + 'page_key' => self::nullableTrimmed($field['page_key'] ?? null), + 'required' => self::isRequiredField($field, $rules), + 'visible' => true, + 'disabled' => false, + 'composite' => false, + 'parent' => null, + 'subfield' => null, + 'rules' => $rules, + 'meta' => is_array($field['meta'] ?? null) ? $field['meta'] : [], + 'lookup_keys' => self::lookupKeys([ + $name, + $fieldKey, + (string) ($field['key'] ?? ''), + (string) ($field['id'] ?? ''), + ]), + ]; + } + + /** + * @return array + */ + private static function buildAddressDescriptor(array $field, string $type, string $name, array $subfield, string $subKey): array + { + $fieldKey = trim((string) ($field['field_key'] ?? '')); + $fieldLabel = self::normalizeLabel($field['label'] ?? null, $name); + $subfieldLabel = self::normalizeLabel($subfield['label'] ?? null, $subKey); + $fieldPath = $name . '.' . $subKey; + $fieldId = trim(($fieldKey !== '' ? $fieldKey : $name) . '.' . $subKey); + + return [ + 'id' => $fieldId, + 'path' => $fieldPath, + 'label' => $subfieldLabel !== '' ? $subfieldLabel : $fieldPath, + 'type' => $type, + 'field_key' => $fieldKey !== '' ? $fieldKey : null, + 'field_name' => $name, + 'page_key' => self::nullableTrimmed($field['page_key'] ?? null), + 'required' => (bool) ($subfield['required'] ?? false), + 'visible' => (bool) ($subfield['visible'] ?? true), + 'disabled' => (bool) ($field['disabled'] ?? false), + 'composite' => true, + 'parent' => [ + 'id' => $fieldKey !== '' ? $fieldKey : $name, + 'path' => $name, + 'label' => $fieldLabel, + 'type' => $type, + 'field_key' => $fieldKey !== '' ? $fieldKey : null, + 'field_name' => $name, + ], + 'subfield' => [ + 'key' => $subKey, + 'label' => $subfieldLabel, + 'required' => (bool) ($subfield['required'] ?? false), + 'visible' => (bool) ($subfield['visible'] ?? true), + ], + 'rules' => self::normalizeRuleList($subfield['rules'] ?? self::addressSubfieldRules($subfield)), + 'meta' => is_array($field['meta'] ?? null) ? $field['meta'] : [], + 'lookup_keys' => self::lookupKeys([ + $fieldPath, + $fieldId, + ]), + ]; + } + + /** + * @param array> $fields + * @return array> + */ + private static function lookupIndex(array $fields): array + { + $lookup = []; + + foreach ($fields as $field) { + if (! is_array($field)) { + continue; + } + + foreach ((array) ($field['lookup_keys'] ?? []) as $alias) { + if (! is_string($alias) || $alias === '') { + continue; + } + + $lookup[$alias] = $field; + } + } + + return $lookup; + } + + /** + * @param array $values + * @return array + */ + private static function lookupKeys(array $values): array + { + $normalized = []; + + foreach ($values as $value) { + $candidate = self::normalizeIdentifier($value); + + if ($candidate !== '') { + $normalized[] = $candidate; + } + } + + return array_values(array_unique($normalized)); + } + + private static function normalizeIdentifier(string $value): string + { + $value = preg_replace('/^\xEF\xBB\xBF/u', '', $value); + + return trim(is_string($value) ? $value : ''); + } + + private static function normalizeLabel(mixed $value, string $fallback): string + { + if (is_string($value)) { + $value = trim($value); + + if ($value !== '') { + return $value; + } + } + + return self::humanizeIdentifier($fallback); + } + + private static function humanizeIdentifier(string $value): string + { + $value = trim($value); + + if ($value === '') { + return ''; + } + + $value = str_replace('_', ' ', $value); + $value = preg_replace('/([a-zA-Z])([0-9]+)/', '$1 $2', $value) ?? $value; + + return ucfirst(trim($value)); + } + + /** + * @param array $rules + * @return array + */ + private static function normalizeRuleList(mixed $rules): array + { + if (! is_array($rules)) { + return []; + } + + $normalized = []; + + foreach ($rules as $rule) { + if (is_array($rule)) { + foreach ($rule as $nested) { + $candidate = trim((string) $nested); + + if ($candidate !== '') { + $normalized[] = $candidate; + } + } + + continue; + } + + $candidate = trim((string) $rule); + + if ($candidate !== '') { + $normalized[] = $candidate; + } + } + + return array_values(array_unique($normalized)); + } + + private static function isRequiredField(array $field, array $rules): bool + { + if ((bool) ($field['required'] ?? false)) { + return true; + } + + return in_array('required', $rules, true); + } + + private static function addressSubfieldRules(array $subfield): array + { + return (bool) ($subfield['required'] ?? false) + ? ['required', 'string'] + : ['nullable', 'string']; + } + + private static function nullableTrimmed(mixed $value): ?string + { + if (! is_scalar($value)) { + return null; + } + + $trimmed = trim((string) $value); + + return $trimmed === '' ? null : $trimmed; + } +} diff --git a/src/Support/FormSchemaLayout.php b/src/Support/FormSchemaLayout.php index e9c2a70..82c9dce 100644 --- a/src/Support/FormSchemaLayout.php +++ b/src/Support/FormSchemaLayout.php @@ -4,10 +4,16 @@ namespace EvanSchleret\FormForge\Support; +use EvanSchleret\FormForge\Definition\FieldType; use EvanSchleret\FormForge\Exceptions\InvalidFieldDefinitionException; +use EvanSchleret\FormForge\Support\RichTextSanitizer; final class FormSchemaLayout { + public const SCHEMA_VERSION_V1 = 1; + public const SCHEMA_VERSION_V2 = 2; + public const LATEST_SCHEMA_VERSION = self::SCHEMA_VERSION_V2; + public const TARGET_PAGE = 'page'; public const TARGET_FIELD = 'field'; @@ -38,6 +44,11 @@ public static function normalize(array $schema): array $key = trim((string) ($schema['key'] ?? '')); $version = trim((string) ($schema['version'] ?? '')); $title = trim((string) ($schema['title'] ?? $key)); + $schemaVersion = (int) ($schema['schema_version'] ?? self::SCHEMA_VERSION_V1); + + if ($schemaVersion <= 0) { + $schemaVersion = self::SCHEMA_VERSION_V1; + } $pagesRaw = self::normalizePagesInput( pages: $schema['pages'] ?? null, @@ -82,7 +93,8 @@ public static function normalize(array $schema): array } $name = trim((string) ($fieldRaw['name'] ?? '')); - $type = trim((string) ($fieldRaw['type'] ?? '')); + $rawType = trim((string) ($fieldRaw['type'] ?? '')); + $type = FieldType::normalize($rawType); if ($name === '' || $type === '') { throw new InvalidFieldDefinitionException("Page [{$pageKey}] fields must contain name and type."); @@ -107,13 +119,42 @@ public static function normalize(array $schema): array $field = $fieldRaw; $field['field_key'] = $fieldKey; $field['page_key'] = $pageKey; + $field['type'] = $type; unset($field['section_key']); $field['required'] = (bool) ($fieldRaw['required'] ?? false); $field['disabled'] = (bool) ($fieldRaw['disabled'] ?? false); unset($field['nullable'], $field['readonly']); - if (! isset($field['label']) || trim((string) $field['label']) === '') { - $field['label'] = ucfirst(str_replace('_', ' ', $name)); + if ($type === FieldType::TEMPORAL) { + $temporalMode = self::normalizeTemporalMode( + is_string($fieldRaw['temporal_mode'] ?? null) ? (string) $fieldRaw['temporal_mode'] : (FieldType::temporalMode($rawType) ?? 'date') + ); + $field['temporal_mode'] = $temporalMode; + if ($temporalMode === 'time') { + $field['hour_cycle'] = self::normalizeHourCycle($fieldRaw['hour_cycle'] ?? null); + } else { + unset($field['hour_cycle']); + } + } else { + unset($field['temporal_mode']); + unset($field['hour_cycle']); + } + + if (array_key_exists('label', $field)) { + $sanitizedLabel = self::sanitizeRichText((string) $field['label']); + if ($type === FieldType::ADDRESS && trim($sanitizedLabel) === '') { + $field['label'] = ''; + } else { + $field['label'] = trim($sanitizedLabel) !== '' + ? $sanitizedLabel + : ($type === FieldType::TEMPORAL + ? self::defaultTemporalLabel((string) ($field['temporal_mode'] ?? 'date')) + : ucfirst(str_replace('_', ' ', $name))); + } + } else { + $field['label'] = $type === FieldType::TEMPORAL + ? self::defaultTemporalLabel((string) ($field['temporal_mode'] ?? 'date')) + : ucfirst(str_replace('_', ' ', $name)); } if (! isset($field['meta']) || ! is_array($field['meta'])) { @@ -127,11 +168,17 @@ public static function normalize(array $schema): array $fields[] = $field; } + $meta = is_array($pageRaw['meta'] ?? null) ? $pageRaw['meta'] : []; + + if (array_key_exists('logic', $meta)) { + $meta['logic'] = self::normalizePageLogic($meta['logic']); + } + $pages[] = [ 'page_key' => $pageKey, 'title' => $pageTitle, 'description' => self::normalizeOptionalString($pageRaw['description'] ?? null), - 'meta' => is_array($pageRaw['meta'] ?? null) ? $pageRaw['meta'] : [], + 'meta' => $meta, 'fields' => $fields, ]; } @@ -148,6 +195,7 @@ public static function normalize(array $schema): array $normalized = $schema; $normalized['title'] = $title; + $normalized['schema_version'] = $schemaVersion; $normalized['pages'] = $pages; $normalized['fields'] = $flatFields; $normalized['conditions'] = $conditions; @@ -208,74 +256,189 @@ public static function resolve(array $schema, array $payload = [], bool $debug = } $conditionDebug = []; + $pageLogicDebug = []; + $hasPageLogic = false; + $pageLogicByPage = []; - foreach ($conditions as $condition) { - if (! is_array($condition)) { + foreach ($pages as $page) { + if (! is_array($page)) { continue; } - $targetType = (string) ($condition['target_type'] ?? ''); - $targetKey = (string) ($condition['target_key'] ?? ''); - $action = (string) ($condition['action'] ?? ''); - $matchMode = (string) ($condition['match'] ?? self::MATCH_ALL); - $clauses = $condition['when'] ?? []; + $pageKey = (string) ($page['page_key'] ?? ''); - if (! is_array($clauses) || $clauses === []) { + if ($pageKey === '') { continue; } - $evaluations = []; + $logic = self::normalizePageLogic($page['meta']['logic'] ?? null); - foreach ($clauses as $clause) { - if (! is_array($clause)) { + if (($logic['rules'] ?? []) === []) { + continue; + } + + $hasPageLogic = true; + $pageLogicByPage[$pageKey] = $logic; + } + + if ($hasPageLogic) { + foreach ($pages as $page) { + if (! is_array($page)) { continue; } - $fieldKey = (string) ($clause['field_key'] ?? ''); - $operator = (string) ($clause['operator'] ?? self::OP_EQ); - $expected = $clause['value'] ?? null; - $fieldName = $fieldNameByKey[$fieldKey] ?? null; - $actual = $fieldName !== null ? ($payload[$fieldName] ?? null) : null; - $result = self::evaluate($actual, $operator, $expected); + $pageKey = (string) ($page['page_key'] ?? ''); + $logic = $pageLogicByPage[$pageKey] ?? null; - $evaluations[] = $result; - } + if ($pageKey === '' || ! is_array($logic)) { + continue; + } - if ($evaluations === []) { - continue; - } + $fieldLookup = []; - $matched = $matchMode === self::MATCH_ANY - ? in_array(true, $evaluations, true) - : ! in_array(false, $evaluations, true); + foreach ((array) ($page['fields'] ?? []) as $field) { + if (! is_array($field)) { + continue; + } - if ($debug) { - $conditionDebug[] = [ - 'condition_key' => (string) ($condition['condition_key'] ?? ''), - 'target_type' => $targetType, - 'target_key' => $targetKey, - 'action' => $action, - 'matched' => $matched, - 'evaluations' => $evaluations, - ]; - } + $fieldKey = (string) ($field['field_key'] ?? ''); + $fieldName = (string) ($field['name'] ?? ''); - if (! $matched) { - continue; + if ($fieldKey !== '' && $fieldName !== '') { + $fieldLookup[$fieldKey] = $fieldName; + } + } + + foreach ((array) ($logic['rules'] ?? []) as $rule) { + if (! is_array($rule)) { + continue; + } + + $evaluations = []; + + foreach ((array) ($rule['when'] ?? []) as $clause) { + if (! is_array($clause)) { + continue; + } + + $fieldKey = (string) ($clause['field_key'] ?? ''); + $operator = (string) ($clause['operator'] ?? self::OP_EQ); + $expected = $clause['value'] ?? null; + $fieldName = $fieldLookup[$fieldKey] ?? null; + $actual = $fieldName !== null ? ($payload[$fieldName] ?? null) : null; + + $evaluations[] = self::evaluatePageLogicClause($actual, $operator, $expected); + } + + if ($evaluations === []) { + continue; + } + + $matched = (string) ($rule['match'] ?? self::MATCH_ALL) === self::MATCH_ANY + ? in_array(true, $evaluations, true) + : ! in_array(false, $evaluations, true); + + if ($debug) { + $pageLogicDebug[] = [ + 'page_key' => $pageKey, + 'rule_key' => (string) ($rule['rule_key'] ?? ''), + 'matched' => $matched, + 'evaluations' => $evaluations, + ]; + } + + if (! $matched) { + continue; + } + + foreach ((array) ($rule['then'] ?? []) as $thenAction) { + if (! is_array($thenAction)) { + continue; + } + + if ((string) ($thenAction['action'] ?? '') !== 'require') { + continue; + } + + $fieldKey = trim((string) ($thenAction['field_key'] ?? '')); + + if ($fieldKey === '' || ! array_key_exists($fieldKey, $fieldNameByKey)) { + continue; + } + + $fieldRequired[$fieldKey] = true; + } + } } + } else { + foreach ($conditions as $condition) { + if (! is_array($condition)) { + continue; + } - self::applyAction( - targetType: $targetType, - targetKey: $targetKey, - action: $action, - pageVisible: $pageVisible, - pageHiddenHard: $pageHiddenHard, - fieldVisible: $fieldVisible, - fieldHiddenHard: $fieldHiddenHard, - fieldRequired: $fieldRequired, - fieldDisabled: $fieldDisabled, - fieldsByPage: $fieldsByPage, - ); + $targetType = (string) ($condition['target_type'] ?? ''); + $targetKey = (string) ($condition['target_key'] ?? ''); + $action = (string) ($condition['action'] ?? ''); + $matchMode = (string) ($condition['match'] ?? self::MATCH_ALL); + $clauses = $condition['when'] ?? []; + + if (! is_array($clauses) || $clauses === []) { + continue; + } + + $evaluations = []; + + foreach ($clauses as $clause) { + if (! is_array($clause)) { + continue; + } + + $fieldKey = (string) ($clause['field_key'] ?? ''); + $operator = (string) ($clause['operator'] ?? self::OP_EQ); + $expected = $clause['value'] ?? null; + $fieldName = $fieldNameByKey[$fieldKey] ?? null; + $actual = $fieldName !== null ? ($payload[$fieldName] ?? null) : null; + $result = self::evaluate($actual, $operator, $expected); + + $evaluations[] = $result; + } + + if ($evaluations === []) { + continue; + } + + $matched = $matchMode === self::MATCH_ANY + ? in_array(true, $evaluations, true) + : ! in_array(false, $evaluations, true); + + if ($debug) { + $conditionDebug[] = [ + 'condition_key' => (string) ($condition['condition_key'] ?? ''), + 'target_type' => $targetType, + 'target_key' => $targetKey, + 'action' => $action, + 'matched' => $matched, + 'evaluations' => $evaluations, + ]; + } + + if (! $matched) { + continue; + } + + self::applyAction( + targetType: $targetType, + targetKey: $targetKey, + action: $action, + pageVisible: $pageVisible, + pageHiddenHard: $pageHiddenHard, + fieldVisible: $fieldVisible, + fieldHiddenHard: $fieldHiddenHard, + fieldRequired: $fieldRequired, + fieldDisabled: $fieldDisabled, + fieldsByPage: $fieldsByPage, + ); + } } $effectivePages = []; @@ -322,6 +485,7 @@ public static function resolve(array $schema, array $payload = [], bool $debug = if ($debug) { $resolved['debug'] = [ 'conditions' => $conditionDebug, + 'page_logic' => $pageLogicDebug, 'visible' => [ 'pages' => array_values(array_keys(array_filter($pageVisible, static fn (bool $state): bool => $state))), 'fields' => array_values(array_keys(array_filter($fieldVisible, static fn (bool $state): bool => $state))), @@ -514,6 +678,174 @@ private static function normalizeConditions( return $normalized; } + private static function normalizePageLogic(mixed $logic): array + { + if (! is_array($logic)) { + return [ + 'version' => 1, + 'rules' => [], + ]; + } + + $rules = []; + + foreach ((array) ($logic['rules'] ?? []) as $index => $ruleRaw) { + if (! is_array($ruleRaw)) { + continue; + } + + $rules[] = self::normalizePageLogicRule($ruleRaw, is_int($index) ? $index : count($rules)); + } + + return [ + 'version' => (($version = (int) ($logic['version'] ?? 1)) > 0) ? $version : 1, + 'rules' => $rules, + ]; + } + + private static function normalizePageLogicRule(array $ruleRaw, int $index): array + { + $when = []; + + foreach ((array) ($ruleRaw['when'] ?? []) as $clauseRaw) { + if (! is_array($clauseRaw)) { + continue; + } + + $when[] = self::normalizePageLogicClause($clauseRaw); + } + + $then = []; + + foreach ((array) ($ruleRaw['then'] ?? []) as $thenRaw) { + if (! is_array($thenRaw)) { + continue; + } + + $then[] = self::normalizePageLogicThen($thenRaw); + } + + $fallback = self::normalizePageLogicFallback($ruleRaw['fallback'] ?? null); + + $dedupedThen = []; + $seenRequireFieldKeys = []; + + foreach ($then as $thenAction) { + if (($thenAction['action'] ?? null) === 'require') { + $fieldKey = trim((string) ($thenAction['field_key'] ?? '')); + + if ($fieldKey !== '' && in_array($fieldKey, $seenRequireFieldKeys, true)) { + continue; + } + + if ($fieldKey !== '') { + $seenRequireFieldKeys[] = $fieldKey; + } + } + + $dedupedThen[] = $thenAction; + } + + return [ + 'rule_key' => self::normalizeKeyValue($ruleRaw['rule_key'] ?? null, 'lr_' . substr(hash('sha256', (string) $index), 0, 6)), + 'match' => (string) ($ruleRaw['match'] ?? self::MATCH_ALL) === self::MATCH_ANY ? self::MATCH_ANY : self::MATCH_ALL, + 'when' => $when !== [] ? $when : [[ + 'field_key' => '', + 'operator' => self::OP_EQ, + 'value' => null, + ]], + 'then' => $dedupedThen !== [] ? $dedupedThen : [[ + 'action' => 'require', + 'field_key' => null, + 'block_index' => null, + ]], + 'fallback' => $fallback, + ]; + } + + private static function normalizePageLogicClause(array $clauseRaw): array + { + $operator = (string) ($clauseRaw['operator'] ?? self::OP_EQ); + + if (! in_array($operator, [ + self::OP_EQ, + self::OP_NEQ, + self::OP_CONTAINS, + self::OP_NOT_CONTAINS, + 'starts_with', + 'not_starts_with', + 'ends_with', + 'not_ends_with', + 'is_submitted', + 'is_not_submitted', + 'accepted', + 'ignored', + ], true)) { + $operator = self::OP_EQ; + } + + return [ + 'field_key' => self::normalizeKeyValue($clauseRaw['field_key'] ?? null, ''), + 'operator' => $operator, + 'value' => $clauseRaw['value'] ?? null, + ]; + } + + private static function normalizePageLogicThen(array $thenRaw): array + { + $action = (string) ($thenRaw['action'] ?? 'require'); + + if (! in_array($action, ['require', 'goto_block'], true)) { + $action = 'require'; + } + + return [ + 'action' => $action, + 'field_key' => self::normalizeNullableString($thenRaw['field_key'] ?? null), + 'block_index' => self::normalizeNullableBlockIndex($thenRaw['block_index'] ?? null), + ]; + } + + private static function normalizePageLogicFallback(mixed $fallback): array + { + if (! is_array($fallback)) { + return [ + 'action' => 'next', + 'block_index' => null, + ]; + } + + $action = (string) ($fallback['action'] ?? 'next'); + + if (! in_array($action, ['next', 'goto_block'], true)) { + $action = 'next'; + } + + return [ + 'action' => $action, + 'block_index' => self::normalizeNullableBlockIndex($fallback['block_index'] ?? null), + ]; + } + + private static function evaluatePageLogicClause(mixed $actual, string $operator, mixed $expected): bool + { + return match ($operator) { + self::OP_EQ => $actual === $expected, + self::OP_NEQ => $actual !== $expected, + self::OP_CONTAINS => is_string($actual) && is_string($expected) && str_contains($actual, $expected), + self::OP_NOT_CONTAINS => ! (is_string($actual) && is_string($expected) && str_contains($actual, $expected)), + 'starts_with' => is_string($actual) && is_string($expected) && str_starts_with($actual, $expected), + 'not_starts_with' => ! (is_string($actual) && is_string($expected) && str_starts_with($actual, $expected)), + 'ends_with' => is_string($actual) && is_string($expected) && str_ends_with($actual, $expected), + 'not_ends_with' => ! (is_string($actual) && is_string($expected) && str_ends_with($actual, $expected)), + 'is_submitted' => ! self::isPageLogicEmpty($actual), + 'is_not_submitted' => self::isPageLogicEmpty($actual), + 'accepted' => $actual === true, + 'ignored' => $actual !== true, + default => false, + }; + } + private static function applyAction( string $targetType, string $targetKey, @@ -648,6 +980,23 @@ private static function isEmpty(mixed $value): bool return false; } + private static function isPageLogicEmpty(mixed $value): bool + { + if ($value === null || $value === '') { + return true; + } + + if (is_string($value)) { + return trim($value) === ''; + } + + if (is_array($value)) { + return $value === []; + } + + return false; + } + private static function flattenFieldsFromPages(array $pages): array { $fields = []; @@ -691,6 +1040,65 @@ private static function normalizeOptionalString(mixed $value): ?string return $value === '' ? null : $value; } + private static function sanitizeRichText(string $value): string + { + return RichTextSanitizer::sanitize($value); + } + + private static function normalizeTemporalMode(string $mode): string + { + return in_array($mode, ['date', 'time'], true) + ? $mode + : 'date'; + } + + private static function defaultTemporalLabel(string $mode): string + { + return match (self::normalizeTemporalMode($mode)) { + 'time' => 'Time', + default => 'Date', + }; + } + + private static function normalizeHourCycle(mixed $value): int + { + if (is_int($value) && in_array($value, [12, 24], true)) { + return $value; + } + + return 24; + } + + private static function normalizeNullableString(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + $value = trim($value); + + return $value === '' ? null : $value; + } + + private static function normalizeNullableBlockIndex(mixed $value): ?int + { + if (! is_int($value) && ! is_string($value)) { + return null; + } + + if (is_string($value)) { + $value = trim($value); + + if ($value === '' || ! is_numeric($value)) { + return null; + } + } + + $index = (int) $value; + + return $index > 0 ? $index : null; + } + private static function normalizeKeyValue(mixed $value, string $fallback): string { if (is_string($value)) { diff --git a/src/Support/RichTextSanitizer.php b/src/Support/RichTextSanitizer.php new file mode 100644 index 0000000..c015a2b --- /dev/null +++ b/src/Support/RichTextSanitizer.php @@ -0,0 +1,25 @@ + 'a[href],b,br,em,i,li,ol,p,strong,u,ul', + 'AutoFormat.AutoParagraph' => false, + 'AutoFormat.RemoveEmpty' => false, + ]; + + public static function sanitize(?string $value): string + { + if (! is_string($value) || trim($value) === '') { + return ''; + } + + return Purifier::clean($value, self::PURIFIER_CONFIG); + } +} diff --git a/tests/Feature/FormForgeTest.php b/tests/Feature/FormForgeTest.php index 335e346..ea7433d 100644 --- a/tests/Feature/FormForgeTest.php +++ b/tests/Feature/FormForgeTest.php @@ -3,7 +3,9 @@ declare(strict_types=1); use EvanSchleret\FormForge\Exceptions\ImmutableVersionException; +use EvanSchleret\FormForge\Exceptions\FormNotFoundException; use EvanSchleret\FormForge\Exceptions\UnknownFieldsException; +use EvanSchleret\FormForge\Definition\FieldType; use EvanSchleret\FormForge\Facades\Form; use EvanSchleret\FormForge\FormManager; use EvanSchleret\FormForge\Models\FormCategory; @@ -15,6 +17,7 @@ use EvanSchleret\FormForge\Automations\SubmissionAutomationDispatcher; use EvanSchleret\FormForge\Ownership\OwnershipReference; use EvanSchleret\FormForge\Registry\FormRegistry; +use EvanSchleret\FormForge\Support\FormSchemaLayout; use EvanSchleret\FormForge\Tests\Fixtures\ActiveFormKeyAutomationResolver; use EvanSchleret\FormForge\Tests\Fixtures\CreateRecordFromSubmissionAutomation; use EvanSchleret\FormForge\Tests\Fixtures\Models\CustomFormSubmission; @@ -36,13 +39,13 @@ ->title('Registration') ->version('1') ->text('name')->required()->max(120) - ->email('email')->required(); + ->text('email')->required(); Form::define($key) ->title('Registration') ->version('2') ->text('name')->required()->max(120) - ->email('email')->required(); + ->text('email')->required(); $schemaV1 = Form::get($key, '1')->toArray(); $schemaV1Again = Form::get($key, '1')->toArray(); @@ -52,22 +55,32 @@ expect($schemaV1['fields'][0]['field_key'])->toBe($schemaV2['fields'][0]['field_key']); }); +it('sanitizes rich text field labels before exporting the schema', function (): void { + $key = 'rich_label_' . Str::lower(Str::random(8)); + + Form::define($key) + ->version('1') + ->text('name') + ->label('

Hello WorldLink

'); + + $schema = Form::get($key, '1')->toArray(); + + expect($schema['fields'][0]['label'])->toBe('

Hello WorldLink

'); +}); + it('treats nullish values as absent for optional fields', function (): void { $key = 'nullish_optional_' . Str::lower(Str::random(8)); Form::define($key) ->version('1') ->text('name') - ->email('email') - ->dateRange('vacation'); + ->text('email') + ->temporal('vacation', 'date'); $submission = Form::get($key, '1')->submit([ 'name' => '', 'email' => null, - 'vacation' => [ - 'start' => null, - 'end' => '', - ], + 'vacation' => '', ]); expect($submission->payload)->toBe([]); @@ -89,6 +102,195 @@ ]))->toThrow(\Illuminate\Validation\ValidationException::class); }); +it('exports address fields and validates nested address rules', function (): void { + $key = 'address_' . Str::lower(Str::random(8)); + + Form::define($key) + ->version('1') + ->address('address')->addressFields([ + ['key' => 'line1', 'label' => 'Address Line 1', 'visible' => true, 'required' => true], + ['key' => 'line2', 'label' => 'Address Line 2', 'visible' => true, 'required' => false], + ['key' => 'city', 'label' => 'City', 'visible' => true, 'required' => false], + ['key' => 'state', 'label' => 'State', 'visible' => true, 'required' => false], + ['key' => 'zip', 'label' => 'Zip', 'visible' => true, 'required' => false], + ['key' => 'country', 'label' => 'Country', 'visible' => true, 'required' => false], + ])->meta([ + 'validation' => [ + 'match' => 'all', + 'rules' => [ + [ + 'validation_key' => 'vr_line1_min', + 'target' => 'line1', + 'operator' => 'min', + 'value' => 5, + 'unit' => 'characters', + ], + ], + ], + ]); + + $schema = Form::get($key, '1')->toArray(); + + expect($schema['fields'][0]['type'])->toBe(FieldType::ADDRESS); + expect($schema['fields'][0]['address_fields'])->toHaveCount(6); + + expect(static fn () => Form::get($key, '1')->submit([ + 'address' => [ + 'line1' => '1234', + 'line2' => null, + 'city' => 'Geneva', + 'state' => 'GE', + 'zip' => '1200', + 'country' => 'CH', + ], + ]))->toThrow(\Illuminate\Validation\ValidationException::class); + + $submission = Form::get($key, '1')->submit([ + 'address' => [ + 'line1' => '1 Route des Acacias', + 'line2' => null, + 'city' => 'Geneva', + 'state' => 'GE', + 'zip' => '1200', + 'country' => 'CH', + ], + ]); + + expect($submission->payload['address']['line1'])->toBe('1 Route des Acacias'); + expect($submission->payload['address']['city'])->toBe('Geneva'); +}); + +it('exports translated default address labels according to the active locale', function (): void { + $key = 'address_locale_' . Str::lower(Str::random(8)); + + app()->setLocale('fr'); + + try { + Form::define($key) + ->version('1') + ->address('address'); + + $schema = Form::get($key, '1')->toArray(); + + expect($schema['fields'][0]['address_fields'])->toEqual([ + ['key' => 'line1', 'label' => "Ligne d'adresse 1", 'visible' => true, 'required' => true], + ['key' => 'line2', 'label' => "Ligne d'adresse 2", 'visible' => false, 'required' => false], + ['key' => 'city', 'label' => 'Ville', 'visible' => true, 'required' => true], + ['key' => 'state', 'label' => 'État', 'visible' => false, 'required' => false], + ['key' => 'zip', 'label' => 'Code postal', 'visible' => true, 'required' => true], + ['key' => 'country', 'label' => 'Pays', 'visible' => true, 'required' => true], + ]); + } finally { + app()->setLocale('en'); + } +}); + +it('preserves empty address field labels when exporting the schema', function (): void { + $key = 'address_empty_labels_' . Str::lower(Str::random(8)); + + Form::define($key) + ->version('1') + ->address('address') + ->addressFields([ + ['key' => 'line1', 'label' => '', 'visible' => true, 'required' => true], + ['key' => 'line2', 'label' => '', 'visible' => true, 'required' => false], + ['key' => 'city', 'label' => 'City', 'visible' => true, 'required' => true], + ['key' => 'state', 'label' => 'State', 'visible' => false, 'required' => false], + ['key' => 'zip', 'label' => 'Zip', 'visible' => true, 'required' => true], + ['key' => 'country', 'label' => 'Country', 'visible' => true, 'required' => true], + ]); + + $schema = Form::get($key, '1')->toArray(); + + expect($schema['fields'][0]['address_fields'])->toEqual([ + ['key' => 'line1', 'label' => '', 'visible' => true, 'required' => true], + ['key' => 'line2', 'label' => '', 'visible' => true, 'required' => false], + ['key' => 'city', 'label' => 'City', 'visible' => true, 'required' => true], + ['key' => 'state', 'label' => 'State', 'visible' => false, 'required' => false], + ['key' => 'zip', 'label' => 'Zip', 'visible' => true, 'required' => true], + ['key' => 'country', 'label' => 'Country', 'visible' => true, 'required' => true], + ]); +}); + +it('ignores top-level required on address fields and only enforces required address subfields', function (): void { + $key = 'address_required_' . Str::lower(Str::random(8)); + + Form::define($key) + ->version('1') + ->address('address') + ->required() + ->addressFields([ + ['key' => 'line1', 'label' => 'Address Line 1', 'visible' => true, 'required' => false], + ['key' => 'line2', 'label' => 'Address Line 2', 'visible' => true, 'required' => false], + ['key' => 'city', 'label' => 'City', 'visible' => true, 'required' => false], + ['key' => 'state', 'label' => 'State', 'visible' => true, 'required' => false], + ['key' => 'zip', 'label' => 'Zip', 'visible' => true, 'required' => false], + ['key' => 'country', 'label' => 'Country', 'visible' => true, 'required' => false], + ]); + + $submission = Form::get($key, '1')->submit([ + 'address' => [ + 'line1' => null, + 'line2' => null, + 'city' => null, + 'state' => null, + 'zip' => null, + 'country' => null, + ], + ]); + + expect($submission->payload)->toBe([]); +}); + +it('exports temporal fields through the merged temporal type and keeps legacy schemas compatible', function (): void { + $key = 'temporal_' . Str::lower(Str::random(8)); + + Form::define($key) + ->version('1') + ->temporal('travel', 'date'); + + $schema = Form::get($key, '1')->toArray(); + + expect($schema['fields'][0]['type'])->toBe(FieldType::TEMPORAL); + expect($schema['fields'][0]['temporal_mode'])->toBe('date'); + + $normalized = FormSchemaLayout::normalize([ + 'key' => $key, + 'version' => '1', + 'schema_version' => 2, + 'title' => 'Temporal', + 'fields' => [ + [ + 'field_key' => 'fk_trip', + 'type' => 'date', + 'name' => 'trip', + 'required' => false, + 'nullable' => false, + 'default' => null, + 'rules' => [], + 'meta' => [], + ], + ], + ]); + + expect($normalized['fields'][0]['type'])->toBe(FieldType::TEMPORAL); + expect($normalized['fields'][0]['temporal_mode'])->toBe('date'); +}); + +it('exports time temporal fields with a default 24-hour cycle', function (): void { + $key = 'time_' . Str::lower(Str::random(8)); + + Form::define($key) + ->version('1') + ->temporal('meeting_time', 'time'); + + $schema = Form::get($key, '1')->toArray(); + + expect($schema['fields'][0]['type'])->toBe(FieldType::TEMPORAL); + expect($schema['fields'][0]['temporal_mode'])->toBe('time'); + expect($schema['fields'][0]['hour_cycle'])->toBe(24); +}); + it('syncs runtime definitions idempotently and enforces immutability', function (): void { $key = 'sync_' . Str::lower(Str::random(8)); $initialCategoryCount = FormCategory::query()->count(); @@ -157,7 +359,7 @@ Form::define($key) ->version('1') ->text('name')->required() - ->email('email')->required(); + ->text('email')->required(); $form = Form::get($key, '1'); $schema = $form->toArray(); @@ -193,7 +395,7 @@ 'name' => 'email', 'field_key' => 'email_field_key', 'key' => 'email_key', - 'type' => 'email', + 'type' => 'text', 'rules' => ['required', 'email'], ]], ]; @@ -209,7 +411,7 @@ Form::define($key) ->version('1') - ->email('email')->required(); + ->text('email')->required()->rules('email'); $form = Form::get($key, '1'); $valid = $form->validateField('email', 'evan@example.com'); @@ -229,7 +431,7 @@ 'field_key' => ' email_field_key ', 'key' => ' email_key ', 'id' => ' email_id ', - 'type' => 'email', + 'type' => 'text', 'rules' => ['required', 'email'], ]], ]; @@ -248,12 +450,190 @@ ->toThrow(UnknownFieldsException::class); }); +it('stores field validation rules in the serialized schema', function (): void { + $key = 'validation_meta_' . Str::lower(Str::random(8)); + + Form::define($key) + ->version('1') + ->text('nickname') + ->meta([ + 'validation' => [ + 'match' => 'all', + 'rules' => [ + [ + 'validation_key' => 'vr_min', + 'operator' => 'min', + 'value' => 3, + 'unit' => 'characters', + ], + ], + ], + ]); + + $schema = Form::get($key, '1')->toArray(); + $validation = $schema['fields'][0]['meta']['validation'] ?? null; + + expect($validation)->toBeArray(); + expect($validation['match'])->toBe('all'); + expect($validation['rules'])->toHaveCount(1); +}); + +it('validates field meta rules on the backend', function (): void { + $validator = app(SubmissionValidator::class); + $schema = [ + 'fields' => [[ + 'name' => 'nickname', + 'field_key' => 'nickname_field_key', + 'type' => 'text', + 'required' => true, + 'rules' => ['required', 'string'], + 'meta' => [ + 'validation' => [ + 'match' => 'all', + 'rules' => [ + [ + 'validation_key' => 'vr_min', + 'operator' => 'min', + 'value' => 3, + 'unit' => 'characters', + ], + [ + 'validation_key' => 'vr_max', + 'operator' => 'max', + 'value' => 8, + 'unit' => 'characters', + ], + [ + 'validation_key' => 'vr_regex', + 'operator' => 'regex', + 'value' => '^[a-z]+$', + ], + [ + 'validation_key' => 'vr_contains', + 'operator' => 'contains', + 'value' => 'abc', + ], + [ + 'validation_key' => 'vr_not_contains', + 'operator' => 'not_contains', + 'value' => 'zzz', + ], + [ + 'validation_key' => 'vr_eq', + 'operator' => 'eq', + 'value' => 'abcde', + ], + [ + 'validation_key' => 'vr_neq', + 'operator' => 'neq', + 'value' => 'forbidden', + ], + ], + ], + ], + ]], + ]; + + $valid = $validator->validateField($schema, 'nickname', 'abcde'); + $invalid = $validator->validateField($schema, 'nickname', 'abczzz'); + + expect($valid['valid'])->toBeTrue(); + expect($valid['errors'])->toBe([]); + expect($invalid['valid'])->toBeFalse(); + expect($invalid['errors'])->toHaveKey('nickname'); +}); + +it('supports any-match field meta validation on the backend', function (): void { + $validator = app(SubmissionValidator::class); + $schema = [ + 'fields' => [[ + 'name' => 'nickname', + 'field_key' => 'nickname_field_key', + 'type' => 'text', + 'required' => true, + 'rules' => ['required', 'string'], + 'meta' => [ + 'validation' => [ + 'match' => 'any', + 'rules' => [ + [ + 'validation_key' => 'vr_eq', + 'operator' => 'eq', + 'value' => 'alpha', + ], + [ + 'validation_key' => 'vr_contains', + 'operator' => 'contains', + 'value' => 'beta', + ], + ], + ], + ], + ]], + ]; + + $accepted = $validator->validateField($schema, 'nickname', 'beta value'); + $rejected = $validator->validateField($schema, 'nickname', 'gamma value'); + + expect($accepted['valid'])->toBeTrue(); + expect($rejected['valid'])->toBeFalse(); +}); + +it('validates temporal field meta rules on the backend', function (): void { + $validator = app(SubmissionValidator::class); + $schema = [ + 'fields' => [[ + 'name' => 'meeting', + 'field_key' => 'meeting_field_key', + 'type' => 'temporal', + 'temporal_mode' => 'date', + 'required' => true, + 'rules' => ['required'], + 'meta' => [ + 'validation' => [ + 'match' => 'all', + 'rules' => [ + [ + 'validation_key' => 'vr_after', + 'operator' => 'after', + 'value' => '2026-01-01', + ], + [ + 'validation_key' => 'vr_before', + 'operator' => 'before', + 'value' => '2026-12-31', + ], + [ + 'validation_key' => 'vr_between', + 'operator' => 'between', + 'value' => [ + 'start' => '2026-03-01', + 'end' => '2026-09-30', + ], + ], + ], + ], + ], + ]], + ]; + + $valid = $validator->validateField($schema, 'meeting', '2026-06-15'); + $tooEarly = $validator->validateField($schema, 'meeting', '2025-12-31'); + $tooLate = $validator->validateField($schema, 'meeting', '2027-01-01'); + $outsideRange = $validator->validateField($schema, 'meeting', '2026-10-01'); + + expect($valid['valid'])->toBeTrue(); + expect($tooEarly['valid'])->toBeFalse(); + expect($tooLate['valid'])->toBeFalse(); + expect($outsideRange['valid'])->toBeFalse(); +}); + it('describes fields with normalized descriptors', function (): void { $key = 'describe_fields_' . Str::lower(Str::random(8)); Form::define($key) ->version('1') - ->select('country')->required()->options([ + ->radio('country')->required()->options([ ['label' => 'France', 'value' => 'fr'], ['label' => 'Switzerland', 'value' => 'ch'], ]) @@ -275,12 +655,126 @@ expect($nickname['options'])->toBe([]); }); +it('exports flattened exportable fields for simple and address fields', function (): void { + $key = 'exportable_fields_' . Str::lower(Str::random(8)); + + Form::define($key) + ->version('1') + ->text('name')->label('Full name')->required() + ->address('address')->label('Mailing address')->addressFields([ + ['key' => 'line1', 'label' => 'Address line 1', 'visible' => true, 'required' => true], + ['key' => 'line2', 'label' => 'Address line 2', 'visible' => false, 'required' => false], + ['key' => 'city', 'label' => 'City', 'visible' => true, 'required' => true], + ['key' => 'state', 'label' => 'State', 'visible' => false, 'required' => false], + ['key' => 'zip', 'label' => 'Postal code', 'visible' => true, 'required' => true], + ['key' => 'country', 'label' => 'Country', 'visible' => true, 'required' => true], + ]); + + $fields = Form::get($key, '1')->exportableFields(); + $paths = array_column($fields, 'path'); + $name = collect($fields)->firstWhere('path', 'name'); + $addressLine1 = collect($fields)->firstWhere('path', 'address.line1'); + $addressLine2 = collect($fields)->firstWhere('path', 'address.line2'); + + expect($paths)->toBe([ + 'name', + 'address.line1', + 'address.city', + 'address.zip', + 'address.country', + ]); + + expect($name)->not->toBeNull(); + expect($name['label'])->toBe('Full name'); + expect($name['type'])->toBe(FieldType::TEXT); + expect($name['required'])->toBeTrue(); + expect($name['composite'])->toBeFalse(); + + expect($addressLine1)->not->toBeNull(); + expect($addressLine1['id'])->toContain('.line1'); + expect($addressLine1['label'])->toBe('Address line 1'); + expect($addressLine1['type'])->toBe(FieldType::ADDRESS); + expect($addressLine1['required'])->toBeTrue(); + expect($addressLine1['composite'])->toBeTrue(); + expect($addressLine1['subfield'])->toBe([ + 'key' => 'line1', + 'label' => 'Address line 1', + 'required' => true, + 'visible' => true, + ]); + + expect($addressLine2)->toBeNull(); +}); + +it('validates exportable headers and maps rows back to nested payloads', function (): void { + $key = 'exportable_headers_' . Str::lower(Str::random(8)); + + Form::define($key) + ->version('1') + ->text('email')->required() + ->address('address')->addressFields([ + ['key' => 'line1', 'label' => 'Address line 1', 'visible' => true, 'required' => true], + ['key' => 'line2', 'label' => 'Address line 2', 'visible' => false, 'required' => false], + ['key' => 'city', 'label' => 'City', 'visible' => true, 'required' => true], + ['key' => 'state', 'label' => 'State', 'visible' => false, 'required' => false], + ['key' => 'zip', 'label' => 'Postal code', 'visible' => true, 'required' => true], + ['key' => 'country', 'label' => 'Country', 'visible' => true, 'required' => true], + ]); + + $form = Form::get($key, '1'); + + $valid = $form->validateExportableHeaders([ + 'email', + 'address.line1', + 'address.city', + 'address.zip', + 'address.country', + ]); + + expect($valid['valid'])->toBeTrue(); + expect($valid['missing'])->toBe([]); + expect($valid['unknown'])->toBe([]); + + $invalid = $form->validateExportableHeaders([ + 'email', + 'address.line1', + 'address.zip', + 'unexpected', + ]); + + expect($invalid['valid'])->toBeFalse(); + expect($invalid['missing'])->toBe([ + 'address.city', + 'address.country', + ]); + expect($invalid['unknown'])->toBe(['unexpected']); + + $mapped = $form->mapExportableRow([ + 'email' => 'evan@example.com', + 'address.line1' => '1 Route des Acacias', + 'address.city' => 'Geneva', + 'address.zip' => '1200', + 'address.country' => 'CH', + 'unexpected' => 'ignored', + ], false); + + expect($mapped)->toBe([ + 'email' => 'evan@example.com', + 'address' => [ + 'line1' => '1 Route des Acacias', + 'city' => 'Geneva', + 'zip' => '1200', + 'country' => 'CH', + ], + ]); +}); + it('validates partial fields subset and reports canonical errors', function (): void { $key = 'validate_fields_subset_' . Str::lower(Str::random(8)); Form::define($key) ->version('1') - ->email('email')->required() + ->text('email')->required()->rules('email') ->number('age')->required(); $form = Form::get($key, '1'); @@ -311,7 +805,7 @@ 'field_key' => 'email_field_key', 'key' => 'email_key', 'id' => 'email_id', - 'type' => 'email', + 'type' => 'text', 'rules' => ['required', 'email'], ]], ]; @@ -330,7 +824,7 @@ $schema = [ 'fields' => [[ 'name' => 'email', - 'type' => 'email', + 'type' => 'text', 'rules' => ['required', 'email'], ]], ]; @@ -350,7 +844,7 @@ $schema = [ 'fields' => [[ 'name' => 'email', - 'type' => 'email', + 'type' => 'text', 'rules' => ['required', 'email'], ]], ]; @@ -366,7 +860,7 @@ $schema = [ 'fields' => [[ 'name' => 'email', - 'type' => 'email', + 'type' => 'text', 'rules' => ['required', 'email'], ]], ]; @@ -384,7 +878,7 @@ $schema = [ 'fields' => [[ 'name' => 'email', - 'type' => 'email', + 'type' => 'text', 'rules' => ['required', 'email'], ]], ]; @@ -400,20 +894,20 @@ expect((string) ($fallback['errors']['missing_alias'][0] ?? ''))->toBe('Unknown field identifier.'); }); -it('normalizes primitive and date range values before persistence', function (): void { +it('normalizes primitive and temporal values before persistence', function (): void { $key = 'normalize_' . Str::lower(Str::random(8)); Form::define($key) ->version('1') ->number('age')->required() ->number('price')->required() - ->checkbox('newsletter')->required() - ->switch('active')->required() + ->addField(FieldType::CONSENT, 'newsletter')->required() + ->addField(FieldType::CONSENT, 'active')->required() ->date('birthday')->required() ->time('meeting_time')->required() - ->datetime('meeting_at')->required() - ->dateRange('vacation')->required() - ->datetimeRange('window')->required(); + ->temporal('meeting_at', 'time')->required() + ->temporal('vacation', 'date')->required() + ->temporal('window', 'time')->required(); $submission = Form::get($key, '1')->submit([ 'age' => '42', @@ -422,15 +916,9 @@ 'active' => true, 'birthday' => '2026-01-05', 'meeting_time' => '09:10:11', - 'meeting_at' => '2026-01-05T09:10:11+00:00', - 'vacation' => [ - 'start' => '2026-08-01', - 'end' => '2026-08-10', - ], - 'window' => [ - 'start' => '2026-01-05T09:10:11+00:00', - 'end' => '2026-01-05T10:10:11+00:00', - ], + 'meeting_at' => '09:10:11', + 'vacation' => '2026-08-01', + 'window' => '10:10:11', ]); expect($submission->payload['age'])->toBe(42); @@ -439,12 +927,30 @@ expect($submission->payload['active'])->toBeTrue(); expect($submission->payload['birthday'])->toBe('2026-01-05'); expect($submission->payload['meeting_time'])->toBe('09:10:11'); - expect($submission->payload['meeting_at'])->toContain('2026-01-05T09:10:11'); - expect($submission->payload['vacation'])->toBe([ - 'start' => '2026-08-01', - 'end' => '2026-08-10', + expect($submission->payload['meeting_at'])->toBe('09:10:11'); + expect($submission->payload['vacation'])->toBe('2026-08-01'); + expect($submission->payload['window'])->toBe('10:10:11'); +}); + +it('validates date boundaries for date fields', function (): void { + $key = 'date_bounds_' . Str::lower(Str::random(8)); + + Form::define($key) + ->version('1') + ->date('birthday') + ->required() + ->min('2026-01-01') + ->max('2026-12-31'); + + $submission = Form::get($key, '1')->submit([ + 'birthday' => '2026-06-15', ]); - expect($submission->payload['window']['start'])->toContain('2026-01-05T09:10:11'); + + expect($submission->payload['birthday'])->toBe('2026-06-15'); + + expect(static fn () => Form::get($key, '1')->submit([ + 'birthday' => '2025-12-31', + ]))->toThrow(\Illuminate\Validation\ValidationException::class); }); it('stores uploaded files and persists normalized metadata in managed mode', function (): void { @@ -558,7 +1064,7 @@ Form::define($key) ->version('1') - ->email('email')->required() + ->text('email')->required()->rules('email') ->text('plan')->required(); $submission = Form::get($key, '1')->submit([ @@ -602,12 +1108,12 @@ Form::define($firstKey) ->version('1') - ->email('email')->required() + ->text('email')->required()->rules('email') ->text('plan')->required(); Form::define($secondKey) ->version('1') - ->email('email')->required() + ->text('email')->required()->rules('email') ->text('plan')->required(); config()->set('formforge.tests.active_form_key', $firstKey); @@ -879,6 +1385,126 @@ expect($latestB)->toBeNull(); }); +it('resolves the latest active form by uuid across versions', function (): void { + $formUuid = (string) Str::uuid(); + $key = 'uuid_latest_' . Str::lower(Str::random(8)); + + $createDefinition = function ( + string $version, + int $versionNumber, + bool $active = true, + ?string $ownerType = null, + ?string $ownerId = null, + ) use ($formUuid, $key): FormDefinition { + $schema = [ + 'key' => $key, + 'version' => $version, + 'schema_version' => 2, + 'title' => 'UUID latest ' . $version, + 'fields' => [], + ]; + + return FormDefinition::query()->create([ + 'key' => $key, + 'form_uuid' => $formUuid, + 'revision_id' => (string) Str::ulid(), + 'version' => $version, + 'version_number' => $versionNumber, + 'title' => 'UUID latest ' . $version, + 'schema' => $schema, + 'schema_hash' => hash('sha256', json_encode($schema) ?: ''), + 'is_active' => $active, + 'is_published' => true, + 'owner_type' => $ownerType, + 'owner_id' => $ownerId, + ]); + }; + + $createDefinition('1', 1); + $createDefinition('2', 2); + $createDefinition('3', 3); + $createDefinition('3b', 3); + $createDefinition('4', 4, false); + + $latest = Form::latestByUuid($formUuid); + + expect($latest->version())->toBe('3b'); + expect($latest->key())->toBe($key); +}); + +it('resolves the latest active form by uuid through the scoped owner API', function (): void { + $formUuid = (string) Str::uuid(); + $key = 'uuid_scoped_' . Str::lower(Str::random(8)); + $ownerA = ['type' => 'user', 'id' => '101']; + $ownerB = ['type' => 'user', 'id' => '202']; + + $schema = static fn (string $version) => [ + 'key' => $key, + 'version' => $version, + 'schema_version' => 2, + 'title' => 'Scoped UUID ' . $version, + 'fields' => [], + ]; + + FormDefinition::query()->create([ + 'key' => $key, + 'form_uuid' => $formUuid, + 'revision_id' => (string) Str::ulid(), + 'version' => '1', + 'version_number' => 1, + 'title' => 'Scoped UUID 1', + 'schema' => $schema('1'), + 'schema_hash' => hash('sha256', json_encode($schema('1')) ?: ''), + 'is_active' => true, + 'is_published' => true, + 'owner_type' => $ownerA['type'], + 'owner_id' => $ownerA['id'], + ]); + + FormDefinition::query()->create([ + 'key' => $key, + 'form_uuid' => $formUuid, + 'revision_id' => (string) Str::ulid(), + 'version' => '2', + 'version_number' => 2, + 'title' => 'Scoped UUID 2', + 'schema' => $schema('2'), + 'schema_hash' => hash('sha256', json_encode($schema('2')) ?: ''), + 'is_active' => true, + 'is_published' => true, + 'owner_type' => $ownerA['type'], + 'owner_id' => $ownerA['id'], + ]); + + FormDefinition::query()->create([ + 'key' => $key, + 'form_uuid' => $formUuid, + 'revision_id' => (string) Str::ulid(), + 'version' => '3b', + 'version_number' => 3, + 'title' => 'Scoped UUID 3b', + 'schema' => $schema('3b'), + 'schema_hash' => hash('sha256', json_encode($schema('3b')) ?: ''), + 'is_active' => true, + 'is_published' => true, + 'owner_type' => $ownerB['type'], + 'owner_id' => $ownerB['id'], + ]); + + $latestA = Form::for($ownerA)->latestByUuid($formUuid); + $latestB = Form::for($ownerB)->latestByUuid($formUuid); + + expect($latestA->version)->toBe('2'); + expect($latestB->version)->toBe('3b'); +}); + +it('throws when no form exists for a uuid lookup', function (): void { + $missing = (string) Str::uuid(); + + expect(static fn () => Form::latestByUuid($missing)) + ->toThrow(FormNotFoundException::class); +}); + it('exports submissions through facade in csv and jsonl formats', function (): void { $key = 'exports_facade_' . Str::lower(Str::random(8)); @@ -992,7 +1618,7 @@ Form::define($key) ->version('1') ->text('name')->required() - ->email('email')->required(); + ->text('email')->required()->rules('email'); Form::get($key, '1')->submit([ 'name' => 'Sensitive', @@ -1025,7 +1651,7 @@ Form::define($key) ->version('1') ->text('name')->required() - ->email('email')->required(); + ->text('email')->required()->rules('email'); $submission = Form::get($key, '1')->submit([ 'name' => 'One-off', diff --git a/tests/Feature/HttpApiTest.php b/tests/Feature/HttpApiTest.php index 87ac048..4ebe69b 100644 --- a/tests/Feature/HttpApiTest.php +++ b/tests/Feature/HttpApiTest.php @@ -18,6 +18,7 @@ use Illuminate\Routing\RouteCollection; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Carbon; use Illuminate\Support\Str; it('exposes schema endpoints over HTTP', function (): void { @@ -32,7 +33,7 @@ ->title('Schema') ->version('2') ->text('name')->required() - ->email('email')->required(); + ->text('email')->required()->rules('email'); $this->getJson("/api/formforge/v1/forms/{$key}") ->assertOk() @@ -78,7 +79,7 @@ Form::define($key) ->version('1') ->text('name')->required() - ->email('email')->required(); + ->text('email')->required()->rules('email'); $schema = Form::get($key, '1')->toArray(); $nameField = collect($schema['fields'])->firstWhere('name', 'name'); @@ -514,6 +515,91 @@ ->assertJsonPath('data.meta.mode', 'test'); }); +it('blocks live submission outside the configured availability window', function (): void { + config()->set('formforge.http.submission.require_published', false); + + $futureCreate = $this->postJson('/api/formforge/v1/forms', [ + 'title' => 'Future', + 'publish_at' => Carbon::now()->addDay()->toIso8601String(), + 'fields' => [ + ['type' => 'text', 'name' => 'name', 'required' => true], + ], + 'pages' => [], + 'conditions' => [], + ])->assertCreated(); + + $futureKey = (string) $futureCreate->json('data.key'); + + $this->postJson("/api/formforge/v1/forms/{$futureKey}/submit", [ + 'name' => 'Future', + ])->assertStatus(422) + ->assertJsonValidationErrors('form'); + + $pausedCreate = $this->postJson('/api/formforge/v1/forms', [ + 'title' => 'Paused', + 'publish_at' => Carbon::now()->subDay()->toIso8601String(), + 'pause_at' => Carbon::now()->subMinute()->toIso8601String(), + 'fields' => [ + ['type' => 'text', 'name' => 'name', 'required' => true], + ], + 'pages' => [], + 'conditions' => [], + ])->assertCreated(); + + $pausedKey = (string) $pausedCreate->json('data.key'); + + $this->postJson("/api/formforge/v1/forms/{$pausedKey}/submit", [ + 'name' => 'Paused', + ])->assertStatus(422) + ->assertJsonValidationErrors('form'); +}); + +it('requires the submission code and closes after the response limit', function (): void { + config()->set('formforge.http.submission.require_published', false); + + $create = $this->postJson('/api/formforge/v1/forms', [ + 'title' => 'Protected', + 'submission_code' => 'open-sesame', + 'response_limit' => 1, + 'fields' => [ + ['type' => 'text', 'name' => 'name', 'required' => true], + ], + 'pages' => [], + 'conditions' => [], + ])->assertCreated(); + + $key = (string) $create->json('data.key'); + + $this->postJson("/api/formforge/v1/forms/{$key}/submit", [ + 'name' => 'Denied', + ])->assertStatus(422) + ->assertJsonValidationErrors('form'); + + $this->postJson("/api/formforge/v1/forms/{$key}/submit", [ + 'name' => 'Denied', + 'meta' => [ + 'submission_code' => 'wrong-code', + ], + ])->assertStatus(422) + ->assertJsonValidationErrors('form'); + + $this->postJson("/api/formforge/v1/forms/{$key}/submit", [ + 'name' => 'Allowed', + 'meta' => [ + 'submission_code' => 'open-sesame', + ], + ])->assertCreated() + ->assertJsonPath('data.meta.submission_code_present', true); + + $this->postJson("/api/formforge/v1/forms/{$key}/submit", [ + 'name' => 'Closed', + 'meta' => [ + 'submission_code' => 'open-sesame', + ], + ])->assertStatus(422) + ->assertJsonValidationErrors('form'); +}); + it('supports staged upload then JSON submit with upload_token', function (): void { Storage::fake('local'); @@ -613,6 +699,36 @@ ->assertJsonPath('data.title', 'Contact Form'); }); +it('locks lifecycle settings when the form api declares settings locked', function (): void { + $response = $this->postJson('/api/formforge/v1/forms', [ + 'title' => 'System Form', + 'publish_at' => '2026-01-01T08:00:00Z', + 'pause_at' => '2026-01-31T18:00:00Z', + 'response_limit' => 25, + 'submission_code_required' => true, + 'submission_code' => '1234', + 'api' => [ + 'formforge' => [ + 'settings_locked' => true, + ], + ], + 'fields' => [ + [ + 'type' => 'text', + 'name' => 'name', + ], + ], + ]); + + $response + ->assertCreated() + ->assertJsonPath('data.schema.publish_at', null) + ->assertJsonPath('data.schema.pause_at', null) + ->assertJsonPath('data.schema.response_limit', null) + ->assertJsonPath('data.schema.submission_code_required', false) + ->assertJsonPath('data.schema.api.formforge.settings_locked', true); +}); + it('lists forms through paginated management endpoint', function (): void { $surveyCategory = $this->postJson('/api/formforge/v1/categories', [ 'name' => 'Survey', @@ -633,13 +749,13 @@ $this->postJson('/api/formforge/v1/forms', [ 'title' => 'Form B', - 'fields' => [['type' => 'email', 'name' => 'email']], + 'fields' => [['type' => 'text', 'name' => 'email']], 'category' => $contactCategoryKey, ])->assertCreated(); $this->postJson('/api/formforge/v1/forms', [ 'title' => 'Form C', - 'fields' => [['type' => 'textarea', 'name' => 'message']], + 'fields' => [['type' => 'text', 'name' => 'message']], 'category' => $surveyCategoryKey, ])->assertCreated(); @@ -700,7 +816,7 @@ $contactCreate = $this->postJson('/api/formforge/v1/forms', [ 'title' => 'Contact Form', - 'fields' => [['type' => 'email', 'name' => 'email']], + 'fields' => [['type' => 'text', 'name' => 'email']], 'category' => $contactCategoryKey, ])->assertCreated(); @@ -913,7 +1029,7 @@ Form::define($key) ->version('1') ->text('name')->required() - ->email('email')->required(); + ->text('email')->required()->rules('email'); $this->postJson("/api/formforge/v1/forms/{$key}/submit", [ 'name' => 'A', @@ -1071,7 +1187,7 @@ Form::define($key) ->version('1') ->text('name')->required() - ->email('email')->required(); + ->text('email')->required()->rules('email'); $this->postJson("/api/formforge/v1/forms/{$key}/submit", [ 'name' => 'Policy User', @@ -1111,7 +1227,7 @@ Form::define($key) ->version('1') ->text('name')->required() - ->email('email')->required(); + ->text('email')->required()->rules('email'); $submit = $this->postJson("/api/formforge/v1/forms/{$key}/submit", [ 'name' => 'Target User', @@ -1151,6 +1267,21 @@ ->assertJsonPath('data.schema.pages.0.fields.0.name', 'short_text'); }); +it('exposes a generated public url on management responses', function (): void { + config()->set('formforge.http.public_link.base_url', 'https://forms.example.test'); + + $response = $this->postJson('/api/formforge/v1/forms', [ + 'title' => 'Public Link Survey', + 'fields' => [ + ['type' => 'text', 'name' => 'name', 'required' => true], + ], + ]); + + $response + ->assertCreated() + ->assertJsonPath('data.public_url', 'https://forms.example.test/api/formforge/v1/forms/' . $response->json('data.key')); +}); + it('patches a form and creates a new draft revision', function (): void { $create = $this->postJson('/api/formforge/v1/forms', [ 'title' => 'Survey V1', @@ -1165,7 +1296,7 @@ 'title' => 'Survey V2', 'fields' => [ ['type' => 'text', 'name' => 'name', 'required' => true], - ['type' => 'email', 'name' => 'email', 'required' => true], + ['type' => 'text', 'name' => 'email', 'required' => true], ], ]); @@ -1210,7 +1341,7 @@ 'title' => 'Auto Published Patch V2', 'fields' => [ ['type' => 'text', 'name' => 'name', 'required' => true], - ['type' => 'email', 'name' => 'email', 'required' => true], + ['type' => 'text', 'name' => 'email', 'required' => true], ], 'auto_publish' => true, ]); @@ -1508,7 +1639,7 @@ $this->patchJson("/api/formforge/v1/forms/{$key}", [ 'fields' => [ ['type' => 'text', 'name' => 'name', 'required' => true], - ['type' => 'email', 'name' => 'email', 'required' => true], + ['type' => 'text', 'name' => 'email', 'required' => true], ], ])->assertOk(); @@ -1528,7 +1659,7 @@ 'fields' => [ [ 'field_key' => 'fk_has_company', - 'type' => 'checkbox', + 'type' => 'consent', 'name' => 'has_company', ], [ @@ -1575,6 +1706,168 @@ ->assertJsonPath('data.schema.debug.conditions.0.matched', true); }); +it('resolves and enforces block logic require actions over HTTP', function (): void { + $create = $this->postJson('/api/formforge/v1/forms', [ + 'title' => 'Resolve Block Logic', + 'pages' => [ + [ + 'page_key' => 'pg_intro', + 'title' => 'Intro', + 'meta' => [ + 'logic' => [ + 'version' => 1, + 'rules' => [ + [ + 'rule_key' => 'lr_require_company', + 'match' => 'all', + 'when' => [ + [ + 'field_key' => 'fk_has_company', + 'operator' => 'accepted', + 'value' => null, + ], + ], + 'then' => [ + [ + 'action' => 'require', + 'field_key' => 'fk_company_name', + 'block_index' => 2, + ], + ], + 'fallback' => [ + 'action' => 'next', + 'block_index' => null, + ], + ], + ], + ], + ], + 'fields' => [ + [ + 'field_key' => 'fk_has_company', + 'type' => 'consent', + 'name' => 'has_company', + ], + ], + ], + [ + 'page_key' => 'pg_company', + 'title' => 'Company', + 'fields' => [ + [ + 'field_key' => 'fk_company_name', + 'type' => 'text', + 'name' => 'company_name', + ], + ], + ], + ], + 'drafts' => [ + 'enabled' => true, + ], + ])->assertCreated(); + + $key = (string) $create->json('data.key'); + + $this->postJson("/api/formforge/v1/forms/{$key}/resolve", [ + 'payload' => [ + 'has_company' => true, + ], + 'debug' => true, + ]) + ->assertOk() + ->assertJsonPath('data.schema.pages.1.fields.0.required', true) + ->assertJsonPath('data.schema.debug.page_logic.0.matched', true); + + $this->postJson("/api/formforge/v1/forms/{$key}/submit", [ + 'has_company' => true, + ])->assertStatus(422); +}); + +it('supports address submission checks in block logic over HTTP', function (): void { + $create = $this->postJson('/api/formforge/v1/forms', [ + 'title' => 'Resolve Address Block Logic', + 'pages' => [ + [ + 'page_key' => 'pg_intro', + 'title' => 'Intro', + 'meta' => [ + 'logic' => [ + 'version' => 1, + 'rules' => [ + [ + 'rule_key' => 'lr_require_company', + 'match' => 'all', + 'when' => [ + [ + 'field_key' => 'fk_address', + 'operator' => 'is_not_submitted', + 'value' => null, + ], + ], + 'then' => [ + [ + 'action' => 'require', + 'field_key' => 'fk_company_name', + 'block_index' => 2, + ], + ], + 'fallback' => [ + 'action' => 'next', + 'block_index' => null, + ], + ], + ], + ], + ], + 'fields' => [ + [ + 'field_key' => 'fk_address', + 'type' => 'address', + 'name' => 'address', + ], + ], + ], + [ + 'page_key' => 'pg_company', + 'title' => 'Company', + 'fields' => [ + [ + 'field_key' => 'fk_company_name', + 'type' => 'text', + 'name' => 'company_name', + ], + ], + ], + ], + 'drafts' => [ + 'enabled' => true, + ], + ])->assertCreated(); + + $key = (string) $create->json('data.key'); + + $this->postJson("/api/formforge/v1/forms/{$key}/resolve", [ + 'payload' => [], + 'debug' => true, + ]) + ->assertOk() + ->assertJsonPath('data.schema.pages.1.fields.0.required', true) + ->assertJsonPath('data.schema.debug.page_logic.0.matched', true); + + $this->postJson("/api/formforge/v1/forms/{$key}/resolve", [ + 'payload' => [ + 'address' => [ + 'line1' => '1 Route des Acacias', + ], + ], + 'debug' => true, + ]) + ->assertOk() + ->assertJsonPath('data.schema.pages.1.fields.0.required', false) + ->assertJsonPath('data.schema.debug.page_logic.0.matched', false); +}); + it('returns not found on resolve endpoint when environment is not allowed', function (): void { config()->set('formforge.http.resolve.enabled_environments', ['local']); @@ -1594,11 +1887,11 @@ Form::define($key) ->version('1') - ->email('email')->required(); + ->text('email')->required()->rules('email'); Form::define($key) ->version('2') - ->email('email')->required(); + ->text('email')->required()->rules('email'); $this->postJson("/api/formforge/v1/forms/{$key}/validate-field", [ 'field' => 'email', @@ -1625,7 +1918,7 @@ Form::define($key) ->version('1') ->text('name') - ->email('email'); + ->text('email')->rules('email'); $user = User::query()->create([ 'name' => 'Draft User', diff --git a/tests/TestCase.php b/tests/TestCase.php index caea850..dfabce8 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -7,6 +7,7 @@ use EvanSchleret\FormForge\FormForgeServiceProvider; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; +use Mews\Purifier\PurifierServiceProvider; use Orchestra\Testbench\TestCase as Orchestra; abstract class TestCase extends Orchestra @@ -15,6 +16,7 @@ protected function getPackageProviders($app): array { return [ FormForgeServiceProvider::class, + PurifierServiceProvider::class, ]; }