diff --git a/app/Http/Requests/AnswerFormRequest.php b/app/Http/Requests/AnswerFormRequest.php index 4057c9bc5..8bfc10ee9 100644 --- a/app/Http/Requests/AnswerFormRequest.php +++ b/app/Http/Requests/AnswerFormRequest.php @@ -4,6 +4,7 @@ use App\Models\Forms\Form; use App\Rules\CustomFieldValidationRule; +use App\Rules\MatrixValidationRule; use App\Rules\StorageFile; use App\Rules\ValidHCaptcha; use App\Rules\ValidPhoneInputRule; @@ -82,9 +83,14 @@ public function rules() } elseif ($property['type'] == 'rating') { // For star rating, needs a minimum of 1 star $rules[] = 'min:1'; + } elseif ($property['type'] == 'matrix') { + $rules[] = new MatrixValidationRule($property, true); } } else { $rules[] = 'nullable'; + if ($property['type'] == 'matrix') { + $rules[] = new MatrixValidationRule($property, false); + } } // Clean id to escape "." @@ -97,7 +103,7 @@ public function rules() } // User custom validation - if(!(Str::of($property['type'])->startsWith('nf-')) && isset($property['validation'])) { + if (!(Str::of($property['type'])->startsWith('nf-')) && isset($property['validation'])) { $rules[] = (new CustomFieldValidationRule($property['validation'], $data)); } diff --git a/app/Rules/FormPropertyLogicRule.php b/app/Rules/FormPropertyLogicRule.php index 795171a4d..cfaee33bb 100644 --- a/app/Rules/FormPropertyLogicRule.php +++ b/app/Rules/FormPropertyLogicRule.php @@ -73,6 +73,34 @@ class FormPropertyLogicRule implements DataAwareRule, ValidationRule ], ], ], + 'matrix' => [ + 'comparators' => [ + 'equals' => [ + 'expected_type' => 'object', + 'format' => [ + 'type' => 'object', + ], + ], + 'does_not_equal' => [ + 'expected_type' => 'object', + 'format' => [ + 'type' => 'object', + ], + ], + 'contains' => [ + 'expected_type' => 'object', + 'format' => [ + 'type' => 'object', + ], + ], + 'does_not_contain' => [ + 'expected_type' => 'object', + 'format' => [ + 'type' => 'object', + ], + ], + ], + ], 'url' => [ 'comparators' => [ 'equals' => [ diff --git a/app/Rules/MatrixValidationRule.php b/app/Rules/MatrixValidationRule.php new file mode 100644 index 000000000..f50c1238b --- /dev/null +++ b/app/Rules/MatrixValidationRule.php @@ -0,0 +1,61 @@ +field = $field; + $this->isRequired = $isRequired; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (!$this->isRequired && empty($value)) { + return; // If not required and empty, validation passes + } + + if (!is_array($value)) { + $fail('The Matrix field must be an array.'); + return; + } + + $rows = $this->field['rows']; + $columns = $this->field['columns']; + + foreach ($rows as $row) { + if (!array_key_exists($row, $value)) { + if ($this->isRequired) { + $fail("Missing value for row '{$row}'."); + } + continue; + } + + $cellValue = $value[$row]; + + if ($cellValue === null) { + if ($this->isRequired) { + $fail("Value for row '{$row}' is required."); + } + continue; + } + + if (!in_array($cellValue, $columns)) { + $fail("Invalid value '{$cellValue}' for row '{$row}'."); + } + } + + // Check for extra rows that shouldn't be there + $extraRows = array_diff(array_keys($value), $rows); + foreach ($extraRows as $extraRow) { + $fail("Unexpected row '{$extraRow}' in the matrix."); + } + } +} diff --git a/app/Service/Forms/FormLogicConditionChecker.php b/app/Service/Forms/FormLogicConditionChecker.php index 11f2794d7..44e347b46 100644 --- a/app/Service/Forms/FormLogicConditionChecker.php +++ b/app/Service/Forms/FormLogicConditionChecker.php @@ -72,6 +72,8 @@ private function propertyConditionMet(array $propertyCondition, $value): bool return $this->multiSelectConditionMet($propertyCondition, $value); case 'files': return $this->filesConditionMet($propertyCondition, $value); + case 'matrix': + return $this->matrixConditionMet($propertyCondition, $value); } return false; @@ -90,6 +92,30 @@ private function checkContains($condition, $fieldValue): bool return \Str::contains($fieldValue, $condition['value']); } + private function checkMatrixContains($condition, $fieldValue): bool + { + + foreach($condition['value'] as $key => $value) { + if(!(array_key_exists($key, $condition['value']) && array_key_exists($key, $fieldValue))) { + return false; + } + if($condition['value'][$key] == $fieldValue[$key]) { + return true; + } + } + return false; + } + + private function checkMatrixEquals($condition, $fieldValue): bool + { + foreach($condition['value'] as $key => $value) { + if($condition['value'][$key] !== $fieldValue[$key]) { + return false; + } + } + return true; + } + private function checkListContains($condition, $fieldValue): bool { if (is_null($fieldValue)) { @@ -408,4 +434,20 @@ private function filesConditionMet(array $propertyCondition, $value): bool return false; } + + private function matrixConditionMet(array $propertyCondition, $value): bool + { + switch ($propertyCondition['operator']) { + case 'equals': + return $this->checkMatrixEquals($propertyCondition, $value); + case 'does_not_equal': + return !$this->checkMatrixEquals($propertyCondition, $value); + case 'contains': + return $this->checkMatrixContains($propertyCondition, $value); + case 'does_not_contain': + return !$this->checkMatrixContains($propertyCondition, $value); + } + + return false; + } } diff --git a/app/Service/Forms/FormSubmissionFormatter.php b/app/Service/Forms/FormSubmissionFormatter.php index b21317704..5b123c854 100644 --- a/app/Service/Forms/FormSubmissionFormatter.php +++ b/app/Service/Forms/FormSubmissionFormatter.php @@ -81,6 +81,17 @@ public function useSignedUrlForFiles() return $this; } + public function getMatrixString(array $val): string + { + $parts = []; + foreach ($val as $key => $value) { + if ($key !== null && $value !== null) { + $parts[] = "$key: $value"; + } + } + return implode(' | ', $parts); + } + /** * Return a nice "FieldName": "Field Response" array * - If createLink enabled, returns html link for emails and links @@ -145,7 +156,9 @@ public function getCleanKeyValue() } else { $returnArray[$field['name']] = $val; } - } elseif (in_array($field['type'], ['files', 'signature'])) { + } elseif ($field['type'] == 'matrix' && is_array($data[$field['id']])) { + $returnArray[$field['name']] = $this->getMatrixString($data[$field['id']]); + } elseif ($field['type'] == 'files') { if ($this->outputStringsOnly) { $formId = $this->form->id; $returnArray[$field['name']] = implode( @@ -219,7 +232,9 @@ public function getFieldsWithValue() } else { $field['value'] = $val; } - } elseif (in_array($field['type'], ['files', 'signature'])) { + } elseif ($field['type'] == 'matrix') { + $field['value'] = str_replace(' | ', "\n", $this->getMatrixString($data[$field['id']])); + } elseif ($field['type'] == 'files') { if ($this->outputStringsOnly) { $formId = $this->form->id; $field['value'] = implode( diff --git a/client/components/forms/FlatSelectInput.vue b/client/components/forms/FlatSelectInput.vue index a417bee20..7f6b4c414 100644 --- a/client/components/forms/FlatSelectInput.vue +++ b/client/components/forms/FlatSelectInput.vue @@ -16,60 +16,45 @@ theme.default.input, theme.default.borderRadius, { - 'mb-2': index !== options.length, '!ring-red-500 !ring-2 !border-transparent': hasError, '!cursor-not-allowed !bg-gray-200': disabled, }, ]" > - +
+ + +

+ {{ option[displayKey] }} +

+
+
+ \ No newline at end of file diff --git a/client/components/forms/MatrixInput.vue b/client/components/forms/MatrixInput.vue new file mode 100644 index 000000000..25cdad39f --- /dev/null +++ b/client/components/forms/MatrixInput.vue @@ -0,0 +1,121 @@ + + \ No newline at end of file diff --git a/client/components/forms/components/CheckboxIcon.vue b/client/components/forms/components/CheckboxIcon.vue new file mode 100644 index 000000000..2c80db4e0 --- /dev/null +++ b/client/components/forms/components/CheckboxIcon.vue @@ -0,0 +1,27 @@ + + + \ No newline at end of file diff --git a/client/components/forms/components/RadioButtonIcon.vue b/client/components/forms/components/RadioButtonIcon.vue new file mode 100644 index 000000000..157971945 --- /dev/null +++ b/client/components/forms/components/RadioButtonIcon.vue @@ -0,0 +1,29 @@ + + + \ No newline at end of file diff --git a/client/components/open/forms/OpenForm.vue b/client/components/open/forms/OpenForm.vue index e20cbe458..f2ef896f7 100644 --- a/client/components/open/forms/OpenForm.vue +++ b/client/components/open/forms/OpenForm.vue @@ -448,6 +448,8 @@ export default { formData[field.id] = urlPrefill.getAll(field.id + '[]') } else if (field.type === 'date' && field.prefill_today === true) { // For Prefill with 'today' formData[field.id] = new Date().toISOString() + } else if (field.type === 'matrix') { + formData[field.id] = {...field.prefill} } else { // Default prefill if any formData[field.id] = field.prefill } diff --git a/client/components/open/forms/OpenFormField.vue b/client/components/open/forms/OpenFormField.vue index 67ea37464..5022df793 100644 --- a/client/components/open/forms/OpenFormField.vue +++ b/client/components/open/forms/OpenFormField.vue @@ -6,7 +6,7 @@ :class="[ getFieldWidthClasses(field), { - 'group/nffield hover:bg-gray-100/50 relative hover:z-10 transition-colors hover:border-gray-200 dark:hover:bg-gray-900 border-dashed border border-transparent box-border dark:hover:border-blue-900 rounded-md':adminPreview, + 'group/nffield hover:bg-gray-100/50 relative hover:z-10 transition-colors hover:border-gray-200 dark:hover:!bg-gray-900 border-dashed border border-transparent box-border dark:hover:border-blue-900 rounded-md':adminPreview, 'bg-blue-50 hover:!bg-blue-50 dark:bg-gray-800 rounded-md': beingEdited, }]" > @@ -95,8 +95,12 @@ >
-