feat(onboarding): add wizard Steps 2 (PSA) and 3 (Invite team)
Step 2 (`/welcome/step-2`): four PSA tiles (ConnectWise / Autotask /
HaloPSA / No PSA yet). Selecting a real PSA reveals a quiet inline
"Connect now" link to `/account/integrations` — credential entry is
intentionally OUT of the wizard. Continue persists `primary_psa`,
Skip advances without writing.
Step 3 (`/welcome/step-3`): up to 10 email/role rows (default 3,
"+ Add another" extends, role defaults to Tech / engineer with
Viewer alt). "Send invites and continue" filters empty rows, POSTs
`/accounts/me/invites/bulk`, then PATCHes onboarding-step
`{step:3, action:"complete"}` and navigates to `/?welcome=true`.
Per-row `failed[]` errors render inline next to the email and the
wizard does NOT auto-advance — user can fix-and-retry or click
"Continue anyway" to mark step complete. Empty + Skip / empty + Send
both advance without sending.
Adds `accountsApi.bulkInvite` and registers `/welcome/step-{2,3}`
in the router. Vitest: 5 named tests (selecting PSA persists,
Skip advances without primary_psa, valid emails create invites,
partial-failure inline error, empty + Skip no-op) + 5 incidental
coverage tests. tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,22 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client'
|
||||||
import type { Account, SubscriptionDetails, AccountMember, AccountInvite } from '@/types'
|
import type { Account, SubscriptionDetails, AccountMember, AccountInvite } from '@/types'
|
||||||
|
|
||||||
|
export interface BulkInviteRow {
|
||||||
|
email: string
|
||||||
|
role: 'engineer' | 'viewer'
|
||||||
|
expires_in_days?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkInviteFailure {
|
||||||
|
email: string
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkInviteResponse {
|
||||||
|
created: AccountInvite[]
|
||||||
|
failed: BulkInviteFailure[]
|
||||||
|
}
|
||||||
|
|
||||||
export const accountsApi = {
|
export const accountsApi = {
|
||||||
async getMyAccount(): Promise<Account> {
|
async getMyAccount(): Promise<Account> {
|
||||||
const response = await apiClient.get<Account>('/accounts/me')
|
const response = await apiClient.get<Account>('/accounts/me')
|
||||||
@@ -39,6 +55,18 @@ export const accountsApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create multiple invites in one call (used by the welcome wizard step 3).
|
||||||
|
* Per-row failures land in `failed[]`; successes in `created[]`.
|
||||||
|
*/
|
||||||
|
async bulkInvite(invites: BulkInviteRow[]): Promise<BulkInviteResponse> {
|
||||||
|
const response = await apiClient.post<BulkInviteResponse>(
|
||||||
|
'/accounts/me/invites/bulk',
|
||||||
|
{ invites },
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
async getInvites(): Promise<AccountInvite[]> {
|
async getInvites(): Promise<AccountInvite[]> {
|
||||||
const response = await apiClient.get<AccountInvite[]>('/accounts/me/invites')
|
const response = await apiClient.get<AccountInvite[]>('/accounts/me/invites')
|
||||||
return response.data
|
return response.data
|
||||||
|
|||||||
208
frontend/src/pages/welcome/WelcomeStep2.tsx
Normal file
208
frontend/src/pages/welcome/WelcomeStep2.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { onboardingApi, type PrimaryPsa } from '@/api/onboarding'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const PSA_OPTIONS: { value: PrimaryPsa; label: string; description: string }[] = [
|
||||||
|
{ value: 'connectwise', label: 'ConnectWise', description: 'Manage / PSA' },
|
||||||
|
{ value: 'autotask', label: 'Autotask', description: 'Datto Autotask PSA' },
|
||||||
|
{ value: 'halopsa', label: 'HaloPSA', description: 'Halo Service Solutions' },
|
||||||
|
{ value: 'none', label: 'No PSA yet', description: "We'll add one later" },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/welcome/step-2` — second step of the welcome wizard. Captures the PSA the
|
||||||
|
* shop primarily uses. Selecting a non-`none` tile reveals a quiet "Connect
|
||||||
|
* now" link that navigates out to `/account/integrations`. The wizard's
|
||||||
|
* primary action is "Continue" — credential entry is intentionally OUT of
|
||||||
|
* the wizard (per spec).
|
||||||
|
*/
|
||||||
|
export function WelcomeStep2() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const fetchUser = useAuthStore((s) => s.fetchUser)
|
||||||
|
|
||||||
|
const [primaryPsa, setPrimaryPsa] = useState<PrimaryPsa | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState<'continue' | 'skip' | 'dismiss' | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const isBusy = submitting !== null
|
||||||
|
const showConnectNow = primaryPsa !== null && primaryPsa !== 'none'
|
||||||
|
|
||||||
|
const handleContinue = async () => {
|
||||||
|
if (isBusy) return
|
||||||
|
setError(null)
|
||||||
|
setSubmitting('continue')
|
||||||
|
try {
|
||||||
|
await onboardingApi.updateStep({
|
||||||
|
step: 2,
|
||||||
|
action: 'complete',
|
||||||
|
data: primaryPsa ? { primary_psa: primaryPsa } : undefined,
|
||||||
|
})
|
||||||
|
await fetchUser()
|
||||||
|
navigate('/welcome/step-3')
|
||||||
|
} catch {
|
||||||
|
setError('Could not save. Please try again.')
|
||||||
|
setSubmitting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSkipStep = async () => {
|
||||||
|
if (isBusy) return
|
||||||
|
setError(null)
|
||||||
|
setSubmitting('skip')
|
||||||
|
try {
|
||||||
|
await onboardingApi.updateStep({ step: 2, action: 'skip' })
|
||||||
|
await fetchUser()
|
||||||
|
navigate('/welcome/step-3')
|
||||||
|
} catch {
|
||||||
|
setError('Could not save. Please try again.')
|
||||||
|
setSubmitting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismissRest = async () => {
|
||||||
|
if (isBusy) return
|
||||||
|
setError(null)
|
||||||
|
setSubmitting('dismiss')
|
||||||
|
try {
|
||||||
|
await onboardingApi.dismissRest()
|
||||||
|
await fetchUser()
|
||||||
|
navigate('/')
|
||||||
|
} catch {
|
||||||
|
setError('Could not save. Please try again.')
|
||||||
|
setSubmitting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-2xl px-4 py-10">
|
||||||
|
<header className="mb-8">
|
||||||
|
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
Step 2 of 3
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-2xl font-heading font-bold text-text-heading">
|
||||||
|
Your PSA
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Pick the PSA your team uses today. We'll wire it up later — no
|
||||||
|
credentials needed yet.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border border-border bg-card p-6 space-y-5"
|
||||||
|
data-testid="welcome-step-2-form"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Primary PSA"
|
||||||
|
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
|
||||||
|
>
|
||||||
|
{PSA_OPTIONS.map((opt) => {
|
||||||
|
const selected = primaryPsa === opt.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
onClick={() => setPrimaryPsa(opt.value)}
|
||||||
|
disabled={isBusy}
|
||||||
|
data-testid={`welcome-step-2-tile-${opt.value}`}
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border px-4 py-3 text-left transition-colors btn-press',
|
||||||
|
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
|
||||||
|
'disabled:opacity-60',
|
||||||
|
selected
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border bg-card hover:border-primary/40 hover:bg-foreground/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-semibold text-foreground">
|
||||||
|
{opt.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{opt.description}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showConnectNow && (
|
||||||
|
<div className="pt-1">
|
||||||
|
<Link
|
||||||
|
to="/account/integrations"
|
||||||
|
data-testid="welcome-step-2-connect-now"
|
||||||
|
className="text-xs text-muted-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Connect now →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-red-400" data-testid="welcome-step-2-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleContinue}
|
||||||
|
disabled={isBusy}
|
||||||
|
data-testid="welcome-step-2-continue"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
|
||||||
|
'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
|
||||||
|
'disabled:opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submitting === 'continue' && (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSkipStep}
|
||||||
|
disabled={isBusy}
|
||||||
|
data-testid="welcome-step-2-skip"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium btn-press',
|
||||||
|
'bg-card border border-border text-foreground hover:bg-foreground/5',
|
||||||
|
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
|
||||||
|
'disabled:opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submitting === 'skip' && (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Skip this step
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDismissRest}
|
||||||
|
disabled={isBusy}
|
||||||
|
data-testid="welcome-step-2-dismiss-rest"
|
||||||
|
className={cn(
|
||||||
|
'text-xs text-muted-foreground hover:underline',
|
||||||
|
'disabled:opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submitting === 'dismiss' ? 'Saving…' : 'Skip the rest'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WelcomeStep2
|
||||||
374
frontend/src/pages/welcome/WelcomeStep3.tsx
Normal file
374
frontend/src/pages/welcome/WelcomeStep3.tsx
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Loader2, Plus, X } from 'lucide-react'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { onboardingApi } from '@/api/onboarding'
|
||||||
|
import { accountsApi, type BulkInviteRow } from '@/api/accounts'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const MAX_ROWS = 10
|
||||||
|
const DEFAULT_ROW_COUNT = 3
|
||||||
|
|
||||||
|
type RowRole = 'engineer' | 'viewer'
|
||||||
|
|
||||||
|
interface InviteRow {
|
||||||
|
email: string
|
||||||
|
role: RowRole
|
||||||
|
/**
|
||||||
|
* Server-returned per-row error (from `failed[]`). Kept on the row so
|
||||||
|
* users can fix and retry without losing the rest of their input.
|
||||||
|
*/
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_OPTIONS: { value: RowRole; label: string }[] = [
|
||||||
|
{ value: 'engineer', label: 'Tech' },
|
||||||
|
{ value: 'viewer', label: 'Viewer' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
function makeEmptyRow(): InviteRow {
|
||||||
|
return { email: '', role: 'engineer' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/welcome/step-3` — final step of the welcome wizard. Captures up to
|
||||||
|
* `MAX_ROWS` teammate invites. On submit:
|
||||||
|
*
|
||||||
|
* 1. POST `/accounts/me/invites/bulk` with populated rows.
|
||||||
|
* 2. PATCH `/users/me/onboarding-step` `{step: 3, action: "complete"}`.
|
||||||
|
* 3. Navigate to `/?welcome=true` and fire a "You're all set" toast.
|
||||||
|
*
|
||||||
|
* Partial-failure UX: rows in `failed[]` keep their input and show an
|
||||||
|
* inline error. The wizard does NOT auto-advance when there are failures —
|
||||||
|
* the user can edit and retry, OR click "Continue anyway" to mark step 3
|
||||||
|
* complete and head to the dashboard.
|
||||||
|
*
|
||||||
|
* Empty rows are filtered before submit, so empty-form + "Send" is a no-op
|
||||||
|
* that just marks the step complete. (Skip does the same with `action: skip`.)
|
||||||
|
*/
|
||||||
|
export function WelcomeStep3() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const fetchUser = useAuthStore((s) => s.fetchUser)
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<InviteRow[]>(() =>
|
||||||
|
Array.from({ length: DEFAULT_ROW_COUNT }, makeEmptyRow),
|
||||||
|
)
|
||||||
|
const [submitting, setSubmitting] = useState<
|
||||||
|
'send' | 'skip' | 'dismiss' | 'continue-anyway' | null
|
||||||
|
>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [hasUnresolvedFailures, setHasUnresolvedFailures] = useState(false)
|
||||||
|
|
||||||
|
const isBusy = submitting !== null
|
||||||
|
|
||||||
|
const updateRow = (idx: number, patch: Partial<InviteRow>) => {
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeRow = (idx: number) => {
|
||||||
|
setRows((prev) => {
|
||||||
|
if (prev.length <= 1) return [makeEmptyRow()]
|
||||||
|
return prev.filter((_, i) => i !== idx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addRow = () => {
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.length >= MAX_ROWS ? prev : [...prev, makeEmptyRow()],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate populated rows. Empty-email rows are dropped silently.
|
||||||
|
* Returns either the list of valid rows OR a per-index error map.
|
||||||
|
*/
|
||||||
|
const validatePopulated = useMemo(
|
||||||
|
() => () => {
|
||||||
|
const errs: Record<number, string> = {}
|
||||||
|
const populated: { idx: number; row: BulkInviteRow }[] = []
|
||||||
|
rows.forEach((row, idx) => {
|
||||||
|
const email = row.email.trim()
|
||||||
|
if (!email) return
|
||||||
|
if (!EMAIL_RE.test(email)) {
|
||||||
|
errs[idx] = 'Invalid email'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
populated.push({ idx, row: { email, role: row.role } })
|
||||||
|
})
|
||||||
|
return { errs, populated }
|
||||||
|
},
|
||||||
|
[rows],
|
||||||
|
)
|
||||||
|
|
||||||
|
const completeWizardAndExit = async () => {
|
||||||
|
await onboardingApi.updateStep({ step: 3, action: 'complete' })
|
||||||
|
await fetchUser()
|
||||||
|
toast.success("You're all set!")
|
||||||
|
navigate('/?welcome=true')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendInvites = async () => {
|
||||||
|
if (isBusy) return
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const { errs, populated } = validatePopulated()
|
||||||
|
if (Object.keys(errs).length > 0) {
|
||||||
|
// Surface client-side validation errors inline.
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((row, idx) =>
|
||||||
|
errs[idx] ? { ...row, error: errs[idx] } : { ...row, error: undefined },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting('send')
|
||||||
|
try {
|
||||||
|
let failedSet = new Map<string, string>()
|
||||||
|
if (populated.length > 0) {
|
||||||
|
const result = await accountsApi.bulkInvite(populated.map((p) => p.row))
|
||||||
|
failedSet = new Map(result.failed.map((f) => [f.email, f.error]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedSet.size > 0) {
|
||||||
|
// Stamp errors on the matching rows; do NOT auto-advance.
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((row) => {
|
||||||
|
const email = row.email.trim()
|
||||||
|
const err = email ? failedSet.get(email) : undefined
|
||||||
|
return { ...row, error: err }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setHasUnresolvedFailures(true)
|
||||||
|
setSubmitting(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// All-clear (or zero invites sent): mark step complete and exit.
|
||||||
|
await completeWizardAndExit()
|
||||||
|
} catch {
|
||||||
|
setError('Could not send invites. Please try again.')
|
||||||
|
setSubmitting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContinueAnyway = async () => {
|
||||||
|
if (isBusy) return
|
||||||
|
setError(null)
|
||||||
|
setSubmitting('continue-anyway')
|
||||||
|
try {
|
||||||
|
await completeWizardAndExit()
|
||||||
|
} catch {
|
||||||
|
setError('Could not save. Please try again.')
|
||||||
|
setSubmitting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSkipStep = async () => {
|
||||||
|
if (isBusy) return
|
||||||
|
setError(null)
|
||||||
|
setSubmitting('skip')
|
||||||
|
try {
|
||||||
|
await onboardingApi.updateStep({ step: 3, action: 'skip' })
|
||||||
|
await fetchUser()
|
||||||
|
toast.success("You're all set!")
|
||||||
|
navigate('/?welcome=true')
|
||||||
|
} catch {
|
||||||
|
setError('Could not save. Please try again.')
|
||||||
|
setSubmitting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismissRest = async () => {
|
||||||
|
if (isBusy) return
|
||||||
|
setError(null)
|
||||||
|
setSubmitting('dismiss')
|
||||||
|
try {
|
||||||
|
await onboardingApi.dismissRest()
|
||||||
|
await fetchUser()
|
||||||
|
navigate('/')
|
||||||
|
} catch {
|
||||||
|
setError('Could not save. Please try again.')
|
||||||
|
setSubmitting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass = cn(
|
||||||
|
'block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||||
|
'text-foreground placeholder:text-muted-foreground',
|
||||||
|
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-2xl px-4 py-10">
|
||||||
|
<header className="mb-8">
|
||||||
|
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
Step 3 of 3
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-2xl font-heading font-bold text-text-heading">
|
||||||
|
Invite your team
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Add up to {MAX_ROWS} teammates. They'll get an email with a link to
|
||||||
|
join. Leave blank to do this later.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border border-border bg-card p-6 space-y-4"
|
||||||
|
data-testid="welcome-step-3-form"
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rows.map((row, idx) => (
|
||||||
|
<div key={idx} className="space-y-1">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={row.email}
|
||||||
|
onChange={(e) => updateRow(idx, { email: e.target.value, error: undefined })}
|
||||||
|
placeholder="teammate@example.com"
|
||||||
|
className={cn(inputClass, 'flex-1')}
|
||||||
|
data-testid={`welcome-step-3-email-${idx}`}
|
||||||
|
disabled={isBusy}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={row.role}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateRow(idx, { role: e.target.value as RowRole })
|
||||||
|
}
|
||||||
|
className={cn(inputClass, 'w-32 flex-shrink-0')}
|
||||||
|
data-testid={`welcome-step-3-role-${idx}`}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
{ROLE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeRow(idx)}
|
||||||
|
disabled={isBusy || rows.length <= 1}
|
||||||
|
data-testid={`welcome-step-3-remove-${idx}`}
|
||||||
|
aria-label="Remove row"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-10 w-10 items-center justify-center rounded-xl',
|
||||||
|
'text-muted-foreground hover:bg-foreground/5 hover:text-foreground',
|
||||||
|
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
|
||||||
|
'disabled:opacity-30 disabled:hover:bg-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{row.error && (
|
||||||
|
<p
|
||||||
|
className="pl-1 text-xs text-red-400"
|
||||||
|
data-testid={`welcome-step-3-row-error-${idx}`}
|
||||||
|
>
|
||||||
|
{row.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addRow}
|
||||||
|
disabled={isBusy || rows.length >= MAX_ROWS}
|
||||||
|
data-testid="welcome-step-3-add-row"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-xl px-2 py-1 text-xs font-medium',
|
||||||
|
'text-muted-foreground hover:bg-foreground/5 hover:text-foreground',
|
||||||
|
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
|
||||||
|
'disabled:opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
Add another
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-red-400" data-testid="welcome-step-3-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSendInvites}
|
||||||
|
disabled={isBusy}
|
||||||
|
data-testid="welcome-step-3-send"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
|
||||||
|
'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
|
||||||
|
'disabled:opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submitting === 'send' && (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Send invites and continue
|
||||||
|
</button>
|
||||||
|
{hasUnresolvedFailures && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleContinueAnyway}
|
||||||
|
disabled={isBusy}
|
||||||
|
data-testid="welcome-step-3-continue-anyway"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium btn-press',
|
||||||
|
'bg-card border border-border text-foreground hover:bg-foreground/5',
|
||||||
|
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
|
||||||
|
'disabled:opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submitting === 'continue-anyway' && (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Continue anyway
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSkipStep}
|
||||||
|
disabled={isBusy}
|
||||||
|
data-testid="welcome-step-3-skip"
|
||||||
|
className={cn(
|
||||||
|
'text-sm text-muted-foreground hover:underline',
|
||||||
|
'disabled:opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submitting === 'skip' ? 'Saving…' : 'Skip'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDismissRest}
|
||||||
|
disabled={isBusy}
|
||||||
|
data-testid="welcome-step-3-dismiss-rest"
|
||||||
|
className={cn(
|
||||||
|
'text-xs text-muted-foreground hover:underline',
|
||||||
|
'disabled:opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submitting === 'dismiss' ? 'Saving…' : 'Skip the rest'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WelcomeStep3
|
||||||
174
frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx
Normal file
174
frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { WelcomeStep2 } from '../WelcomeStep2'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { onboardingApi } from '@/api/onboarding'
|
||||||
|
import type { Account, User } from '@/types'
|
||||||
|
|
||||||
|
vi.mock('@/api/onboarding', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/api/onboarding')>(
|
||||||
|
'@/api/onboarding',
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
onboardingApi: {
|
||||||
|
...actual.onboardingApi,
|
||||||
|
updateStep: vi.fn(),
|
||||||
|
dismissRest: vi.fn(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function makeUser(overrides: Partial<User> = {}): User {
|
||||||
|
return {
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'Test User',
|
||||||
|
role: 'engineer',
|
||||||
|
is_super_admin: false,
|
||||||
|
is_active: true,
|
||||||
|
must_change_password: false,
|
||||||
|
account_id: 'acct-1',
|
||||||
|
account_role: 'owner',
|
||||||
|
team_id: null,
|
||||||
|
created_at: '2026-05-01T00:00:00Z',
|
||||||
|
last_login: null,
|
||||||
|
phone: null,
|
||||||
|
job_title: null,
|
||||||
|
timezone: 'UTC',
|
||||||
|
avatar_url: null,
|
||||||
|
email_verified_at: null,
|
||||||
|
onboarding_step_completed: 1,
|
||||||
|
onboarding_dismissed: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAccount(overrides: Partial<Account> = {}): Account {
|
||||||
|
return {
|
||||||
|
id: 'acct-1',
|
||||||
|
name: 'Acme MSP',
|
||||||
|
display_code: 'ACME',
|
||||||
|
owner_id: 'user-1',
|
||||||
|
created_at: '2026-05-01T00:00:00Z',
|
||||||
|
updated_at: '2026-05-01T00:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={['/welcome/step-2']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/welcome/step-2" element={<WelcomeStep2 />} />
|
||||||
|
<Route path="/welcome/step-3" element={<div>step-3</div>} />
|
||||||
|
<Route path="/account/integrations" element={<div>integrations</div>} />
|
||||||
|
<Route path="/" element={<div>dashboard</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WelcomeStep2', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAuthStore.setState({
|
||||||
|
user: makeUser(),
|
||||||
|
account: makeAccount(),
|
||||||
|
subscription: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: true,
|
||||||
|
fetchUser: vi.fn().mockResolvedValue(undefined),
|
||||||
|
})
|
||||||
|
vi.mocked(onboardingApi.updateStep).mockResolvedValue({
|
||||||
|
onboarding_step_completed: 2,
|
||||||
|
onboarding_dismissed: false,
|
||||||
|
})
|
||||||
|
vi.mocked(onboardingApi.dismissRest).mockResolvedValue({
|
||||||
|
onboarding_step_completed: null,
|
||||||
|
onboarding_dismissed: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selecting PSA persists primary_psa', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('welcome-step-2-tile-connectwise'))
|
||||||
|
// Selecting a real PSA reveals the inline "Connect now" link.
|
||||||
|
expect(screen.getByTestId('welcome-step-2-connect-now')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('welcome-step-2-continue'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
|
||||||
|
step: 2,
|
||||||
|
action: 'complete',
|
||||||
|
data: { primary_psa: 'connectwise' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('step-3')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Skip advances without writing primary_psa', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('welcome-step-2-skip'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
|
||||||
|
step: 2,
|
||||||
|
action: 'skip',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Confirm no `data` key on the call (skip doesn't persist primary_psa).
|
||||||
|
const call = vi.mocked(onboardingApi.updateStep).mock.calls[0]?.[0]
|
||||||
|
expect(call?.data).toBeUndefined()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('step-3')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"No PSA yet" tile does NOT show the Connect now link', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('welcome-step-2-tile-none'))
|
||||||
|
expect(screen.queryByTestId('welcome-step-2-connect-now')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('default action is Continue (not Connect now)', () => {
|
||||||
|
renderPage()
|
||||||
|
// Continue is rendered as a primary button.
|
||||||
|
const continueBtn = screen.getByTestId('welcome-step-2-continue')
|
||||||
|
expect(continueBtn.className).toMatch(/bg-primary/)
|
||||||
|
// Connect-now is hidden until a real PSA is picked.
|
||||||
|
expect(screen.queryByTestId('welcome-step-2-connect-now')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Skip-the-rest dismisses and navigates to /', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('welcome-step-2-dismiss-rest'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onboardingApi.dismissRest).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('dashboard')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
279
frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx
Normal file
279
frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { WelcomeStep3 } from '../WelcomeStep3'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { onboardingApi } from '@/api/onboarding'
|
||||||
|
import { accountsApi } from '@/api/accounts'
|
||||||
|
import type { Account, User } from '@/types'
|
||||||
|
|
||||||
|
vi.mock('@/api/onboarding', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/api/onboarding')>(
|
||||||
|
'@/api/onboarding',
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
onboardingApi: {
|
||||||
|
...actual.onboardingApi,
|
||||||
|
updateStep: vi.fn(),
|
||||||
|
dismissRest: vi.fn(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/api/accounts', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/api/accounts')>(
|
||||||
|
'@/api/accounts',
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
accountsApi: {
|
||||||
|
...actual.accountsApi,
|
||||||
|
bulkInvite: vi.fn(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/lib/toast', () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
promise: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function makeUser(overrides: Partial<User> = {}): User {
|
||||||
|
return {
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'Test User',
|
||||||
|
role: 'engineer',
|
||||||
|
is_super_admin: false,
|
||||||
|
is_active: true,
|
||||||
|
must_change_password: false,
|
||||||
|
account_id: 'acct-1',
|
||||||
|
account_role: 'owner',
|
||||||
|
team_id: null,
|
||||||
|
created_at: '2026-05-01T00:00:00Z',
|
||||||
|
last_login: null,
|
||||||
|
phone: null,
|
||||||
|
job_title: null,
|
||||||
|
timezone: 'UTC',
|
||||||
|
avatar_url: null,
|
||||||
|
email_verified_at: null,
|
||||||
|
onboarding_step_completed: 2,
|
||||||
|
onboarding_dismissed: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAccount(overrides: Partial<Account> = {}): Account {
|
||||||
|
return {
|
||||||
|
id: 'acct-1',
|
||||||
|
name: 'Acme MSP',
|
||||||
|
display_code: 'ACME',
|
||||||
|
owner_id: 'user-1',
|
||||||
|
created_at: '2026-05-01T00:00:00Z',
|
||||||
|
updated_at: '2026-05-01T00:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={['/welcome/step-3']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/welcome/step-3" element={<WelcomeStep3 />} />
|
||||||
|
<Route path="/" element={<div>dashboard</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WelcomeStep3', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAuthStore.setState({
|
||||||
|
user: makeUser(),
|
||||||
|
account: makeAccount(),
|
||||||
|
subscription: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: true,
|
||||||
|
fetchUser: vi.fn().mockResolvedValue(undefined),
|
||||||
|
})
|
||||||
|
vi.mocked(onboardingApi.updateStep).mockResolvedValue({
|
||||||
|
onboarding_step_completed: 3,
|
||||||
|
onboarding_dismissed: false,
|
||||||
|
})
|
||||||
|
vi.mocked(onboardingApi.dismissRest).mockResolvedValue({
|
||||||
|
onboarding_step_completed: null,
|
||||||
|
onboarding_dismissed: true,
|
||||||
|
})
|
||||||
|
vi.mocked(accountsApi.bulkInvite).mockResolvedValue({
|
||||||
|
created: [],
|
||||||
|
failed: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('valid emails create invites and complete wizard', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
vi.mocked(accountsApi.bulkInvite).mockResolvedValueOnce({
|
||||||
|
created: [
|
||||||
|
{
|
||||||
|
id: 'inv-1',
|
||||||
|
account_id: 'acct-1',
|
||||||
|
email: 'a@example.com',
|
||||||
|
role: 'engineer',
|
||||||
|
code: 'c1',
|
||||||
|
expires_at: null,
|
||||||
|
used_at: null,
|
||||||
|
created_at: '2026-05-06T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inv-2',
|
||||||
|
account_id: 'acct-1',
|
||||||
|
email: 'b@example.com',
|
||||||
|
role: 'viewer',
|
||||||
|
code: 'c2',
|
||||||
|
expires_at: null,
|
||||||
|
used_at: null,
|
||||||
|
created_at: '2026-05-06T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
failed: [],
|
||||||
|
})
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await user.type(screen.getByTestId('welcome-step-3-email-0'), 'a@example.com')
|
||||||
|
await user.type(screen.getByTestId('welcome-step-3-email-1'), 'b@example.com')
|
||||||
|
await user.selectOptions(screen.getByTestId('welcome-step-3-role-1'), 'viewer')
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('welcome-step-3-send'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(accountsApi.bulkInvite).toHaveBeenCalledWith([
|
||||||
|
{ email: 'a@example.com', role: 'engineer' },
|
||||||
|
{ email: 'b@example.com', role: 'viewer' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
|
||||||
|
step: 3,
|
||||||
|
action: 'complete',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('dashboard')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('partial-failure shows inline error per failed email', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
vi.mocked(accountsApi.bulkInvite).mockResolvedValueOnce({
|
||||||
|
created: [
|
||||||
|
{
|
||||||
|
id: 'inv-1',
|
||||||
|
account_id: 'acct-1',
|
||||||
|
email: 'good@example.com',
|
||||||
|
role: 'engineer',
|
||||||
|
code: 'c1',
|
||||||
|
expires_at: null,
|
||||||
|
used_at: null,
|
||||||
|
created_at: '2026-05-06T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
failed: [
|
||||||
|
{ email: 'bad@example.com', error: 'Email already invited' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await user.type(screen.getByTestId('welcome-step-3-email-0'), 'good@example.com')
|
||||||
|
await user.type(screen.getByTestId('welcome-step-3-email-1'), 'bad@example.com')
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('welcome-step-3-send'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(accountsApi.bulkInvite).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// The bad-email row shows the error text.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('welcome-step-3-row-error-1')).toHaveTextContent(
|
||||||
|
/already invited/i,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wizard did NOT auto-advance — onboarding-step is unchanged.
|
||||||
|
expect(onboardingApi.updateStep).not.toHaveBeenCalled()
|
||||||
|
expect(screen.queryByText('dashboard')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
// "Continue anyway" is offered.
|
||||||
|
expect(screen.getByTestId('welcome-step-3-continue-anyway')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty + Skip advances without sending invites', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('welcome-step-3-skip'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
|
||||||
|
step: 3,
|
||||||
|
action: 'skip',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// No bulk-invite call.
|
||||||
|
expect(accountsApi.bulkInvite).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('dashboard')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty + Send is a no-op bulk call but still completes the step', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
// All rows blank — Send should skip the bulk call entirely and just
|
||||||
|
// mark the step complete.
|
||||||
|
await user.click(screen.getByTestId('welcome-step-3-send'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
|
||||||
|
step: 3,
|
||||||
|
action: 'complete',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(accountsApi.bulkInvite).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('+ Add another adds a row, capped at 10', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
// Starts with 3 default rows.
|
||||||
|
expect(screen.getByTestId('welcome-step-3-email-0')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('welcome-step-3-email-1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('welcome-step-3-email-2')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('welcome-step-3-email-3')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
const addBtn = screen.getByTestId('welcome-step-3-add-row')
|
||||||
|
// Click 7 more times → 10 total.
|
||||||
|
for (let i = 0; i < 7; i++) await user.click(addBtn)
|
||||||
|
expect(screen.getByTestId('welcome-step-3-email-9')).toBeInTheDocument()
|
||||||
|
// Capped — button disabled at 10.
|
||||||
|
expect(addBtn).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -70,6 +70,8 @@ const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsP
|
|||||||
// Welcome wizard (Phase 2)
|
// Welcome wizard (Phase 2)
|
||||||
const WelcomeRouter = lazyWithRetry(() => import('@/pages/welcome/WelcomeRouter'))
|
const WelcomeRouter = lazyWithRetry(() => import('@/pages/welcome/WelcomeRouter'))
|
||||||
const WelcomeStep1 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep1'))
|
const WelcomeStep1 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep1'))
|
||||||
|
const WelcomeStep2 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep2'))
|
||||||
|
const WelcomeStep3 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep3'))
|
||||||
const NetworkDiagramsPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams'))
|
const NetworkDiagramsPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams'))
|
||||||
const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor'))
|
const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor'))
|
||||||
// Admin pages
|
// Admin pages
|
||||||
@@ -247,6 +249,8 @@ export const router = sentryCreateBrowserRouter([
|
|||||||
// verification banner persists above each step.
|
// verification banner persists above each step.
|
||||||
{ path: 'welcome', element: page(WelcomeRouter) },
|
{ path: 'welcome', element: page(WelcomeRouter) },
|
||||||
{ path: 'welcome/step-1', element: page(WelcomeStep1) },
|
{ path: 'welcome/step-1', element: page(WelcomeStep1) },
|
||||||
|
{ path: 'welcome/step-2', element: page(WelcomeStep2) },
|
||||||
|
{ path: 'welcome/step-3', element: page(WelcomeStep3) },
|
||||||
// Admin routes
|
// Admin routes
|
||||||
{
|
{
|
||||||
path: 'admin',
|
path: 'admin',
|
||||||
|
|||||||
Reference in New Issue
Block a user