feat(upload): add validation feedback and auto-scroll on missing required fields

This commit is contained in:
Matthias Lotz 2026-04-01 18:14:30 +02:00
parent 1de0e16c4a
commit bf5c518a8f
7 changed files with 252 additions and 37 deletions

View File

@ -0,0 +1,148 @@
<!--
Feature Request: Validierungsfeedback & Auto-Scroll bei fehlenden Pflichtfeldern im Upload-Formular
Zielgruppe: Entwickler / KI-Implementierer
-->
# FEATURE_REQUEST: Upload-Formular Validierungsfeedback bei fehlenden Pflichtfeldern
**Status**: ⏳ Geplant
**Erstellt**: 1. April 2026
---
## Problem
Das Upload-Formular (`MultiUploadPage`) blockiert den Upload-Vorgang still, wenn Pflichtfelder
fehlen. Die Funktion `handleUpload` bricht ohne Benutzerrückmeldung ab:
```js
// Aktuelles Verhalten in handleUpload():
if (selectedImages.length === 0) return;
if (!metadata.year || !metadata.title.trim()) return;
if (!consents.workshopConsent) return;
```
Der Nutzer klickt auf den Upload-Button nichts passiert. Kein Hinweis, welches Feld fehlt,
keine visuelle Hervorhebung, kein Scroll zum Problem.
### Betroffene Pflichtfelder
| Feld | Bedingung |
|---|---|
| Bilder auswählen | `selectedImages.length === 0` |
| Jahr | `!metadata.year` |
| Titel der Slideshow | `!metadata.title.trim()` |
| Workshop-Einwilligung (Checkbox) | `!consents.workshopConsent` |
---
## Gewünschtes Verhalten
1. **Klick auf Upload-Button** → Validierung aller Pflichtfelder
2. **Bei einem oder mehreren fehlenden Feldern:**
- Das erste fehlende Feld wird visuell hervorgehoben (roter Rahmen / Fehlermeldung unterhalb)
- Die Seite scrollt automatisch zum ersten fehlenden Feld
- Der Upload wird nicht ausgeführt
3. **Fehlermeldungen verschwinden**, sobald der Nutzer das jeweilige Feld korrekt ausfüllt
---
## Use Cases
### UC-1: Kein Titel eingegeben
- Nutzer wählt Bilder aus, füllt Jahr aus, setzt Einwilligung, aber lässt Titel leer
- Klick auf Upload-Button
- → Seite scrollt zum Titel-Feld, Feld wird rot umrandet, Meldung: *„Bitte gib einen Titel für die Slideshow ein."*
### UC-2: Kein Jahr gewählt
- → Seite scrollt zum Jahr-Feld, Fehlermeldung: *„Bitte wähle ein Jahr aus."*
### UC-3: Einwilligung fehlt
- → Seite scrollt zur Checkbox, Fehlermeldung: *„Bitte bestätige die Einwilligung."*
### UC-4: Mehrere Felder fehlen gleichzeitig
- → Scroll zum **ersten** fehlenden Feld (Reihenfolge: Bilder → Jahr → Titel → Einwilligung)
- Alle fehlenden Felder werden gleichzeitig markiert
---
## Technische Umsetzung
### Implementierungsschritte
1. **Branch anlegen**: `feature/upload-form-validation`
2. **Plan-Datei anlegen**: `FeatureRequests/FEATURE_PLAN-upload-form-validation.md`
3. Fragen zur Umsetzung stellen (siehe unten)
4. Code-Implementierung
### Technischer Ansatz
**State für Validierungsfehler** in `MultiUploadPage`:
```js
const [validationErrors, setValidationErrors] = useState({
images: false,
year: false,
title: false,
workshopConsent: false,
});
```
**Refs für Auto-Scroll** zu jedem Pflichtfeld:
```js
const imagesSectionRef = useRef(null);
const yearFieldRef = useRef(null);
const titleFieldRef = useRef(null);
const consentRef = useRef(null);
```
**Validierungslogik** vor dem Upload:
```js
const validate = () => {
const errors = {
images: selectedImages.length === 0,
year: !metadata.year,
title: !metadata.title.trim(),
workshopConsent: !consents.workshopConsent,
};
setValidationErrors(errors);
// Zum ersten Fehler scrollen
if (errors.images) imagesSectionRef.current?.scrollIntoView({ behavior: 'smooth' });
else if (errors.year) yearFieldRef.current?.scrollIntoView({ behavior: 'smooth' });
else if (errors.title) titleFieldRef.current?.scrollIntoView({ behavior: 'smooth' });
else if (errors.workshopConsent) consentRef.current?.scrollIntoView({ behavior: 'smooth' });
return !Object.values(errors).some(Boolean);
};
```
**Fehler-Reset** bei Änderungen an den Feldern (jeweils im jeweiligen onChange-Handler):
```js
setValidationErrors(prev => ({ ...prev, title: false }));
```
**Visuelle Hervorhebung**: Felder-Komponenten erhalten ein `error`-Prop (MUI-Standard):
```jsx
<TextField
ref={titleFieldRef}
error={validationErrors.title}
helperText={validationErrors.title ? 'Bitte gib einen Titel ein.' : ''}
...
/>
```
---
## Klärungsfragen vor der Umsetzung
1. Werden die Eingabe-Komponenten für Jahr und Titel als MUI-`TextField`/`Select` gerendert
oder als eigene Custom-Komponenten? (Bestimmt, ob `error`-Prop direkt nutzbar ist)
2. Soll die Einwilligungs-Checkbox ebenfalls eine inline Fehlermeldung erhalten oder reicht
ein Scroll + farbliche Markierung des Checkbox-Labels?
3. Soll der Upload-Button während der Validierung deaktiviert (`disabled`) bleiben, oder erst
nach dem ersten Klickversuch aktiviert werden? (aktuelle Implementierung: Button ist immer aktiv)

