ソースを参照

fixing frontend and backend validator not beeing aligned

Medowar 4 日 前
コミット
eb7766d692
6 ファイル変更197 行追加16 行削除
  1. 32 1
      api/submit.php
  2. 19 0
      assets/css/base.css
  3. 125 11
      assets/js/form.js
  4. 1 1
      config/app.sample.php
  5. 6 2
      index.php
  6. 14 1
      src/form/validator.php

+ 32 - 1
api/submit.php

@@ -49,6 +49,32 @@ function isMinorBirthdate(string $birthdate): bool
     return $date->diff($today)->y < 18;
 }
 
+/**
+ * @param array<string, string> $errors
+ * @return array<int, array{key: string, label: string, message: string}>
+ */
+function buildValidationErrorDetails(array $errors, FormSchema $schema): array
+{
+    $details = [];
+    $allFields = $schema->getAllFields();
+
+    foreach ($errors as $key => $message) {
+        $field = $allFields[$key] ?? [];
+        $label = '';
+        if (is_array($field)) {
+            $label = trim((string) ($field['label'] ?? ''));
+        }
+
+        $details[] = [
+            'key' => (string) $key,
+            'label' => $label !== '' ? $label : (string) $key,
+            'message' => (string) $message,
+        ];
+    }
+
+    return $details;
+}
+
 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
     Bootstrap::jsonResponse([
         'ok' => false,
@@ -153,14 +179,17 @@ try {
             $store->saveDraft($email, [
                 'step' => 4,
                 'form_data' => $mergedFormData,
-                'uploads' => $uploadResult['uploads'],
+                'uploads' => $mergedUploads,
             ]);
 
+            $errorFields = array_values(array_map(static fn ($key): string => (string) $key, array_keys($errors)));
             return [
                 'ok' => false,
                 'already_submitted' => false,
                 'message' => Bootstrap::appMessage('submit.validation_error'),
                 'errors' => $errors,
+                'error_fields' => $errorFields,
+                'error_details' => buildValidationErrorDetails($errors, $schema),
             ];
         }
 
@@ -191,6 +220,8 @@ if (($submitResult['ok'] ?? false) !== true) {
         'already_submitted' => (bool) ($submitResult['already_submitted'] ?? false),
         'message' => (string) ($submitResult['message'] ?? Bootstrap::appMessage('submit.failure')),
         'errors' => $submitResult['errors'] ?? [],
+        'error_fields' => $submitResult['error_fields'] ?? [],
+        'error_details' => $submitResult['error_details'] ?? [],
     ], $status);
 }
 

+ 19 - 0
assets/css/base.css

@@ -712,6 +712,11 @@ small {
   background: transparent;
 }
 
