From 1ec7a729085d456d3185a1c74d02f99f40de0e8d Mon Sep 17 00:00:00 2001 From: Urban Modig Date: Fri, 24 Jul 2026 20:21:33 +0200 Subject: [PATCH] feat: add user selection flow --- .gitignore | 3 + README.md | 4 +- backend/pom.xml | 14 +- .../java/se/rubble/hemhub/api/ApiError.java | 5 + .../hemhub/api/ApiExceptionHandler.java | 30 +++ .../rubble/hemhub/user/CreateUserRequest.java | 5 + .../hemhub/user/InvalidUserNameException.java | 5 + .../main/java/se/rubble/hemhub/user/User.java | 48 ++++ .../se/rubble/hemhub/user/UserController.java | 34 +++ .../user/UserNameAlreadyExistsException.java | 5 + .../se/rubble/hemhub/user/UserRepository.java | 11 + .../se/rubble/hemhub/user/UserResponse.java | 12 + .../se/rubble/hemhub/user/UserService.java | 66 +++++ .../src/main/resources/application.properties | 7 + .../db/migration/V1__create_users.sql | 7 + .../se/rubble/hemhub/user/UserApiTest.java | 108 +++++++++ .../src/test/resources/application.properties | 7 + frontend/src/App.test.tsx | 142 ++++++++++- frontend/src/App.tsx | 225 +++++++++++++++--- frontend/src/styles.css | 50 ++++ frontend/src/test/setup.ts | 25 ++ frontend/vite.config.ts | 5 + 22 files changed, 778 insertions(+), 40 deletions(-) create mode 100644 backend/src/main/java/se/rubble/hemhub/api/ApiError.java create mode 100644 backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java create mode 100644 backend/src/main/java/se/rubble/hemhub/user/CreateUserRequest.java create mode 100644 backend/src/main/java/se/rubble/hemhub/user/InvalidUserNameException.java create mode 100644 backend/src/main/java/se/rubble/hemhub/user/User.java create mode 100644 backend/src/main/java/se/rubble/hemhub/user/UserController.java create mode 100644 backend/src/main/java/se/rubble/hemhub/user/UserNameAlreadyExistsException.java create mode 100644 backend/src/main/java/se/rubble/hemhub/user/UserRepository.java create mode 100644 backend/src/main/java/se/rubble/hemhub/user/UserResponse.java create mode 100644 backend/src/main/java/se/rubble/hemhub/user/UserService.java create mode 100644 backend/src/main/resources/application.properties create mode 100644 backend/src/main/resources/db/migration/V1__create_users.sql create mode 100644 backend/src/test/java/se/rubble/hemhub/user/UserApiTest.java create mode 100644 backend/src/test/resources/application.properties diff --git a/.gitignore b/.gitignore index e7dd58f..4dde8d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # Backend backend/target/ +backend/data/ +backend/*.mv.db +backend/*.trace.db # Frontend frontend/node_modules/ diff --git a/README.md b/README.md index a55f41f..300eb3e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ innehåller två separata applikationer: - en backend byggd med Java 21, Spring Boot och Maven - en frontend byggd med React, TypeScript, Vite och pnpm +Backend använder en lokal filbaserad H2-databas i `backend/data`. Databasschemat +hanteras med Flyway. Databasfilerna är lokala och ignoreras av Git. + ## Starta backend Backend startar på port 8080. @@ -43,4 +46,3 @@ Frontend: cd frontend pnpm test ``` - diff --git a/backend/pom.xml b/backend/pom.xml index e0977d3..ffa88bb 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -26,6 +26,19 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-flyway + + + com.h2database + h2 + runtime + org.springframework.boot spring-boot-starter-test @@ -42,4 +55,3 @@ - diff --git a/backend/src/main/java/se/rubble/hemhub/api/ApiError.java b/backend/src/main/java/se/rubble/hemhub/api/ApiError.java new file mode 100644 index 0000000..64d7e33 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/api/ApiError.java @@ -0,0 +1,5 @@ +package se.rubble.hemhub.api; + +public record ApiError(String code, String message) { +} + diff --git a/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java b/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java new file mode 100644 index 0000000..f8601d7 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java @@ -0,0 +1,30 @@ +package se.rubble.hemhub.api; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import se.rubble.hemhub.user.InvalidUserNameException; +import se.rubble.hemhub.user.UserNameAlreadyExistsException; + +@RestControllerAdvice +public class ApiExceptionHandler { + + @ExceptionHandler(InvalidUserNameException.class) + public ResponseEntity handleInvalidUserName() { + return ResponseEntity.badRequest() + .body(new ApiError( + "INVALID_USER_NAME", + "Namnet måste innehålla mellan 1 och 50 tecken.")); + } + + @ExceptionHandler(UserNameAlreadyExistsException.class) + public ResponseEntity handleDuplicateUserName() { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(new ApiError( + "USER_NAME_ALREADY_EXISTS", + "En användare med det namnet finns redan.")); + } +} + diff --git a/backend/src/main/java/se/rubble/hemhub/user/CreateUserRequest.java b/backend/src/main/java/se/rubble/hemhub/user/CreateUserRequest.java new file mode 100644 index 0000000..5967503 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/user/CreateUserRequest.java @@ -0,0 +1,5 @@ +package se.rubble.hemhub.user; + +public record CreateUserRequest(String name) { +} + diff --git a/backend/src/main/java/se/rubble/hemhub/user/InvalidUserNameException.java b/backend/src/main/java/se/rubble/hemhub/user/InvalidUserNameException.java new file mode 100644 index 0000000..b712563 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/user/InvalidUserNameException.java @@ -0,0 +1,5 @@ +package se.rubble.hemhub.user; + +public class InvalidUserNameException extends RuntimeException { +} + diff --git a/backend/src/main/java/se/rubble/hemhub/user/User.java b/backend/src/main/java/se/rubble/hemhub/user/User.java new file mode 100644 index 0000000..ea3298d --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/user/User.java @@ -0,0 +1,48 @@ +package se.rubble.hemhub.user; + +import java.time.Instant; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "app_user") +class User { + + @Id + private UUID id; + + @Column(nullable = false, length = 50) + private String name; + + @Column(name = "normalized_name", nullable = false, length = 150, unique = true) + private String normalizedName; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + protected User() { + } + + User(UUID id, String name, String normalizedName, Instant createdAt) { + this.id = id; + this.name = name; + this.normalizedName = normalizedName; + this.createdAt = createdAt; + } + + UUID getId() { + return id; + } + + String getName() { + return name; + } + + Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/se/rubble/hemhub/user/UserController.java b/backend/src/main/java/se/rubble/hemhub/user/UserController.java new file mode 100644 index 0000000..f3657bb --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/user/UserController.java @@ -0,0 +1,34 @@ +package se.rubble.hemhub.user; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/users") +public class UserController { + + private final UserService userService; + + UserController(UserService userService) { + this.userService = userService; + } + + @GetMapping + public List findAll() { + return userService.findAll(); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public UserResponse create(@RequestBody(required = false) CreateUserRequest request) { + return userService.create(request == null ? null : request.name()); + } +} + diff --git a/backend/src/main/java/se/rubble/hemhub/user/UserNameAlreadyExistsException.java b/backend/src/main/java/se/rubble/hemhub/user/UserNameAlreadyExistsException.java new file mode 100644 index 0000000..e251c67 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/user/UserNameAlreadyExistsException.java @@ -0,0 +1,5 @@ +package se.rubble.hemhub.user; + +public class UserNameAlreadyExistsException extends RuntimeException { +} + diff --git a/backend/src/main/java/se/rubble/hemhub/user/UserRepository.java b/backend/src/main/java/se/rubble/hemhub/user/UserRepository.java new file mode 100644 index 0000000..013b4a9 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/user/UserRepository.java @@ -0,0 +1,11 @@ +package se.rubble.hemhub.user; + +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +interface UserRepository extends JpaRepository { + + boolean existsByNormalizedName(String normalizedName); +} + diff --git a/backend/src/main/java/se/rubble/hemhub/user/UserResponse.java b/backend/src/main/java/se/rubble/hemhub/user/UserResponse.java new file mode 100644 index 0000000..734ce31 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/user/UserResponse.java @@ -0,0 +1,12 @@ +package se.rubble.hemhub.user; + +import java.time.Instant; +import java.util.UUID; + +public record UserResponse(UUID id, String name, Instant createdAt) { + + static UserResponse from(User user) { + return new UserResponse(user.getId(), user.getName(), user.getCreatedAt()); + } +} + diff --git a/backend/src/main/java/se/rubble/hemhub/user/UserService.java b/backend/src/main/java/se/rubble/hemhub/user/UserService.java new file mode 100644 index 0000000..ee7ca3d --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/user/UserService.java @@ -0,0 +1,66 @@ +package se.rubble.hemhub.user; + +import java.time.Clock; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +class UserService { + + private static final Comparator BY_DISPLAY_NAME = + Comparator.comparing(User::getName, String.CASE_INSENSITIVE_ORDER) + .thenComparing(User::getName) + .thenComparing(User::getId); + + private final UserRepository userRepository; + private final Clock clock; + + @Autowired + UserService(UserRepository userRepository) { + this(userRepository, Clock.systemUTC()); + } + + UserService(UserRepository userRepository, Clock clock) { + this.userRepository = userRepository; + this.clock = clock; + } + + @Transactional(readOnly = true) + List findAll() { + return userRepository.findAll().stream() + .sorted(BY_DISPLAY_NAME) + .map(UserResponse::from) + .toList(); + } + + @Transactional + UserResponse create(String requestedName) { + String name = requestedName == null ? "" : requestedName.trim(); + + if (name.isEmpty() || name.codePointCount(0, name.length()) > 50) { + throw new InvalidUserNameException(); + } + + String normalizedName = name.toLowerCase(Locale.ROOT); + + if (userRepository.existsByNormalizedName(normalizedName)) { + throw new UserNameAlreadyExistsException(); + } + + User user = new User(UUID.randomUUID(), name, normalizedName, Instant.now(clock)); + + try { + return UserResponse.from(userRepository.saveAndFlush(user)); + } catch (DataIntegrityViolationException exception) { + throw new UserNameAlreadyExistsException(); + } + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties new file mode 100644 index 0000000..4c260d6 --- /dev/null +++ b/backend/src/main/resources/application.properties @@ -0,0 +1,7 @@ +spring.datasource.url=jdbc:h2:file:./data/hemhub;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.open-in-view=false +spring.flyway.enabled=true + diff --git a/backend/src/main/resources/db/migration/V1__create_users.sql b/backend/src/main/resources/db/migration/V1__create_users.sql new file mode 100644 index 0000000..712842e --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__create_users.sql @@ -0,0 +1,7 @@ +CREATE TABLE app_user ( + id UUID PRIMARY KEY, + name VARCHAR(50) NOT NULL, + normalized_name VARCHAR(150) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT uk_app_user_normalized_name UNIQUE (normalized_name) +); diff --git a/backend/src/test/java/se/rubble/hemhub/user/UserApiTest.java b/backend/src/test/java/se/rubble/hemhub/user/UserApiTest.java new file mode 100644 index 0000000..fe1a43f --- /dev/null +++ b/backend/src/test/java/se/rubble/hemhub/user/UserApiTest.java @@ -0,0 +1,108 @@ +package se.rubble.hemhub.user; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +class UserApiTest { + + @Autowired + private WebApplicationContext context; + + @Autowired + private UserRepository userRepository; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + userRepository.deleteAll(); + mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + @Test + void listsNoUsersWhenDatabaseIsEmpty() throws Exception { + mockMvc.perform(get("/api/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + void createsAndListsUserWithTrimmedName() throws Exception { + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name": " Urban "} + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isString()) + .andExpect(jsonPath("$.name").value("Urban")) + .andExpect(jsonPath("$.createdAt").isString()) + .andExpect(jsonPath("$.normalizedName").doesNotExist()); + + mockMvc.perform(get("/api/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("Urban")); + } + + @Test + void rejectsInvalidNames() throws Exception { + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name": " "} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_USER_NAME")); + + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\": \"%s\"}".formatted("a".repeat(51)))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_USER_NAME")); + } + + @Test + void rejectsDuplicateNameIgnoringCase() throws Exception { + createUser("Urban"); + + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name": "urban"} + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("USER_NAME_ALREADY_EXISTS")); + } + + @Test + void listsUsersSortedByDisplayName() throws Exception { + createUser("Urban"); + createUser("Anna"); + createUser("Bertil"); + + mockMvc.perform(get("/api/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("Anna")) + .andExpect(jsonPath("$[1].name").value("Bertil")) + .andExpect(jsonPath("$[2].name").value("Urban")); + } + + private void createUser(String name) throws Exception { + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\": \"%s\"}".formatted(name))) + .andExpect(status().isCreated()); + } +} diff --git a/backend/src/test/resources/application.properties b/backend/src/test/resources/application.properties new file mode 100644 index 0000000..90f0331 --- /dev/null +++ b/backend/src/test/resources/application.properties @@ -0,0 +1,7 @@ +spring.datasource.url=jdbc:h2:mem:hemhub-test;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1 +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.open-in-view=false +spring.flyway.enabled=true + diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index b6fe831..560cca5 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -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() + + 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() + + 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() + + 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() + + 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() - 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() + + 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() + + 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() + + 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' }, + }) +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9fd5c6e..2098cf8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null) - const [hasError, setHasError] = useState(false) + const [users, setUsers] = useState([]) + const [activeUser, setActiveUser] = useState(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
Laddar HemHub…
+ } + + if (loadState === 'error') { + return ( +
+

HemHub

+

Kunde inte ansluta till HemHub.

+ +
+ ) + } + + if (activeUser) { + return ( +
+

HemHub

+

Hej {activeUser.name}

+

Du är vald som aktiv användare.

+ +
+ ) + } + + if (showCreateUser) { + return ( + 0} + onCancel={() => setShowCreateUser(false)} + onCreated={(user) => { + setUsers((currentUsers) => [...currentUsers, user]) + selectUser(user) + }} + /> + ) } return (

HemHub

-

Frontend har startat.

-

{statusMessage}

+

Vem är du?

+
+ {users.map((user) => ( + + ))} +
+ +
+ ) +} + +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) => { + 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 ( +
+

HemHub

+

Skapa användare

+
void submit(event)}> + + setName(event.target.value)} + /> + {error && ( +

+ {error} +

+ )} + + {hasExistingUsers && ( + + )} +
) } export default App - diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 147e567..837fdfb 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; +} diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 4306b68..98b4738 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1,2 +1,27 @@ import '@testing-library/jest-dom/vitest' +const storedValues = new Map() + +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, +}) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3aa32aa..7389a89 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -11,6 +11,11 @@ export default defineConfig({ }, test: { environment: 'jsdom', + environmentOptions: { + jsdom: { + url: 'http://localhost', + }, + }, setupFiles: './src/test/setup.ts', }, })