feat: add user selection flow

This commit is contained in:
Urban Modig
2026-07-24 20:21:33 +02:00
parent 9957383e88
commit 1ec7a72908
22 changed files with 778 additions and 40 deletions

View File

@ -1,21 +1,145 @@
import { render, screen } from '@testing-library/react'
import { afterEach, expect, test, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import App from './App'
const users = [
{
id: 'd56b54dd-31b0-4d71-8a10-82464be59a61',
name: 'Urban',
createdAt: '2026-07-24T10:15:30Z',
},
{
id: '2213a673-c859-462b-a031-7b68b6f01c73',
name: 'Anna',
createdAt: '2026-07-24T10:16:30Z',
},
]
beforeEach(() => {
window.localStorage.clear()
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
test('visar sidans rubrik', () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 'UP' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
test('tom användarlista visar Skapa användare', async () => {
mockJsonResponse([])
render(<App />)
expect(await screen.findByRole('heading', { name: 'Skapa användare' })).toBeInTheDocument()
})
test('befintliga användare utan lokalt val visar Vem är du', async () => {
mockJsonResponse(users)
render(<App />)
expect(await screen.findByRole('heading', { name: 'Vem är du?' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Urban' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Anna' })).toBeInTheDocument()
})
test('val av användare lagrar id och visar startsidan', async () => {
mockJsonResponse(users)
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Urban' }))
expect(screen.getByRole('heading', { name: 'Hej Urban' })).toBeInTheDocument()
expect(window.localStorage.getItem('hemhub.activeUserId')).toBe(users[0].id)
})
test('skapad användare blir automatiskt aktiv', async () => {
const createdUser = users[0]
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse([]))
fetchMock.mockResolvedValueOnce(jsonResponse(createdUser, 201))
render(<App />)
fireEvent.change(await screen.findByLabelText('Namn'), { target: { value: ' Urban ' } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa användare' }))
expect(await screen.findByRole('heading', { name: 'Hej Urban' })).toBeInTheDocument()
expect(window.localStorage.getItem('hemhub.activeUserId')).toBe(createdUser.id)
expect(fetchMock).toHaveBeenLastCalledWith('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Urban' }),
})
})
test('skapandefel visas utan att namnet försvinner', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse([]))
fetchMock.mockResolvedValueOnce(
jsonResponse(
{
code: 'USER_NAME_ALREADY_EXISTS',
message: 'En användare med det namnet finns redan.',
},
409,
),
)
render(<App />)
expect(screen.getByRole('heading', { name: 'HemHub' })).toBeInTheDocument()
const input = await screen.findByLabelText('Namn')
fireEvent.change(input, { target: { value: 'Urban' } })
fireEvent.click(screen.getByRole('button', { name: 'Skapa användare' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'En användare med det namnet finns redan.',
)
expect(input).toHaveValue('Urban')
})
test('Byt användare rensar valt id och visar användarvalet', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
mockJsonResponse(users)
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Byt användare' }))
expect(screen.getByRole('heading', { name: 'Vem är du?' })).toBeInTheDocument()
expect(window.localStorage.getItem('hemhub.activeUserId')).toBeNull()
})
test('hämtningsfel visas som fel och kan återförsökas', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockRejectedValueOnce(new Error('Nätverksfel'))
fetchMock.mockResolvedValueOnce(jsonResponse(users))
render(<App />)
expect(await screen.findByText('Kunde inte ansluta till HemHub.')).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Skapa användare' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Försök igen' }))
expect(await screen.findByRole('heading', { name: 'Vem är du?' })).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
test('ogiltigt lagrat användar-id rensas', async () => {
window.localStorage.setItem('hemhub.activeUserId', 'finns-inte')
mockJsonResponse(users)
render(<App />)
expect(await screen.findByRole('heading', { name: 'Vem är du?' })).toBeInTheDocument()
expect(window.localStorage.getItem('hemhub.activeUserId')).toBeNull()
})
function mockJsonResponse(body: unknown, status = 200) {
return vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(body, status))
}
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}

View File

