MVP frontend+backend

This commit is contained in:
2026-06-14 09:58:30 +02:00
parent d9539f23cc
commit 772d853e37
56 changed files with 5099 additions and 207 deletions
+49 -23
View File
@@ -1,51 +1,77 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BrowserRouter } from 'react-router-dom'
import { Header } from './Header'
const renderWithRouter = (element: React.ReactElement) => {
return render(
<BrowserRouter>
{element}
</BrowserRouter>,
)
}
describe('Header', () => {
it('renders header with default title', () => {
render(<Header />)
renderWithRouter(<Header />)
expect(screen.getByTestId('header-title')).toHaveTextContent('Föräldraledigplannerare')
})
it('renders header with custom title', () => {
render(<Header title="Custom Title" />)
expect(screen.getByTestId('header-title')).toHaveTextContent('Custom Title')
it('renders parent name when provided', () => {
renderWithRouter(<Header parentName="Elin" />)
expect(screen.getByTestId('parent-name')).toHaveTextContent('Elin')
})
it('renders child select when children provided', () => {
const childrenList = [
{ id: '1', name: 'Emma' },
{ id: '2', name: 'Oscar' },
]
renderWithRouter(<Header childrenList={childrenList} selectedChildId="1" />)
expect(screen.getByTestId('child-select')).toBeInTheDocument()
expect(screen.getByDisplayValue('Emma')).toBeInTheDocument()
})
it('calls onChildSelect when child selection changes', async () => {
const onChildSelect = vi.fn()
const childrenList = [
{ id: '1', name: 'Emma' },
{ id: '2', name: 'Oscar' },
]
renderWithRouter(
<Header childrenList={childrenList} selectedChildId="1" onChildSelect={onChildSelect} />,
)
const select = screen.getByTestId('child-select') as HTMLSelectElement
await userEvent.selectOptions(select, '2')
expect(onChildSelect).toHaveBeenCalledWith('2')
})
it('renders navigation links', () => {
renderWithRouter(<Header />)
expect(screen.getByText('Översikt')).toBeInTheDocument()
expect(screen.getByText('Kalender')).toBeInTheDocument()
expect(screen.getByText('Tips')).toBeInTheDocument()
expect(screen.getByText('Inställningar')).toBeInTheDocument()
})
it('renders logout button when onLogout provided', () => {
const onLogout = vi.fn()
render(<Header onLogout={onLogout} />)
renderWithRouter(<Header onLogout={onLogout} />)
const button = screen.getByTestId('logout-button')
expect(button).toBeInTheDocument()
})
it('calls onLogout when logout button clicked', async () => {
const onLogout = vi.fn()
render(<Header onLogout={onLogout} />)
renderWithRouter(<Header onLogout={onLogout} />)
const button = screen.getByTestId('logout-button')
await userEvent.click(button)
expect(onLogout).toHaveBeenCalled()
})
it('renders workspace selector button when onWorkspaceSelect provided', () => {
const onWorkspaceSelect = vi.fn()
render(<Header onWorkspaceSelect={onWorkspaceSelect} />)
expect(screen.getByTestId('workspace-selector')).toBeInTheDocument()
})
it('calls onWorkspaceSelect when workspace button clicked', async () => {
const onWorkspaceSelect = vi.fn()
render(<Header onWorkspaceSelect={onWorkspaceSelect} />)
const button = screen.getByTestId('workspace-selector')
await userEvent.click(button)
expect(onWorkspaceSelect).toHaveBeenCalled()
})
it('does not render buttons when no handlers provided', () => {
render(<Header />)
it('does not render logout button when not provided', () => {
renderWithRouter(<Header />)
expect(screen.queryByTestId('logout-button')).not.toBeInTheDocument()
expect(screen.queryByTestId('workspace-selector')).not.toBeInTheDocument()
})
})
+77 -18
View File
@@ -1,32 +1,91 @@
import { ReactNode } from 'react'
import { NavLink } from 'react-router-dom'
import './header.css'
export interface HeaderProps {
title?: string
onLogout?: () => void
onWorkspaceSelect?: () => void
parentName?: string | undefined
selectedChildName?: string | undefined
childrenList?: Array<{ id: string; name: string }> | undefined
selectedChildId?: string | null | undefined
onChildSelect?: ((childId: string) => void) | undefined
onLogout?: (() => void) | undefined
}
export function Header({ title = 'Föräldraledigplannerare', onLogout, onWorkspaceSelect }: HeaderProps): ReactNode {
export function Header({
parentName,
selectedChildName,
childrenList,
selectedChildId,
onChildSelect,
onLogout,
}: HeaderProps): ReactNode {
return (
<header role="banner" data-testid="header">
<div data-testid="header-title">{title}</div>
<nav role="navigation" data-testid="header-nav">
{onWorkspaceSelect && (
<button
data-testid="workspace-selector"
onClick={onWorkspaceSelect}
aria-label="Select workspace"
>
Select Workspace
</button>
)}
<header className="header" role="banner" data-testid="header">
<div className="header__top">
<div className="header__title" data-testid="header-title">
Föräldraledigplannerare
</div>
<div className="header__identity">
{parentName && (
<span className="header__parent-name" data-testid="parent-name">
{parentName}
</span>
)}
{childrenList && childrenList.length > 0 && (
<select
className="header__child-select"
value={selectedChildId ?? ''}
onChange={(e) => onChildSelect?.(e.target.value)}
data-testid="child-select"
aria-label="Select child"
>
{childrenList.map((child) => (
<option key={child.id} value={child.id}>
{child.name}
</option>
))}
</select>
)}
{selectedChildName && childrenList && childrenList.length === 1 && (
<span className="header__child-name" data-testid="child-name">
{selectedChildName}
</span>
)}
</div>
</div>
<nav className="header__nav" role="navigation" data-testid="header-nav">
<NavLink
to="/"
className={({ isActive }) => `header__nav-link ${isActive ? 'header__nav-link--active' : ''}`}
>
Översikt
</NavLink>
<NavLink
to="/calendar"
className={({ isActive }) => `header__nav-link ${isActive ? 'header__nav-link--active' : ''}`}
>
Kalender
</NavLink>
<NavLink
to="/tips"
className={({ isActive }) => `header__nav-link ${isActive ? 'header__nav-link--active' : ''}`}
>
Tips
</NavLink>
<NavLink
to="/settings"
className={({ isActive }) => `header__nav-link ${isActive ? 'header__nav-link--active' : ''}`}
>
Inställningar
</NavLink>
{onLogout && (
<button
data-testid="logout-button"
className="header__logout-button"
onClick={onLogout}
data-testid="logout-button"
aria-label="Log out"
>
Logout
Logga ut
</button>
)}
</nav>
+26 -2
View File
@@ -1,4 +1,7 @@
import { ReactNode } from 'react'
import { ReactNode, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
import { useWorkspace } from '@/context/WorkspaceContext'
import { Header, HeaderProps } from './Header'
import { Footer } from './Footer'
@@ -8,9 +11,30 @@ interface LayoutProps {
}
export function Layout({ children, header }: LayoutProps): ReactNode {
const navigate = useNavigate()
const { logout } = useAuth()
const { parentName, children: childrenList, selectedChildId, selectChild } = useWorkspace()
const selectedChild = childrenList.find((c) => c.id === selectedChildId)
const handleLogout = useCallback(async () => {
await logout()
navigate('/auth', { replace: true })
}, [logout, navigate])
const headerProps: HeaderProps = {
...header,
parentName: parentName ?? undefined,
selectedChildName: selectedChild?.name,
childrenList,
selectedChildId,
onChildSelect: selectChild,
onLogout: handleLogout,
}
return (
<div role="main" data-testid="layout">
<Header {...(header || {})} />
<Header {...headerProps} />
<main data-testid="layout-main">{children}</main>
<Footer />
</div>
+83
View File
@@ -0,0 +1,83 @@
.header {
background-color: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
padding: 16px 20px;
}
.header__top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header__title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.header__identity {
display: flex;
align-items: center;
gap: 12px;
}
.header__parent-name {
font-size: 14px;
color: #666;
font-weight: 500;
}
.header__child-select {
padding: 6px 10px;
border: 1px solid #d0d0d0;
border-radius: 4px;
font-size: 14px;
background-color: #fff;
cursor: pointer;
}
.header__child-name {
font-size: 14px;
color: #0066cc;
font-weight: 500;
}
.header__nav {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.header__nav-link {
padding: 8px 14px;
font-size: 14px;
text-decoration: none;
color: #333;
border-radius: 4px;
transition: background-color 0.2s;
}
.header__nav-link:hover {
background-color: #e8e8e8;
}
.header__nav-link--active {
background-color: #0066cc;
color: #fff;
}
.header__logout-button {
padding: 8px 14px;
font-size: 14px;
background-color: #f8f8f8;
border: 1px solid #d0d0d0;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.header__logout-button:hover {
background-color: #e8e8e8;
}