mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
feat(onboarding): BB welcome modal + 25-step guided tour
Closes the recurring "I added the printer but it isn't connecting" +
"where is X feature" cluster that drove ~1/3 of invalid-tagged issues.
New users now see a friendly welcome modal on first load and can take
a guided walkthrough that highlights the load-bearing UI surface
step-by-step.
Backend: two new nullable columns on users (onboarding_status
VARCHAR(64), onboarding_snoozed_until TIMESTAMP) added via inline
migration with a one-shot backfill that marks every existing user as
dismissed_at_migration -- pre-existing installs never see the welcome
modal. GET + PATCH /api/v1/users/me/onboarding round-trip the state;
OnboardingUpdate schema rejects dismissed_at_migration from the API
(migration-internal only), validates the tour_in_progress:<step_id>
form, and enforces snooze coherence (snoozed_until required only when
status is snoozed). _users_column_exists helper mirrors
_api_keys_column_exists and gates the backfill so restarts after
new-user signups do not clobber the welcome-eligible NULL state.
SQLite and Postgres both verified end-to-end.
Frontend architecture: OnboardingProvider wraps the app inside
AuthProvider; reads from the backend when auth is on, falls back to
localStorage (bambuddy.onboarding_status + onboarding_snoozed_until)
when auth is off so no-auth installs still get the welcome
experience. A loadFailed gate prevents the welcome modal from popping
over a backend outage -- we cannot distinguish "new user" from "GET
errored" so we stay silent. OnboardingFlow driver picks between the
Phase 0 welcome modal, the Phase 0.2 about modal, and the
step-by-step TourEngine based on persisted status. Mounted inside
BrowserRouter so the route guard's useLocation has its context (a
sibling-of-Router placement crashed at runtime).
Route guard: the overlay never renders on /setup, /login,
/spoolbuddy/*, /camera/*, /overlay/*, or while requiresSetup is
true -- fresh installs walk through the existing /setup flow
uninterrupted, and the SpoolBuddy kiosk / OBS overlay / camera-popout
windows never get a modal slapped over them.
Tour engine: 25 steps targeting existing data-tour anchors --
add-printer -> verify-connection -> printer-card sub-tour x5 (status
row, AMS row, camera, controls, customize menu) -> add-spool ->
bambu-cloud-sync -> sidebar overview x6 -> vp -> slicer-api ->
makerworld -> obico -> integrations -> notifications -> users ->
groups -> sso -> outro. Per-step route navigation via useNavigate,
anchor polling at 100ms intervals with a 3-second cap (pages need a
beat after navigation; never spin forever), box-shadow dimmed-
spotlight cutout that pointer-events-through so the user can still
interact, smart modal positioning (sidebar anchors to the right;
page anchors below or flipped above based on viewport room),
Back/Next/Skip with Escape as Skip, persistence on every Back/Next
so mid-tour reloads resume at the same step.
Conditional skip: each step exposes a skipIf(ctx) evaluated against
at least one printer exists; verify-connection + the entire card
sub-tour skip when no printer exists; makerworld skips when the user
lacks makerworld:view (the sidebar entry is permission-gated and the
anchor would not resolve); users / groups / sso skip when auth is
off. Pre-render gate means the user never sees a flash of a step
that is about to skip.
Phase 1.1 "Lock the front door" auth step explicitly removed from
the live tour -- /setup already prompts for the auth choice on fresh
installs, and users who deliberately chose no-auth must not be
nudged to enable it. The auth-card anchor stays for any future
privacy-checkup surface.
BB mascot: hero pose + 5 named poses (started / walk / almost /
allset / help) sliced from the character sheet via Pillow into
public/img/bb_*.webp. MascotIcon component takes a pose prop;
per-step pose mapping in tourSteps.ts. TourLauncher sits in the
sidebar footer as a BB icon that relaunches the tour from step 0
and consumes the [data-tour="help-icon"] selector.
WikiHelpIcon component lands the per-page question-mark icon on
Queue / Archives / Stats / Maintenance / Files / Projects /
Inventory -- opens the matching wiki.bambuddy.cool/{path}/ page in
a new tab (chose new-tab over iframe because the wiki sets
X-Frame-Options: DENY).
27 new data-tour anchors across PrintersPage / SettingsPage /
ProfilesPage / InventoryPage / Layout. i18n: 138 new keys under a
new onboarding.* namespace, real translations in every locale
(de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW) -- no
IDENTICAL_TO_EN_ALLOWED entries added.
Backend tests: 12 in TestOnboardingAPI cover the round-trips and the
validator branches. Frontend tests: 61 across 7 files -- anchor
presence backstop, Phase 0 modal interaction, tour engine + step
helpers, provider state machine (auth-on/off split, PATCH error
fall-through, localStorage round-trip), route guard for every
suppress path, WikiHelpIcon href/target/aria. Backend pytest
5723/5723; frontend vitest 2154/2154; ESLint clean; frontend build
clean; i18n parity clean at 5233 leaves x 11 locales.
Companion docs: docs/onboarding-tour-plan.md carries the design
(Phase 0-5 step inventory, anchor selector list, state model, asset
inventory, resolved decisions, Implementation Status section that
is authoritative for current shipped state).
This commit is contained in:
@@ -991,3 +991,182 @@ class TestInputLengthValidation:
|
||||
)
|
||||
# Schema accepts it; auth may reject with 401 (auth disabled) or 400
|
||||
assert response.status_code != 422
|
||||
|
||||
|
||||
class TestOnboardingAPI:
|
||||
"""Integration tests for /api/v1/users/me/onboarding endpoints.
|
||||
|
||||
See docs/onboarding-tour-plan.md Appendix B for the state model.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
async def user_token(self, async_client: AsyncClient):
|
||||
"""Enable auth, create a regular user, return their bearer token."""
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "onboardingadmin",
|
||||
"admin_password": "AdminPass1!",
|
||||
},
|
||||
)
|
||||
|
||||
admin_login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "onboardingadmin", "password": "AdminPass1!"},
|
||||
)
|
||||
admin_token = admin_login.json()["access_token"]
|
||||
|
||||
await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"username": "onboardinguser", "password": "Userpass123!"},
|
||||
)
|
||||
|
||||
user_login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "onboardinguser", "password": "Userpass123!"},
|
||||
)
|
||||
return user_login.json()["access_token"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_returns_null_for_new_user(self, async_client: AsyncClient, user_token: str):
|
||||
"""A newly-created user has no onboarding status set yet (welcome modal eligible)."""
|
||||
response = await async_client.get(
|
||||
"/api/v1/users/me/onboarding",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] is None
|
||||
assert body["snoozed_until"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_patch_sets_dismissed(self, async_client: AsyncClient, user_token: str):
|
||||
"""PATCH with status=dismissed persists and is returned by subsequent GET."""
|
||||
response = await async_client.patch(
|
||||
"/api/v1/users/me/onboarding",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
json={"status": "dismissed"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "dismissed"
|
||||
|
||||
followup = await async_client.get(
|
||||
"/api/v1/users/me/onboarding",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
assert followup.json()["status"] == "dismissed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_patch_sets_snoozed_with_timestamp(self, async_client: AsyncClient, user_token: str):
|
||||
"""PATCH with status=snoozed + snoozed_until persists both fields."""
|
||||
response = await async_client.patch(
|
||||
"/api/v1/users/me/onboarding",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
json={"status": "snoozed", "snoozed_until": "2026-06-15T12:00:00+00:00"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "snoozed"
|
||||
assert body["snoozed_until"] is not None
|
||||
assert body["snoozed_until"].startswith("2026-06-15T12:00:00")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_patch_snoozed_without_timestamp_rejected(self, async_client: AsyncClient, user_token: str):
|
||||
"""status=snoozed without snoozed_until is a 422 — UI must supply both."""
|
||||
response = await async_client.patch(
|
||||
"/api/v1/users/me/onboarding",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
json={"status": "snoozed"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_patch_non_snoozed_with_timestamp_rejected(self, async_client: AsyncClient, user_token: str):
|
||||
"""snoozed_until is meaningful only for snoozed status — reject otherwise."""
|
||||
response = await async_client.patch(
|
||||
"/api/v1/users/me/onboarding",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
json={"status": "dismissed", "snoozed_until": "2026-06-15T12:00:00+00:00"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_patch_tour_in_progress_with_step_id(self, async_client: AsyncClient, user_token: str):
|
||||
"""tour_in_progress:<step_id> is accepted so the tour can resume on next session."""
|
||||
response = await async_client.patch(
|
||||
"/api/v1/users/me/onboarding",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
json={"status": "tour_in_progress:1.2"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "tour_in_progress:1.2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_patch_completed_tour(self, async_client: AsyncClient, user_token: str):
|
||||
"""status=completed_tour is the happy-path terminal state."""
|
||||
response = await async_client.patch(
|
||||
"/api/v1/users/me/onboarding",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
json={"status": "completed_tour"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "completed_tour"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_patch_invalid_status_rejected(self, async_client: AsyncClient, user_token: str):
|
||||
"""Arbitrary status strings outside the allowed set are 422."""
|
||||
response = await async_client.patch(
|
||||
"/api/v1/users/me/onboarding",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
json={"status": "nonsense"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_patch_dismissed_at_migration_rejected(self, async_client: AsyncClient, user_token: str):
|
||||
"""dismissed_at_migration is set only by the column-add migration, never by clients."""
|
||||
response = await async_client.patch(
|
||||
"/api/v1/users/me/onboarding",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
json={"status": "dismissed_at_migration"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_patch_tour_in_progress_malformed_step_id_rejected(self, async_client: AsyncClient, user_token: str):
|
||||
"""Step IDs with characters outside the allowlist are 422."""
|
||||
response = await async_client.patch(
|
||||
"/api/v1/users/me/onboarding",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
json={"status": "tour_in_progress:step with spaces"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_requires_auth(self, async_client: AsyncClient):
|
||||
"""No bearer token → 401."""
|
||||
response = await async_client.get("/api/v1/users/me/onboarding")
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_patch_requires_auth(self, async_client: AsyncClient):
|
||||
"""No bearer token → 401 (route is authenticated even though it has no permission gate)."""
|
||||
response = await async_client.patch(
|
||||
"/api/v1/users/me/onboarding",
|
||||
json={"status": "dismissed"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
Reference in New Issue
Block a user