@ -1,48 +1,215 @@
import { useEffect, useState } from 'react'
import { FormEvent, useEffect, useRef, useState } from 'react'
type HealthResponse = {
status: string
type User = {
id: string
name: string
createdAt: string
}
type ApiError = {
code?: string
message?: string
}
const ACTIVE_USER_KEY = 'hemhub.activeUserId'
function App() {
const [backendStatus, setBackendStatus] = useState<string | null>(null)
const [hasError, setHasError] = useState(false)
const [users, setUsers] = useState<User[]>([])
const [activeUser, setActiveUser] = useState<User | null>(null)
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [showCreateUser, setShowCreateUser] = useState(false)
const loadUsers = async () => {
setLoadState('loading')
try {
const response = await fetch('/api/users')
if (!response.ok) {
throw new Error('Kunde inte hämta användare')
}
const loadedUsers = (await response.json()) as User[]
const storedUserId = window.localStorage.getItem(ACTIVE_USER_KEY)
const storedUser = loadedUsers.find((user) => user.id === storedUserId) ?? null
if (storedUserId && !storedUser) {
window.localStorage.removeItem(ACTIVE_USER_KEY)
}
setUsers(loadedUsers)
setActiveUser(storedUser)
setShowCreateUser(loadedUsers.length === 0)
setLoadState('ready')
} catch {
setLoadState('error')
}
}
useEffect(() => {
const loadHealth = async () => {
try {
const response = await fetch('/api/health')
if (!response.ok) {
throw new Error(`Backend svarade med status ${response.status}`)
}
const health = (await response.json()) as HealthResponse
setBackendStatus(health.status)
} catch {
setHasError(true)
}
}
void loadHealth()
void loadUsers()
}, [])
let statusMessage = 'Kontrollerar backend…'
const selectUser = (user: User) => {
window.localStorage.setItem(ACTIVE_USER_KEY, user.id)
setActiveUser(user)
}
if (hasError) {
statusMessage = 'Backend kunde inte nås'
} else if (backendStatus) {
statusMessage = `Backend: ${backendStatus}`
const switchUser = () => {
window.localStorage.removeItem(ACTIVE_USER_KEY)
setActiveUser(null)
setShowCreateUser(false)
}
if (loadState === 'loading') {
return <main>Laddar HemHub</main>
}
if (loadState === 'error') {
return (
<main>
<h1>HemHub</h1>
<p>Kunde inte ansluta till HemHub.</p>
<button type="button" onClick={() => void loadUsers()}>
Försök igen
</button>
</main>
)
}
if (activeUser) {
return (
<main>
<h1>HemHub</h1>
<h2>Hej {activeUser.name}</h2>
<p>Du är vald som aktiv användare.</p>
<button type="button" onClick={switchUser}>
Byt användare
</button>
</main>
)
}
if (showCreateUser) {
return (
<CreateUserForm
hasExistingUsers={users.length > 0}
onCancel={() => setShowCreateUser(false)}
onCreated={(user) => {
setUsers((currentUsers) => [...currentUsers, user])
selectUser(user)
}}
/>
)
}
return (
<main>
<h1>HemHub</h1>
<p>Frontend har startat.</p>
<p aria-live="polite">{statusMessage}</p>
<h2>Vem är du?</h2>
<div className="user-list">
{users.map((user) => (
<button type="button" key={user.id} onClick={() => selectUser(user)}>
{user.name}
</button>
))}
</div>
<button type="button" className="secondary" onClick={() => setShowCreateUser(true)}>
Skapa användare
</button>
</main>
)
}
type CreateUserFormProps = {
hasExistingUsers: boolean
onCancel: () => void
onCreated: (user: User) => void
}
function CreateUserForm({ hasExistingUsers, onCancel, onCreated }: CreateUserFormProps) {
const [name, setName] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const isSubmittingRef = useRef(false)
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (isSubmittingRef.current) {
return
}
const trimmedName = name.trim()
if (!trimmedName || [...trimmedName].length > 50) {
setError('Namnet måste innehålla mellan 1 och 50 tecken.')
return
}
setError('')
isSubmittingRef.current = true
setIsSubmitting(true)
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmedName }),
})
if (!response.ok) {
const apiError = (await response.json().catch(() => ({}))) as ApiError
if (
apiError.code === 'INVALID_USER_NAME' ||
apiError.code === 'USER_NAME_ALREADY_EXISTS'
) {
setError(apiError.message ?? 'Det gick inte att skapa användaren.')
} else {
setError('Det gick inte att skapa användaren. Försök igen.')
}
return
}
const createdUser = (await response.json()) as User
onCreated(createdUser)
} catch {
setError('Det gick inte att skapa användaren. Försök igen.')
} finally {
isSubmittingRef.current = false
setIsSubmitting(false)
}
}
return (
<main>
<h1>HemHub</h1>
<h2>Skapa användare</h2>
<form onSubmit={(event) => void submit(event)}>
<label htmlFor="user-name">Namn</label>
<input
id="user-name"
value={name}
disabled={isSubmitting}
onChange={(event) => setName(event.target.value)}
/>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Skapar…' : 'Skapa användare'}
</button>
{hasExistingUsers && (
<button type="button" className="secondary" disabled={isSubmitting} onClick={onCancel}>
Tillbaka
</button>
)}
</form>
</main>
)
}
export default App

View File

@ -21,3 +21,53 @@ h1 {
margin-top: 0;
}
button,
input {
font: inherit;
}
button {
padding: 0.65rem 1rem;
border: 0;
border-radius: 0.4rem;
color: white;
background: #2563eb;
cursor: pointer;
}
button:disabled,
input:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.secondary {
margin-top: 1rem;
color: #1f2937;
background: #e5e7eb;
}
.user-list {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
form {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
input {
box-sizing: border-box;
width: 100%;
padding: 0.6rem;
border: 1px solid #9ca3af;
border-radius: 0.4rem;
}
.error {
color: #b91c1c;
}

View File

@ -1,2 +1,27 @@
import '@testing-library/jest-dom/vitest'
const storedValues = new Map<string, string>()
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
get length() {
return storedValues.size
},
clear() {
storedValues.clear()
},
getItem(key: string) {
return storedValues.get(key) ?? null
},
key(index: number) {
return [...storedValues.keys()][index] ?? null
},
removeItem(key: string) {
storedValues.delete(key)
},
setItem(key: string, value: string) {
storedValues.set(key, String(value))
},
} satisfies Storage,
})