feat(upload): add validation feedback and auto-scroll on missing required fields
This commit is contained in:
parent
1de0e16c4a
commit
bf5c518a8f
148
FeatureRequests/FEATURE_REQUEST-upload-form-validation.md
Normal file
148
FeatureRequests/FEATURE_REQUEST-upload-form-validation.md
Normal 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)
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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() && (
|
||||
|
|
|
|||
|
|
@ -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 }} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user