refactor(wizard): improve combo invitation flow consistency

This commit implements two improvements to the combo wizard invitation flow:

1. Badge Display Fix: Added the "pre-invite" / "post-invite" category badge
   above wizard steps in combo invites. This badge was already present in
   single-invite pages but was missing from combo invites.

2. URL Refactoring: Cleaned up combo wizard URLs to match the pattern used
   by single-invite wizards:
   - Before: /wizard/combo/0?category=pre_invite → /j/<code> → /wizard/combo/0?category=post_invite
   - After: /wizard/combo/pre_invite → /j/<code> → /wizard/combo/post_invite

   This makes combo URLs consistent with single-invite URLs
   (/wizard/pre-wizard → /wizard/post-wizard) by removing the step index
   from the path and using path-based category routing.

Changes include:
- Pass step_phase to template for badge display
- Update routes to use path-based categories instead of query parameters
- Update templates and JavaScript to handle new URL structure
- Fix form method attributes and template formatting
This commit is contained in:
engels74
2025-10-16 17:23:57 +02:00
parent 0fb4767457
commit caceccab54
5 changed files with 80 additions and 49 deletions

View File

@@ -532,8 +532,8 @@ def pre_wizard(idx: int = 0):
server_order = [s.server_type for s in servers]
session["wizard_server_order"] = server_order
# Redirect to combo route with pre_invite category
return redirect(url_for("wizard.combo", idx=idx, category="pre_invite"))
# Redirect to combo route with pre_invite category (path-based)
return redirect(url_for("wizard.combo", category="pre_invite"))
# Single server invitation - handle normally
# Determine server type from invitation
@@ -683,10 +683,8 @@ def post_wizard(idx: int = 0):
server_order = [s.server_type for s in servers]
session["wizard_server_order"] = server_order
# Redirect to combo route with post_invite category
return redirect(
url_for("wizard.combo", idx=idx, category="post_invite")
)
# Redirect to combo route with post_invite category (path-based)
return redirect(url_for("wizard.combo", category="post_invite"))
# Single server invitation
server_type = _get_server_type_from_invitation(invitation)
@@ -843,23 +841,29 @@ def step(server, idx):
# ─── combined wizard for multi-server invites ─────────────────────────────
@wizard_bp.route("/combo/<int:idx>")
def combo(idx: int):
@wizard_bp.route("/combo/<category>")
@wizard_bp.route("/combo/<category>/<int:idx>")
def combo(category: str, idx: int = 0):
"""Combined wizard for multi-server invites with category support.
This route handles multi-server invitations by concatenating wizard steps
from all servers in the invitation. It supports both pre-invite and post-invite
categories, determined by the 'category' query parameter.
categories, determined by the category path parameter.
Args:
idx: Current step index
Query Parameters:
category: 'pre_invite' or 'post_invite' (default: 'post_invite')
category: 'pre_invite' or 'post_invite'
idx: Current step index (default: 0)
Returns:
Rendered wizard template or redirect response
"""
# Validate category parameter
if category not in ["pre_invite", "post_invite"]:
current_app.logger.warning(
f"Invalid category '{category}' for combo wizard, defaulting to post_invite"
)
category = "post_invite"
try:
cfg = _settings()
except Exception as e:
@@ -877,11 +881,6 @@ def combo(idx: int):
)
return redirect(url_for("wizard.start"))
# Determine category from query parameter (default: post_invite for backward compatibility)
category = request.args.get("category", "post_invite")
if category not in ["pre_invite", "post_invite"]:
category = "post_invite"
# Determine phase for template rendering
phase = "pre" if category == "pre_invite" else "post"
invite_code = InviteCodeManager.get_invite_code()
@@ -962,6 +961,7 @@ def combo(idx: int):
direction=direction,
require_interaction=require_interaction,
phase=phase, # Pass phase based on category
step_phase=phase, # Pass step_phase to enable phase badge display
current_server_type=current_server_type, # NEW: Pass current server type for display
completion_url=completion_url,
completion_label=completion_label,
@@ -976,6 +976,9 @@ def combo(idx: int):
resp.headers["X-Require-Interaction"] = (
"true" if require_interaction else "false"
)
resp.headers["X-Wizard-Step-Phase"] = (
phase # FIX: Add phase header for badge updates
)
resp.headers["X-Current-Server-Type"] = (
current_server_type # NEW: Indicate current server
)

View File

@@ -93,7 +93,7 @@
{% endif %}
<form class="space-y-3 md:space-y-4"
action="{{ url_for('public.process_invitation') }}"
method="POST">
method="post">
{{ form.hidden_tag() }}
<div class="form-field"
data-field="1"

View File