View File

@ -16,7 +16,9 @@ function ConsentManager({
token,
groupId,
onRefresh,
mode = 'edit'
mode = 'edit',
validationErrors,
onValidationClear
}) {
// Initialize with proper defaults
const defaultConsents = {
@ -210,6 +212,8 @@ function ConsentManager({
groupId={groupId}
token={token}
onSave={null}
workshopConsentError={validationErrors?.workshopConsent}
onWorkshopConsentErrorClear={() => onValidationClear?.('workshopConsent')}
>
{/* Alerts and Buttons only in edit mode */}
{!isUploadMode && (

View File

@ -19,7 +19,9 @@ function GroupMetadataEditor({
token,
groupId,
onRefresh,
mode = 'edit'
mode = 'edit',
validationErrors,
onValidationClear
}) {
const [metadata, setMetadata] = useState(initialMetadata || {
year: new Date().getFullYear(),
@ -150,6 +152,8 @@ function GroupMetadataEditor({
<DescriptionInput
metadata={currentMetadata}
onMetadataChange={setCurrentMetadata}
validationErrors={validationErrors}
onValidationClear={onValidationClear}
/>
{!isUploadMode && hasChanges() && (

View File

@ -42,7 +42,9 @@ function ConsentCheckboxes({
disabled = false,
mode = 'upload',
groupId = null,
children
children,
workshopConsentError = false,
onWorkshopConsentErrorClear
}) {
const [platforms, setPlatforms] = useState([]);
const [loading, setLoading] = useState(true);
@ -71,6 +73,7 @@ function ConsentCheckboxes({
...consents,
workshopConsent: event.target.checked
});
if (onWorkshopConsentErrorClear) onWorkshopConsentErrorClear();
};
const handleSocialMediaChange = (platformId) => (event) => {
@ -168,6 +171,11 @@ function ConsentCheckboxes({
</Typography>
}
/>
{workshopConsentError && (
<Alert severity="error" sx={{ mt: 1 }}>
Bitte bestätige die Einwilligung zur Anzeige in der Werkstatt.
</Alert>
)}
</Box>
<Divider sx={{ my: 3 }} />

View File

@ -3,7 +3,9 @@ import { TextField, Typography, Grid, Box } from '@mui/material';
function DescriptionInput({
metadata = {},
onMetadataChange
onMetadataChange,
validationErrors = {},
onValidationClear
}) {
const handleFieldChange = (field, value) => {
@ -12,6 +14,7 @@ function DescriptionInput({
[field]: value
};
onMetadataChange(updatedMetadata);
if (onValidationClear) onValidationClear(field);
};
const currentYear = new Date().getFullYear();
@ -86,6 +89,8 @@ function DescriptionInput({
min: 1900,
max: currentYear + 10
}}
error={!!validationErrors.year}
helperText={validationErrors.year ? 'Bitte wähle ein Jahr aus.' : ''}
/>
</Grid>
@ -100,6 +105,8 @@ function DescriptionInput({
onChange={(e) => handleFieldChange('title', e.target.value)}
placeholder="z.B. Wohnzimmer Renovierung"
inputProps={{ maxLength: 100 }}
error={!!validationErrors.title}
helperText={validationErrors.title ? 'Bitte gib einen Titel für die Slideshow ein.' : ''}
/>
</Grid>

View File

@ -1,7 +1,7 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
function MultiImageDropzone({ onImagesSelected, selectedImages = [] }) {
function MultiImageDropzone({ onImagesSelected, selectedImages = [], hasError = false }) {
const handleFiles = (files) => {
// Filter nur Bilddateien
@ -57,21 +57,21 @@ function MultiImageDropzone({ onImagesSelected, selectedImages = [] }) {
};
const dropzoneSx = {
border: '2px dashed #cccccc',
border: `2px dashed ${hasError ? '#d32f2f' : '#cccccc'}`,
borderRadius: '8px',
padding: '40px 20px',
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.3s ease',
backgroundColor: '#fafafa',
backgroundColor: hasError ? '#fff5f5' : '#fafafa',
minHeight: '200px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
'&:hover': {
borderColor: '#999999',
backgroundColor: '#f0f0f0'
borderColor: hasError ? '#b71c1c' : '#999999',
backgroundColor: hasError ? '#ffebee' : '#f0f0f0'
}
};

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
// Components
import NavbarUpload from '../ComponentUtils/Headers/NavbarUpload';
@ -34,6 +34,17 @@ function MultiUploadPage() {
const [uploadResult, setUploadResult] = useState(null);
const [isEditMode, setIsEditMode] = useState(false);
const [imageDescriptions, setImageDescriptions] = useState({});
const [validationErrors, setValidationErrors] = useState({
images: false,
year: false,
title: false,
workshopConsent: false,
});
// Refs für Auto-Scroll zu Pflichtfeldern
const dropzoneRef = useRef(null);
const metadataRef = useRef(null);
const consentRef = useRef(null);
// Cleanup object URLs when component unmounts
useEffect(() => {
@ -108,12 +119,34 @@ function MultiUploadPage() {
}));
};
const clearValidationError = (field) => {
setValidationErrors(prev => ({ ...prev, [field]: false }));
};
const validate = () => {
const errors = {
images: selectedImages.length === 0,
year: !metadata.year,
title: !metadata.title.trim(),
workshopConsent: !consents.workshopConsent,
};
setValidationErrors(errors);
if (errors.images) {
dropzoneRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (errors.year) {
metadataRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (errors.title) {
metadataRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (errors.workshopConsent) {
consentRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
return !Object.values(errors).some(Boolean);
};
const handleUpload = async () => {
if (selectedImages.length === 0) return;
if (!metadata.year || !metadata.title.trim()) return;
if (!consents.workshopConsent) return;
if (!validate()) return;
setUploading(true);
setUploadProgress(0);
@ -151,13 +184,6 @@ function MultiUploadPage() {
}
};
const canUpload = () => {
return selectedImages.length > 0 &&
metadata.year &&
metadata.title.trim() &&
consents.workshopConsent;
};
return (
<div className="allContainer">
{<NavbarUpload />}
@ -177,10 +203,21 @@ function MultiUploadPage() {
{!uploading ? (
<>
{/* Image Dropzone - stays inline as it's upload-specific */}
<MultiImageDropzone
onImagesSelected={handleImagesSelected}
selectedImages={selectedImages}
/>
<div ref={dropzoneRef}>
<MultiImageDropzone
onImagesSelected={(imgs) => {
handleImagesSelected(imgs);
clearValidationError('images');
}}
selectedImages={selectedImages}
hasError={validationErrors.images}
/>
{validationErrors.images && (
<p style={{ color: '#d32f2f', fontSize: '13px', marginTop: '6px' }}>
Bitte wähle mindestens ein Bild aus.
</p>
)}
</div>
{/* Image Gallery with descriptions */}
{selectedImages.length > 0 && (
@ -201,24 +238,31 @@ function MultiUploadPage() {
{selectedImages.length > 0 && (
<>
{/* Modular Components like ManagementPortalPage */}
<GroupMetadataEditor
metadata={metadata}
onMetadataChange={setMetadata}
mode="upload"
/>
<div ref={metadataRef}>
<GroupMetadataEditor
metadata={metadata}
onMetadataChange={setMetadata}
mode="upload"
validationErrors={validationErrors}
onValidationClear={clearValidationError}
/>
</div>
<ConsentManager
consents={consents}
onConsentsChange={setConsents}
mode="upload"
/>
<div ref={consentRef}>
<ConsentManager
consents={consents}
onConsentsChange={setConsents}
mode="upload"
validationErrors={validationErrors}
onValidationClear={clearValidationError}
/>
</div>
{/* Action Buttons */}
<div className="flex-center">
<button
className="btn btn-success"
onClick={handleUpload}
disabled={!canUpload()}
>
🚀 {selectedImages.length} Bild{selectedImages.length !== 1 ? 'er' : ''} hochladen
</button>