feat: add navigation bar with progress indicator and backward navigation

- Created _navigation.html component with 5-step progress indicator
- Added German labels (E-Mail, Persönliche Daten, Motivation, Dokumente, Bestätigung)
- Implemented can_access_page() validation in routes.py
- Users can navigate backward to completed pages (data preserved)
- Sequential forward progression enforced (cannot skip ahead)
- Visual states: completed (green checkmark), current (blue), future (gray)
- Added comprehensive CSS styling with responsive design
- Updated base.html to include navigation component
- Updated page templates to pass current_page context
- Navigation appears on pages 2-5 only
- All 102 tests passing with no regressions
This commit is contained in:
2025-12-27 23:12:21 +01:00
parent c0cca71b74
commit ef5ccc18f7
7 changed files with 504 additions and 2 deletions
+64
View File
@@ -27,6 +27,25 @@ from app.utils import check_rate_limit
def register_routes(app, mail): def register_routes(app, mail):
"""Register all application routes""" """Register all application routes"""
def can_access_page(session_id, requested_page):
"""
Determine if user can access the requested page.
Rules:
- Can always access pages <= current_page (backward navigation)
- Cannot access pages > current_page (must progress sequentially)
- Page 1 is always accessible
"""
if requested_page == 1:
return True
app_data = load_application_data(session_id)
if not app_data:
return False
current_page = app_data.get('current_page', 1)
return requested_page <= current_page
@app.route('/') @app.route('/')
def index(): def index():
"""Redirect to apply page""" """Redirect to apply page"""
@@ -91,6 +110,11 @@ def register_routes(app, mail):
@app.route('/apply/<session_id>/personal') @app.route('/apply/<session_id>/personal')
def page2_personal(session_id): def page2_personal(session_id):
"""Page 2: Basic personal information""" """Page 2: Basic personal information"""
# Validate page access
if not can_access_page(session_id, 2):
flash('Sie müssen zuerst die vorherigen Schritte abschließen.', 'error')
return redirect(url_for('page1_email'))
app_data = load_application_data(session_id) app_data = load_application_data(session_id)
if not app_data: if not app_data:
flash('Bewerbung nicht gefunden.', 'error') flash('Bewerbung nicht gefunden.', 'error')
@@ -99,6 +123,7 @@ def register_routes(app, mail):
return render_template('page2_personal.html', return render_template('page2_personal.html',
session_id=session_id, session_id=session_id,
job_name=app_data['job_name'], job_name=app_data['job_name'],
current_page=app_data.get('current_page', 2),
data=app_data.get('personal_info', {})) data=app_data.get('personal_info', {}))
@@ -180,6 +205,16 @@ def register_routes(app, mail):
@app.route('/apply/<session_id>/motivation') @app.route('/apply/<session_id>/motivation')
def page3_motivation(session_id): def page3_motivation(session_id):
"""Page 3: Motivation and qualification questions""" """Page 3: Motivation and qualification questions"""
# Validate page access
if not can_access_page(session_id, 3):
app_data = load_application_data(session_id)
if app_data:
current_page = app_data.get('current_page', 2)
if current_page == 2:
return redirect(url_for('page2_personal', session_id=session_id))
flash('Sie müssen zuerst die vorherigen Schritte abschließen.', 'error')
return redirect(url_for('page1_email'))
app_data = load_application_data(session_id) app_data = load_application_data(session_id)
if not app_data: if not app_data:
flash('Bewerbung nicht gefunden.', 'error') flash('Bewerbung nicht gefunden.', 'error')
@@ -188,6 +223,7 @@ def register_routes(app, mail):
return render_template('page3_motivation.html', return render_template('page3_motivation.html',
session_id=session_id, session_id=session_id,
job_name=app_data['job_name'], job_name=app_data['job_name'],
current_page=app_data.get('current_page', 3),
data=app_data.get('motivation_answers', {})) data=app_data.get('motivation_answers', {}))
@@ -249,6 +285,18 @@ def register_routes(app, mail):
@app.route('/apply/<session_id>/upload') @app.route('/apply/<session_id>/upload')
def page4_upload(session_id): def page4_upload(session_id):
"""Page 4: Document upload""" """Page 4: Document upload"""
# Validate page access
if not can_access_page(session_id, 4):
app_data = load_application_data(session_id)
if app_data:
current_page = app_data.get('current_page', 2)
if current_page == 2:
return redirect(url_for('page2_personal', session_id=session_id))
elif current_page == 3:
return redirect(url_for('page3_motivation', session_id=session_id))
flash('Sie müssen zuerst die vorherigen Schritte abschließen.', 'error')
return redirect(url_for('page1_email'))
app_data = load_application_data(session_id) app_data = load_application_data(session_id)
if not app_data: if not app_data:
flash('Bewerbung nicht gefunden.', 'error') flash('Bewerbung nicht gefunden.', 'error')
@@ -257,6 +305,7 @@ def register_routes(app, mail):
return render_template('page4_upload.html', return render_template('page4_upload.html',
session_id=session_id, session_id=session_id,
job_name=app_data['job_name'], job_name=app_data['job_name'],
current_page=app_data.get('current_page', 4),
uploaded_files=app_data.get('uploaded_files', []), uploaded_files=app_data.get('uploaded_files', []),
max_files=app.config['MAX_FILES']) max_files=app.config['MAX_FILES'])
@@ -395,6 +444,20 @@ def register_routes(app, mail):
@app.route('/apply/<session_id>/confirmation') @app.route('/apply/<session_id>/confirmation')
def page5_confirmation(session_id): def page5_confirmation(session_id):
"""Page 5: Confirmation page""" """Page 5: Confirmation page"""
# Validate page access
if not can_access_page(session_id, 5):
app_data = load_application_data(session_id)
if app_data:
current_page = app_data.get('current_page', 2)
if current_page == 2:
return redirect(url_for('page2_personal', session_id=session_id))
elif current_page == 3:
return redirect(url_for('page3_motivation', session_id=session_id))
elif current_page == 4:
return redirect(url_for('page4_upload', session_id=session_id))
flash('Sie müssen zuerst die vorherigen Schritte abschließen.', 'error')
return redirect(url_for('page1_email'))
app_data = load_application_data(session_id) app_data = load_application_data(session_id)
if not app_data: if not app_data:
flash('Bewerbung nicht gefunden.', 'error') flash('Bewerbung nicht gefunden.', 'error')
@@ -403,6 +466,7 @@ def register_routes(app, mail):
return render_template('page5_confirmation.html', return render_template('page5_confirmation.html',
session_id=session_id, session_id=session_id,
job_name=app_data['job_name'], job_name=app_data['job_name'],
current_page=app_data.get('current_page', 5),
email=app_data['email']) email=app_data['email'])
@@ -0,0 +1,208 @@
<objective>
Add a navigation bar with progress indicator to the multi-page job application workflow, allowing users to navigate backward to previous pages to make changes while maintaining sequential forward progression.
This will improve user experience by allowing applicants to review and edit their information without losing progress, reducing form abandonment and improving data quality.
</objective>
<context>
Current state:
- 5-page workflow: email capture → personal info → motivation → upload → confirmation
- Users can only move forward through pages
- No way to go back and edit previous pages after submission
- Pages are: page1_email.html, page2_personal.html, page3_motivation.html, page4_upload.html, page5_confirmation.html
- Uses session-based application tracking with session_id
- All data persisted to YAML files in app/models.py
Target state:
- Progress indicator showing all 5 steps
- Users can click on completed steps (pages they've already visited) to go back
- Cannot skip ahead to pages they haven't reached yet
- Current page highlighted in progress bar
- Navigation preserves all previously entered data
Review the current implementation:
@app/routes.py
@templates/page2_personal.html
@templates/page3_motivation.html
@templates/page4_upload.html
</context>
<requirements>
1. **Create navigation component**:
- Add progress bar/breadcrumb navigation to base.html or as includable partial
- Show all 5 steps: Email → Personal → Motivation → Upload → Confirmation
- Visually distinguish between: completed (clickable), current (highlighted), future (disabled)
- Must be present on pages 2, 3, 4, 5 (not on page 1)
2. **Navigation behavior**:
- **Backward navigation**: Users can click on any previously completed page to go back
- **Forward navigation**: Sequential only - users must click "Next" buttons, cannot skip ahead
- **Current page tracking**: Use `current_page` field in application data to determine which pages are accessible
- **Data preservation**: All previously entered data must be preserved when navigating
3. **Visual design** (German labels):
- Step 1: "E-Mail" (page1_email.html)
- Step 2: "Daten" (page2_personal.html)
- Step 3: "Motivation" (page3_motivation.html)
- Step 4: "Dokumente" (page4_upload.html)
- Step 5: "Bestätigung" (page5_confirmation.html)
- Completed steps: Green checkmark + clickable link
- Current step: Bold + highlighted background
- Future steps: Gray + no link
4. **Implementation approach**:
- Add navigation partial to templates/ (e.g., `_navigation.html` or add to `base.html`)
- Pass `current_page` and `session_id` to all templates
- Add CSS styling for progress bar (can be inline in base.html or in style.css)
- Update route handlers to accept navigation from any completed page
- Ensure CSRF tokens work with navigation
5. **Data handling**:
- When user navigates backward, load existing data from YAML
- Pre-populate forms with saved data
- When user submits, update data and allow forward navigation
- Don't lose uploaded files when navigating
6. **Edge cases**:
- If user tries to access a page beyond current_page directly via URL, redirect to current page
- If session_id is invalid, redirect to page 1
- On page 5 (confirmation), navigation can be read-only (all steps completed)
</requirements>
<implementation>
**Suggested implementation strategy**:
1. **Create navigation component**:
- Create `templates/_navigation.html` partial with Jinja2 template for progress bar
- OR add navigation directly to `base.html` as a conditional block
- Use Bootstrap-style progress steps or custom CSS
2. **Update base.html**:
- Add navigation include or block after header
- Only show navigation if `session_id` exists and `current_page >= 2`
3. **Update all page templates** (page2, page3, page4, page5):
- Include navigation component at top
- Pass `current_page`, `session_id`, `job_name` to navigation
4. **Update route handlers** in `app/routes.py`:
- Add helper function to validate page access: `can_access_page(session_id, page_number)`
- Add navigation routes or update existing routes to handle backward navigation
- Ensure all routes check if user can access the requested page
5. **CSS styling**:
- Add styles to `static/style.css` for progress bar
- Use semantic colors: green for completed, blue/highlight for current, gray for future
- Make clickable steps have hover effects
**Example navigation structure** (German):
```
[✓ E-Mail] → [✓ Daten] → [● Motivation] → [ Dokumente] → [ Bestätigung]
clickable clickable current disabled disabled
```
**WHY this approach**:
- Progress indicator provides clear visual feedback on completion status
- Backward navigation allows data correction without starting over
- Sequential forward validation ensures data quality and prevents skipping required fields
- Session-based tracking already in place, just need to leverage current_page
- Preserves all existing functionality while adding navigation
</implementation>
<validation_logic>
Add helper function to check page access:
```python
def can_access_page(session_id, requested_page):
"""
Determine if user can access the requested page.
Rules:
- Can always access pages <= current_page (backward navigation)
- Cannot access pages > current_page (must progress sequentially)
- Page 1 is always accessible
"""
if requested_page == 1:
return True
app_data = load_application_data(session_id)
if not app_data:
return False
current_page = app_data.get('current_page', 1)
return requested_page <= current_page
```
Use in route handlers:
```python
@app.route('/apply/<session_id>/personal')
def page2_personal(session_id):
if not can_access_page(session_id, 2):
return redirect(url_for('page1_email'))
# ... rest of handler
```
</validation_logic>
<output>
Modify the following files:
- `./templates/base.html` - Add navigation component or include statement
- `./templates/_navigation.html` (NEW) - Progress bar component (if using partial approach)
- `./templates/page2_personal.html` - Ensure navigation is displayed
- `./templates/page3_motivation.html` - Ensure navigation is displayed
- `./templates/page4_upload.html` - Ensure navigation is displayed
- `./templates/page5_confirmation.html` - Ensure navigation is displayed (read-only mode)
- `./app/routes.py` - Add `can_access_page()` helper and update route guards
- `./static/style.css` - Add navigation styling
Do NOT modify:
- Data models or storage logic
- Form validation logic
- Email functionality
- File upload logic
</output>
<verification>
Before declaring complete, verify:
1. **Visual verification**:
- Progress bar appears on pages 2, 3, 4, 5
- Current step is highlighted
- Completed steps show checkmarks and are clickable
- Future steps are grayed out and not clickable
2. **Navigation testing**:
- From page 3, click on "Daten" → should go to page 2 with data pre-populated
- From page 4, click on "E-Mail" → should go to page 1 with email shown
- Try to access page 4 by typing URL when on page 2 → should redirect to page 2
- Complete workflow and verify navigation works at each step
3. **Data preservation**:
- Fill out page 2, go to page 3, go back to page 2 → all fields still filled
- Upload files on page 4, go back to page 3, return to page 4 → files still there
- Make changes on a previous page and save → changes persisted
4. **Edge cases**:
- Invalid session_id → redirect to page 1
- Accessing page directly via URL beyond current_page → redirect to current page
- Page 5 navigation is read-only (all steps completed)
5. **Tests still pass**:
- Run `pytest tests/ -v` to ensure no regressions
- Existing tests should pass (they test form submission, not navigation)
- Consider adding new tests for navigation behavior (optional)
</verification>
<success_criteria>
- Progress bar/breadcrumb navigation visible on pages 2-5
- Users can navigate backward to any completed page
- Users cannot skip ahead to uncompleted pages
- All previously entered data is preserved when navigating
- Current page is visually distinct in navigation
- Navigation uses German labels matching the application
- All existing functionality still works
- No test regressions
- Clean, professional styling that matches existing design
</success_criteria>
Completed: Sa 27 Dez 2025 23:11:04 CET
+146
View File
@@ -366,6 +366,123 @@ header h1 {
margin: 20px 0; margin: 20px 0;
} }
/* Progress Navigation */
.progress-navigation {
margin-bottom: 30px;
background-color: #f8f9fa;
padding: 20px;
border-radius: 8px;
border: 1px solid #ddd;
}
.progress-steps {
list-style: none;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
counter-reset: step;
}
.progress-step {
flex: 1;
text-align: center;
position: relative;
}
.progress-step:not(:last-child)::after {
content: '';
position: absolute;
top: 20px;
left: 50%;
width: 100%;
height: 2px;
background-color: #ddd;
z-index: 0;
}
.progress-step.completed:not(:last-child)::after {
background-color: #28a745;
}
.progress-link {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-decoration: none;
color: #666;
position: relative;
z-index: 1;
}
.progress-step.completed .progress-link {
color: #28a745;
cursor: pointer;
}
.progress-step.active .progress-link {
color: #007bff;
font-weight: bold;
}
.progress-step.disabled .progress-link {
color: #999;
cursor: not-allowed;
}
.progress-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.1em;
background-color: white;
border: 2px solid #ddd;
transition: all 0.2s;
}
.progress-step.completed .progress-icon {
background-color: #28a745;
border-color: #28a745;
color: white;
}
.progress-step.active .progress-icon {
background-color: #007bff;
border-color: #007bff;
color: white;
}
.progress-step.disabled .progress-icon {
background-color: #f8f9fa;
border-color: #ddd;
color: #999;
}
.progress-step.completed a.progress-link:hover .progress-icon {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(40, 167, 69, 0.3);
}
.progress-step.completed a.progress-link:hover {
color: #1e7e34;
}
.progress-label {
font-size: 0.85em;
display: block;
max-width: 100px;
word-wrap: break-word;
}
.progress-step.active .progress-label {
font-weight: bold;
}
/* Footer */ /* Footer */
footer { footer {
margin-top: 40px; margin-top: 40px;
@@ -411,4 +528,33 @@ footer {
.page-intro h2 { .page-intro h2 {
font-size: 1.2em; font-size: 1.2em;
} }
/* Progress Navigation Mobile */
.progress-navigation {
padding: 15px 10px;
}
.progress-steps {
flex-wrap: wrap;
gap: 15px;
}
.progress-step {
flex: 0 0 calc(50% - 8px);
}
.progress-step:not(:last-child)::after {
display: none;
}
.progress-icon {
width: 35px;
height: 35px;
font-size: 1em;
}
.progress-label {
font-size: 0.75em;
max-width: 80px;
}
} }
+80
View File
@@ -0,0 +1,80 @@
{% if session_id and current_page and current_page >= 2 %}
<nav class="progress-navigation" aria-label="Bewerbungsfortschritt">
<ol class="progress-steps">
<!-- Step 1: Email -->
<li class="progress-step {% if current_page == 1 %}active{% elif current_page > 1 %}completed{% else %}disabled{% endif %}">
{% if current_page >= 1 %}
<a href="{{ url_for('page1_email') }}" class="progress-link">
<span class="progress-icon">{% if current_page > 1 %}✓{% else %}1{% endif %}</span>
<span class="progress-label">E-Mail</span>
</a>
{% else %}
<span class="progress-link">
<span class="progress-icon">1</span>
<span class="progress-label">E-Mail</span>
</span>
{% endif %}
</li>
<!-- Step 2: Personal Info -->
<li class="progress-step {% if current_page == 2 %}active{% elif current_page > 2 %}completed{% else %}disabled{% endif %}">
{% if current_page >= 2 %}
<a href="{{ url_for('page2_personal', session_id=session_id) }}" class="progress-link">
<span class="progress-icon">{% if current_page > 2 %}✓{% else %}2{% endif %}</span>
<span class="progress-label">Daten</span>
</a>
{% else %}
<span class="progress-link">
<span class="progress-icon">2</span>
<span class="progress-label">Daten</span>
</span>
{% endif %}
</li>
<!-- Step 3: Motivation -->
<li class="progress-step {% if current_page == 3 %}active{% elif current_page > 3 %}completed{% else %}disabled{% endif %}">
{% if current_page >= 3 %}
<a href="{{ url_for('page3_motivation', session_id=session_id) }}" class="progress-link">
<span class="progress-icon">{% if current_page > 3 %}✓{% else %}3{% endif %}</span>
<span class="progress-label">Motivation</span>
</a>
{% else %}
<span class="progress-link">
<span class="progress-icon">3</span>
<span class="progress-label">Motivation</span>
</span>
{% endif %}
</li>
<!-- Step 4: Upload -->
<li class="progress-step {% if current_page == 4 %}active{% elif current_page > 4 %}completed{% else %}disabled{% endif %}">
{% if current_page >= 4 %}
<a href="{{ url_for('page4_upload', session_id=session_id) }}" class="progress-link">
<span class="progress-icon">{% if current_page > 4 %}✓{% else %}4{% endif %}</span>
<span class="progress-label">Dokumente</span>
</a>
{% else %}
<span class="progress-link">
<span class="progress-icon">4</span>
<span class="progress-label">Dokumente</span>
</span>
{% endif %}
</li>
<!-- Step 5: Confirmation -->
<li class="progress-step {% if current_page == 5 %}active{% elif current_page > 5 %}completed{% else %}disabled{% endif %}">
{% if current_page >= 5 %}
<a href="{{ url_for('page5_confirmation', session_id=session_id) }}" class="progress-link">
<span class="progress-icon">{% if current_page > 5 %}✓{% else %}5{% endif %}</span>
<span class="progress-label">Bestätigung</span>
</a>
{% else %}
<span class="progress-link">
<span class="progress-icon">5</span>
<span class="progress-label">Bestätigung</span>
</span>
{% endif %}
</li>
</ol>
</nav>
{% endif %}
+3
View File
@@ -12,6 +12,9 @@
<h1>{% block header %}Bewerbungsformular{% endblock %}</h1> <h1>{% block header %}Bewerbungsformular{% endblock %}</h1>
</header> </header>
<!-- Progress Navigation -->
{% include '_navigation.html' %}
<main> <main>
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
+2 -2
View File
@@ -1,12 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Bewerbung - Persönliche Daten{% endblock %} {% block title %}Bewerbung - Daten{% endblock %}
{% block header %}Bewerbung: {{ job_name }}{% endblock %} {% block header %}Bewerbung: {{ job_name }}{% endblock %}
{% block content %} {% block content %}
<div class="page-intro"> <div class="page-intro">
<h2>Schritt 1 von 3: Persönliche Daten</h2> <h2>Schritt 1 von 3: Daten</h2>
<p>Bitte geben Sie Ihre persönlichen Informationen ein. Alle Felder mit * sind Pflichtfelder.</p> <p>Bitte geben Sie Ihre persönlichen Informationen ein. Alle Felder mit * sind Pflichtfelder.</p>
</div> </div>
+1
View File
@@ -8,6 +8,7 @@
<div class="page-intro"> <div class="page-intro">
<h2>Schritt 2 von 3: Motivation und Qualifikationen</h2> <h2>Schritt 2 von 3: Motivation und Qualifikationen</h2>
<p>Bitte beantworten Sie die folgenden Fragen. Alle Angaben sind optional, helfen uns aber, Sie besser kennenzulernen.</p> <p>Bitte beantworten Sie die folgenden Fragen. Alle Angaben sind optional, helfen uns aber, Sie besser kennenzulernen.</p>
<p>Falls sie ein Motivationsschreiben einreichen wollen haben Sie im nächsten Schritt die Möglichkeit dazu.</p>
<p><small>Maximal ca. 3000 Zeichen pro Antwort (etwa eine A4-Seite)</small></p> <p><small>Maximal ca. 3000 Zeichen pro Antwort (etwa eine A4-Seite)</small></p>
</div> </div>