+.summary-item-invalid {
+  border: 0;
+  background: transparent;
+}
+
 .summary-item-label {
   font-weight: 600;
   margin-bottom: 0.5rem;
@@ -756,6 +761,15 @@ small {
   background: rgba(193, 18, 31, 0.1);
 }
 
+.summary-item-invalid .summary-item-label {
+  color: #ffd3d7;
+}
+
+.summary-item-invalid .summary-item-value {
+  border-color: rgba(193, 18, 31, 0.75);
+  background: rgba(193, 18, 31, 0.1);
+}
+
 .summary-badge {
   display: inline-block;
   margin-left: 0.4rem;
@@ -780,6 +794,11 @@ small {
   color: #ffd3d7;
 }
 
+.summary-badge-invalid {
+  background: rgba(193, 18, 31, 0.24);
+  color: #ffd3d7;
+}
+
 .btn-spinner {
   display: inline-block;
   width: 14px;

+ 125 - 11
assets/js/form.js

@@ -20,6 +20,8 @@
     uploads: {},
     isSubmitting: false,
     summaryMissingCount: 0,
+    summaryInvalidCount: 0,
+    summaryValidationErrors: {},
   };
 
   const disclaimerSection = document.getElementById('disclaimerSection');
@@ -460,6 +462,8 @@
     renderUploadInfo({});
     state.currentStep = 1;
     state.summaryMissingCount = 0;
+    state.summaryInvalidCount = 0;
+    state.summaryValidationErrors = {};
     state.isSubmitting = false;
     if (submitLabel) {
       submitLabel.textContent = 'Verbindlich absenden';
@@ -976,19 +980,92 @@
 
     const type = String(field.type || 'text');
     if (type === 'file') {
-      const uploadItems = state.uploads[key];
-      return !Array.isArray(uploadItems) || uploadItems.length === 0;
+      return !hasFileValue(key);
     }
 
+    return isFormValueEmpty(formData[key], type, field);
+  }
+
+  function isFormValueEmpty(value, type, field) {
     if (type === 'checkbox') {
-      return !isCheckboxTrue(formData[key]);
+      return !isCheckboxTrue(value);
     }
 
     if (type === 'table') {
-      return isTableCsvEmpty(formData[key], field);
+      return isTableCsvEmpty(value, field);
     }
 
-    return String(formData[key] || '').trim() === '';
+    return String(value || '').trim() === '';
+  }
+
+  function validationStringLength(value) {
+    return Array.from(String(value || '')).length;
+  }
+
+  function validateFormData(formData) {
+    const errors = {};
+
+    fieldsByKey.forEach((field, key) => {
+      const type = String((field && field.type) || 'text');
+      if (!isFieldVisible(field, formData)) {
+        return;
+      }
+
+      const required = isFieldRequired(field, formData);
+      if (type === 'file') {
+        if (required && !hasFileValue(key)) {
+          errors[key] = 'Dieses Upload-Feld ist erforderlich.';
+        }
+        return;
+      }
+
+      const value = formData[key];
+      const emptyValue = isFormValueEmpty(value, type, field);
+      if (required && emptyValue) {
+        errors[key] = 'Dieses Feld ist erforderlich.';
+        return;
+      }
+
+      if (emptyValue) {
+        return;
+      }
+
+      const scalarValue = String(value || '').trim();
+
+      if (type === 'email' && !isValidEmail(scalarValue)) {
+        errors[key] = 'Bitte eine gültige E-Mail-Adresse eingeben.';
+        return;
+      }
+
+      if (type === 'select' && Array.isArray(field.options)) {
+        const selected = scalarValue;
+        let validSelection = false;
+
+        field.options.forEach((option) => {
+          if (validSelection || !option || typeof option !== 'object') {
+            return;
+          }
+
+          if (String(option.value || '') !== selected) {
+            return;
+          }
+
+          validSelection = isOptionVisible(option, formData);
+        });
+
+        if (!validSelection) {
+          errors[key] = 'Ungültige Auswahl.';
+          return;
+        }
+      }
+
+      const maxLength = Number(field.max_length);
+      if (Number.isInteger(maxLength) && maxLength > 0 && validationStringLength(scalarValue) > maxLength) {
+        errors[key] = 'Eingabe ist zu lang.';
+      }
+    });
+
+    return errors;
   }
 
   function resolveSelectLabel(field, value) {
@@ -1051,14 +1128,27 @@
     return rawValue;
   }
 
-  function renderSummary() {
+  function renderSummary(serverErrors) {
     if (!summarySection || !summaryContent || !summaryMissingNotice) {
       return;
     }
 
     const formData = collectCurrentFormData();
+    const clientErrors = validateFormData(formData);
+    const mergedErrors = { ...clientErrors };
+    if (serverErrors && typeof serverErrors === 'object') {
+      Object.keys(serverErrors).forEach((key) => {
+        const message = String(serverErrors[key] || '').trim();
+        if (message !== '') {
+          mergedErrors[key] = message;
+        }
+      });
+    }
+    state.summaryValidationErrors = mergedErrors;
+
     const fragment = document.createDocumentFragment();
     let missingCount = 0;
+    let invalidCount = 0;
 
     const introCard = document.createElement('div');
     introCard.className = 'summary-step-card';
@@ -1096,9 +1186,15 @@
       fields.forEach((field) => {
         const required = isFieldRequired(field, formData);
         const missing = isFieldMissing(field, formData);
+        const fieldKey = String(field.key || '').trim();
+        const fieldError = String(mergedErrors[fieldKey] || '').trim();
+        const invalid = fieldError !== '';
         if (missing) {
           missingCount += 1;
         }
+        if (invalid) {
+          invalidCount += 1;
+        }
 
         const row = document.createElement('div');
         row.className = 'field summary-item';
@@ -1108,6 +1204,9 @@
         if (missing) {
           row.classList.add('summary-item-missing');
         }
+        if (invalid) {
+          row.classList.add('summary-item-invalid');
+        }
 
         const labelEl = document.createElement('label');
         labelEl.className = 'summary-item-label';
@@ -1125,6 +1224,11 @@
           missingBadge.className = 'summary-badge summary-badge-missing';
           missingBadge.textContent = '! Pflichtfeld fehlt';
           labelEl.appendChild(missingBadge);
+        } else if (invalid) {
+          const invalidBadge = document.createElement('span');
+          invalidBadge.className = 'summary-badge summary-badge-invalid';
+          invalidBadge.textContent = '! Ungültiger Wert';
+          labelEl.appendChild(invalidBadge);
         }
 
         const valueEl = document.createElement('div');
@@ -1145,6 +1249,12 @@
 
         row.appendChild(labelEl);
         row.appendChild(valueEl);
+        if (invalid) {
+          const errorEl = document.createElement('div');
+          errorEl.className = 'error';
+          errorEl.textContent = fieldError;
+          row.appendChild(errorEl);
+        }
         card.appendChild(row);
       });
 
@@ -1155,14 +1265,15 @@
     summaryContent.appendChild(fragment);
 
     state.summaryMissingCount = missingCount;
-    if (missingCount > 0) {
+    state.summaryInvalidCount = invalidCount;
+    if (invalidCount > 0) {
       summaryMissingNotice.classList.remove('hidden');
       summaryMissingNotice.classList.add('summary-missing-warning');
-      summaryMissingNotice.textContent = '! Es fehlen noch ' + String(missingCount) + ' Pflichtfelder. Bitte korrigieren Sie die rot markierten Einträge.';
+      summaryMissingNotice.textContent = '! Es gibt noch ' + String(invalidCount) + ' ungültige oder fehlende Felder. Bitte korrigieren Sie die rot markierten Einträge.';
     } else {
       summaryMissingNotice.classList.remove('hidden');
       summaryMissingNotice.classList.remove('summary-missing-warning');
-      summaryMissingNotice.textContent = 'Alle Pflichtfelder sind ausgefüllt.';
+      summaryMissingNotice.textContent = 'Alle Pflichtfelder sind ausgefüllt und die Angaben sind gültig.';
     }
   }
 
@@ -1963,9 +2074,11 @@
       return;
     }
 
+    clearErrors();
     renderSummary();
-    if (state.summaryMissingCount > 0) {
-      setFeedback('Bitte zuerst alle rot markierten Pflichtfelder ausfüllen.', true);
+    if (state.summaryInvalidCount > 0) {
+      showErrors(state.summaryValidationErrors);
+      setFeedback('Bitte zuerst alle rot markierten Pflichtfelder und ungültigen Angaben korrigieren.', true);
       return;
     }
 
@@ -1980,6 +2093,7 @@
       }
       if (payload.errors) {
         showErrors(payload.errors);
+        renderSummary(payload.errors);
       }
       const msg = payload.message || err.message || 'Absenden fehlgeschlagen.';
       setFeedback(msg, true);

+ 1 - 1
config/app.sample.php

@@ -78,7 +78,7 @@ return [
         'submit' => [
             'already_submitted' => 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
             'upload_error' => 'Fehler bei Uploads.',
-            'validation_error' => 'Bitte Pflichtfelder prüfen. Nicht alle Pflichtfeler sind ausgefüllt oder ungültige Werte vorhanden.',
+            'validation_error' => 'Bitte Pflichtfelder prüfen. Nicht alle Pflichtfelder sind ausgefüllt oder ungültige Werte vorhanden.',
             'lock_error' => 'Abschluss derzeit nicht möglich. Debug-Info: Lock konnte nicht gesetzt werden.',
             'failure' => 'Abschluss fehlgeschlagen.',
             'success' => 'Ihr Antrag wurde erfolgreich empfangen. Bei Fragen kontaktieren Sie %contact_email%.',

+ 6 - 2
index.php

@@ -52,6 +52,10 @@ function renderField(array $field, string $addressDisclaimerText): void
     $requiredAlways = (bool) ($field['required'] ?? false);
     $requiredConditional = isset($field['required_if']) && is_array($field['required_if']);
     $required = $requiredAlways ? 'required' : '';
+    $maxLengthAttr = '';
+    if (isset($field['max_length']) && is_int($field['max_length']) && $field['max_length'] > 0) {
+        $maxLengthAttr = ' maxlength="' . (string) $field['max_length'] . '"';
+    }
     $requiredLabel = '';
     if ($requiredAlways) {
         $requiredLabel = ' <span class="required-mark required-mark-field" aria-hidden="true">* Pflichtfeld</span>';
@@ -75,7 +79,7 @@ function renderField(array $field, string $addressDisclaimerText): void
         echo '<label for="' . $labelFor . '">' . $label . $requiredLabel . '</label>';
 
         if ($type === 'textarea') {
-            echo '<textarea id="' . $key . '" name="form_data[' . $key . ']" ' . $required . '></textarea>';
+            echo '<textarea id="' . $key . '" name="form_data[' . $key . ']" ' . $required . $maxLengthAttr . '></textarea>';
         } elseif ($type === 'select') {
             echo '<select id="' . $key . '" name="form_data[' . $key . ']" ' . $required . '>';
             echo '<option value="">Bitte wählen</option>';
@@ -179,7 +183,7 @@ function renderField(array $field, string $addressDisclaimerText): void
             echo '<div class="upload-list" data-upload-list="' . $key . '"></div>';
         } else {
             $inputType = htmlspecialchars($type);
-            echo '<input id="' . $key . '" type="' . $inputType . '" name="form_data[' . $key . ']" ' . $required . '>';
+            echo '<input id="' . $key . '" type="' . $inputType . '" name="form_data[' . $key . ']" ' . $required . $maxLengthAttr . '>';
         }
     }
 

+ 14 - 1
src/form/validator.php

@@ -77,7 +77,7 @@ final class Validator
             }
 
             if (isset($field['max_length']) && is_int($field['max_length'])) {
-                if (strlen((string) $value) > $field['max_length']) {
+                if ($this->validationStringLength((string) $value) > $field['max_length']) {
                     $errors[$key] = 'Eingabe ist zu lang.';
                 }
             }
@@ -282,4 +282,17 @@ final class Validator
 
         return true;
     }
+
+    private function validationStringLength(string $value): int
+    {
+        if (function_exists('mb_strlen')) {
+            return mb_strlen($value, 'UTF-8');
+        }
+
+        if (preg_match_all('/./us', $value, $matches) === false) {
+            return strlen($value);
+        }
+
+        return count($matches[0] ?? []);
+    }
 }