@@ -111,12 +111,18 @@
<!-- FLOATING NAV BUTTONS (mobile - fixed at bottom) -->
<div class="wizard-nav-mobile md:hidden">
<a id="wizard-prev-btn"
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx-1) }}"
{% if server_type == 'combo' %}
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.combo', category='pre_invite', idx=idx-1) }}"
{% else %}
hx-get="{{ url_for('wizard.combo', category='post_invite', idx=idx-1) }}"
{% endif %}
{% elif phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx-1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx-1) }}"
hx-get="{{ url_for('wizard.post_wizard', idx=idx-1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx-1) }}"
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx-1) }}"
{% endif %}
hx-vals='{"dir":"prev"}'
hx-target="#wizard-content"
@@ -133,13 +139,19 @@
{% if is_pre_final %}
href="{{ completion_href }}" hx-get="{{ completion_href }}" hx-target="#wizard-content" hx-swap="outerHTML swap:0s" hx-indicator=".htmx-indicator" data-final-step="1"
{% else %}
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx+1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx+1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx+1) }}"
{% endif %}
{% if server_type == 'combo' %}
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.combo', category='pre_invite', idx=idx+1) }}"
{% else %}
hx-get="{{ url_for('wizard.combo', category='post_invite', idx=idx+1) }}"
{% endif %}
{% elif phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx+1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx+1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx+1) }}"
{% endif %}
hx-vals='{"dir":"next"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
@@ -160,12 +172,18 @@
<!-- DESKTOP NAV BUTTONS (centered below card) -->
<div class="hidden md:flex justify-center space-x-4 mt-6">
<a id="wizard-prev-btn-desktop"
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx-1) }}"
{% if server_type == 'combo' %}
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.combo', category='pre_invite', idx=idx-1) }}"
{% else %}
hx-get="{{ url_for('wizard.combo', category='post_invite', idx=idx-1) }}"
{% endif %}
{% elif phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx-1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx-1) }}"
hx-get="{{ url_for('wizard.post_wizard', idx=idx-1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx-1) }}"
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx-1) }}"
{% endif %}
hx-vals='{"dir":"prev"}'
hx-target="#wizard-content"
@@ -177,13 +195,19 @@
{% if is_pre_final %}
href="{{ completion_href }}" hx-get="{{ completion_href }}" hx-target="#wizard-content" hx-swap="outerHTML swap:0s" hx-indicator=".htmx-indicator" data-final-step="1"
{% else %}
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx+1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx+1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx+1) }}"
{% endif %}
{% if server_type == 'combo' %}
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.combo', category='pre_invite', idx=idx+1) }}"
{% else %}
hx-get="{{ url_for('wizard.combo', category='post_invite', idx=idx+1) }}"
{% endif %}
{% elif phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx+1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx+1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx+1) }}"
{% endif %}
hx-vals='{"dir":"next"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
@@ -476,9 +500,13 @@
const targetIdx = isPrev ? idx - 1 : idx + 1;
// Generate URL based on phase
// Generate URL based on phase and server type
let newUrl;
if (phase === 'pre') {
if (serverType === 'combo') {
// Combo wizard uses path-based category routing
const category = phase === 'pre' ? 'pre_invite' : 'post_invite';
newUrl = `/wizard/combo/${category}/${targetIdx}`;
} else if (phase === 'pre') {
newUrl = `/wizard/pre-wizard/${targetIdx}`;
} else if (phase === 'post') {
newUrl = `/wizard/post-wizard/${targetIdx}`;

View File

@@ -542,8 +542,8 @@ class TestInvitationUIComponents:
expect(page.locator("label").filter(has_text="Password").first).to_be_visible()
expect(page.locator("label").filter(has_text="Email")).to_be_visible()
# Check form uses POST method
expect(page.locator("form")).to_have_attribute("method", "POST")
# Check form uses POST method (lowercase is HTML standard)
expect(page.locator("form")).to_have_attribute("method", "post")
# Test keyboard navigation
page.keyboard.press("Tab") # Should focus first input

View File

@@ -346,7 +346,8 @@ class TestComboWizardErrors:
def test_combo_wizard_without_server_order(self, client):
"""Test combo wizard redirects when no server order in session."""
response = client.get("/wizard/combo/0", follow_redirects=False)
# Test with new path-based category routing
response = client.get("/wizard/combo/pre_invite/0", follow_redirects=False)
assert response.status_code == 302
# Should redirect (exact location may vary)
assert response.location is not None
@@ -360,9 +361,8 @@ class TestComboWizardErrors:
with patch("app.blueprints.wizard.routes._steps") as mock_steps:
mock_steps.side_effect = Exception("Database error")
response = client.get(
"/wizard/combo/0?category=pre_invite", follow_redirects=True
)
# Test with new path-based category routing
response = client.get("/wizard/combo/pre_invite/0", follow_redirects=True)
assert response.status_code == 200
# Should handle error gracefully