feat: login page component
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
import { useState, FormEvent } from 'react'
|
||||||
|
|
||||||
|
interface LoginPageProps {
|
||||||
|
onLogin: (email: string, password: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginPage({ onLogin }: LoginPageProps) {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await onLogin(email, password)
|
||||||
|
} catch {
|
||||||
|
setError('Invalid credentials')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<form className="login-card" onSubmit={handleSubmit}>
|
||||||
|
<h2>Research Workbench</h2>
|
||||||
|
{error && <div className="login-error">{error}</div>}
|
||||||
|
<label htmlFor="email">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={loading}>
|
||||||
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { LoginPage } from '../components/LoginPage'
|
||||||
|
|
||||||
|
describe('LoginPage', () => {
|
||||||
|
it('renders a form with email and password inputs and heading', () => {
|
||||||
|
const onLogin = vi.fn().mockResolvedValue(undefined)
|
||||||
|
render(<LoginPage onLogin={onLogin} />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: 'Research Workbench' })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('textbox', { name: /email/i })).toBeTruthy()
|
||||||
|
expect(screen.getByLabelText(/password/i)).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: /sign in/i })).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders login-page and login-card CSS classes', () => {
|
||||||
|
const onLogin = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const { container } = render(<LoginPage onLogin={onLogin} />)
|
||||||
|
|
||||||
|
expect(container.querySelector('.login-page')).toBeTruthy()
|
||||||
|
expect(container.querySelector('.login-card')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show error message initially', () => {
|
||||||
|
const onLogin = vi.fn().mockResolvedValue(undefined)
|
||||||
|
render(<LoginPage onLogin={onLogin} />)
|
||||||
|
|
||||||
|
expect(screen.queryByText('Invalid credentials')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onLogin with email and password on submit', async () => {
|
||||||
|
const onLogin = vi.fn().mockResolvedValue(undefined)
|
||||||
|
render(<LoginPage onLogin={onLogin} />)
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByRole('textbox', { name: /email/i }), 'test@example.com')
|
||||||
|
await userEvent.type(screen.getByLabelText(/password/i), 'secret')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /sign in/i }))
|
||||||
|
|
||||||
|
expect(onLogin).toHaveBeenCalledWith('test@example.com', 'secret')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Invalid credentials" when onLogin rejects', async () => {
|
||||||
|
const onLogin = vi.fn().mockRejectedValue(new Error('Unauthorized'))
|
||||||
|
render(<LoginPage onLogin={onLogin} />)
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByRole('textbox', { name: /email/i }), 'bad@example.com')
|
||||||
|
await userEvent.type(screen.getByLabelText(/password/i), 'wrong')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /sign in/i }))
|
||||||
|
|
||||||
|
const errorEl = await screen.findByText('Invalid credentials')
|
||||||
|
expect(errorEl).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables the submit button and shows "Signing in..." while loading', async () => {
|
||||||
|
let resolve: () => void
|
||||||
|
const onLogin = vi.fn().mockImplementation(
|
||||||
|
() => new Promise<void>(r => { resolve = r })
|
||||||
|
)
|
||||||
|
const { container } = render(<LoginPage onLogin={onLogin} />)
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByRole('textbox', { name: /email/i }), 'test@example.com')
|
||||||
|
await userEvent.type(screen.getByLabelText(/password/i), 'password')
|
||||||
|
|
||||||
|
// Dispatch submit synchronously so loading state is captured before resolution
|
||||||
|
container.querySelector('form')!.dispatchEvent(
|
||||||
|
new Event('submit', { bubbles: true, cancelable: true })
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const btn = screen.getByRole('button', { name: /signing in/i })
|
||||||
|
expect(btn).toBeTruthy()
|
||||||
|
expect((btn as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clean up: resolve the pending promise
|
||||||
|
resolve!()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-enables button after onLogin resolves', async () => {
|
||||||
|
const onLogin = vi.fn().mockResolvedValue(undefined)
|
||||||
|
render(<LoginPage onLogin={onLogin} />)
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByRole('textbox', { name: /email/i }), 'test@example.com')
|
||||||
|
await userEvent.type(screen.getByLabelText(/password/i), 'password')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /sign in/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const btn = screen.getByRole('button', { name: /sign in/i })
|
||||||
|
expect((btn as HTMLButtonElement).disabled).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears error on new submission attempt', async () => {
|
||||||
|
let callCount = 0
|
||||||
|
const onLogin = vi.fn().mockImplementation(() => {
|
||||||
|
callCount++
|
||||||
|
if (callCount === 1) return Promise.reject(new Error('Unauthorized'))
|
||||||
|
return Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<LoginPage onLogin={onLogin} />)
|
||||||
|
|
||||||
|
// First submit — fails and shows error
|
||||||
|
await userEvent.type(screen.getByRole('textbox', { name: /email/i }), 'bad@example.com')
|
||||||
|
await userEvent.type(screen.getByLabelText(/password/i), 'wrong')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /sign in/i }))
|
||||||
|
|
||||||
|
const errorEl = await screen.findByText('Invalid credentials')
|
||||||
|
expect(errorEl).toBeTruthy()
|
||||||
|
|
||||||
|
// Second submit — error should be cleared
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /sign in/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Invalid credentials')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user