diff --git a/README.md b/README.md index 300eb3e..5320d93 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ innehåller två separata applikationer: Backend använder en lokal filbaserad H2-databas i `backend/data`. Databasschemat hanteras med Flyway. Databasfilerna är lokala och ignoreras av Git. +API:t innehåller endpoints under `/api/users` för användare och `/api/tasks` för +att skapa och lista gemensamma hushållsuppgifter. + ## Starta backend Backend startar på port 8080. diff --git a/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java b/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java index f8601d7..c001781 100644 --- a/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java +++ b/backend/src/main/java/se/rubble/hemhub/api/ApiExceptionHandler.java @@ -5,6 +5,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import se.rubble.hemhub.task.InvalidTaskException; import se.rubble.hemhub.user.InvalidUserNameException; import se.rubble.hemhub.user.UserNameAlreadyExistsException; @@ -26,5 +27,11 @@ public class ApiExceptionHandler { "USER_NAME_ALREADY_EXISTS", "En användare med det namnet finns redan.")); } + + @ExceptionHandler(InvalidTaskException.class) + public ResponseEntity handleInvalidTask(InvalidTaskException exception) { + return ResponseEntity.badRequest() + .body(new ApiError("INVALID_TASK", exception.getMessage())); + } } diff --git a/backend/src/main/java/se/rubble/hemhub/task/CreateTaskRequest.java b/backend/src/main/java/se/rubble/hemhub/task/CreateTaskRequest.java new file mode 100644 index 0000000..9efcffc --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/CreateTaskRequest.java @@ -0,0 +1,5 @@ +package se.rubble.hemhub.task; + +public record CreateTaskRequest(String title, String description) { +} + diff --git a/backend/src/main/java/se/rubble/hemhub/task/InvalidTaskException.java b/backend/src/main/java/se/rubble/hemhub/task/InvalidTaskException.java new file mode 100644 index 0000000..2246621 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/InvalidTaskException.java @@ -0,0 +1,9 @@ +package se.rubble.hemhub.task; + +public class InvalidTaskException extends RuntimeException { + + public InvalidTaskException(String message) { + super(message); + } +} + diff --git a/backend/src/main/java/se/rubble/hemhub/task/Task.java b/backend/src/main/java/se/rubble/hemhub/task/Task.java new file mode 100644 index 0000000..fd1a1cb --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/Task.java @@ -0,0 +1,69 @@ +package se.rubble.hemhub.task; + +import java.time.Instant; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "task") +class Task { + + @Id + private UUID id; + + @Column(nullable = false, length = 100) + private String title; + + @Column(length = 500) + private String description; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private TaskStatus status; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + protected Task() { + } + + Task( + UUID id, + String title, + String description, + TaskStatus status, + Instant createdAt) { + this.id = id; + this.title = title; + this.description = description; + this.status = status; + this.createdAt = createdAt; + } + + UUID getId() { + return id; + } + + String getTitle() { + return title; + } + + String getDescription() { + return description; + } + + TaskStatus getStatus() { + return status; + } + + Instant getCreatedAt() { + return createdAt; + } +} + diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskController.java b/backend/src/main/java/se/rubble/hemhub/task/TaskController.java new file mode 100644 index 0000000..37f9221 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskController.java @@ -0,0 +1,36 @@ +package se.rubble.hemhub.task; + +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/tasks") +public class TaskController { + + private final TaskService taskService; + + TaskController(TaskService taskService) { + this.taskService = taskService; + } + + @GetMapping + public List findAll() { + return taskService.findAll(); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public TaskResponse create(@RequestBody(required = false) CreateTaskRequest request) { + return taskService.create( + request == null ? null : request.title(), + request == null ? null : request.description()); + } +} + diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskRepository.java b/backend/src/main/java/se/rubble/hemhub/task/TaskRepository.java new file mode 100644 index 0000000..167e194 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskRepository.java @@ -0,0 +1,12 @@ +package se.rubble.hemhub.task; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +interface TaskRepository extends JpaRepository { + + List findAllByOrderByCreatedAtAscIdAsc(); +} + diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskResponse.java b/backend/src/main/java/se/rubble/hemhub/task/TaskResponse.java new file mode 100644 index 0000000..8712318 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskResponse.java @@ -0,0 +1,22 @@ +package se.rubble.hemhub.task; + +import java.time.Instant; +import java.util.UUID; + +public record TaskResponse( + UUID id, + String title, + String description, + TaskStatus status, + Instant createdAt) { + + static TaskResponse from(Task task) { + return new TaskResponse( + task.getId(), + task.getTitle(), + task.getDescription(), + task.getStatus(), + task.getCreatedAt()); + } +} + diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskService.java b/backend/src/main/java/se/rubble/hemhub/task/TaskService.java new file mode 100644 index 0000000..e062280 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskService.java @@ -0,0 +1,73 @@ +package se.rubble.hemhub.task; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +class TaskService { + + private final TaskRepository taskRepository; + private final Clock clock; + + @Autowired + TaskService(TaskRepository taskRepository) { + this(taskRepository, Clock.systemUTC()); + } + + TaskService(TaskRepository taskRepository, Clock clock) { + this.taskRepository = taskRepository; + this.clock = clock; + } + + @Transactional(readOnly = true) + List findAll() { + return taskRepository.findAllByOrderByCreatedAtAscIdAsc().stream() + .map(TaskResponse::from) + .toList(); + } + + @Transactional + TaskResponse create(String requestedTitle, String requestedDescription) { + String title = requestedTitle == null ? "" : requestedTitle.trim(); + String description = normalizeDescription(requestedDescription); + + if (title.isEmpty() || codePointLength(title) > 100) { + throw new InvalidTaskException( + "Titeln måste innehålla mellan 1 och 100 tecken."); + } + + if (description != null && codePointLength(description) > 500) { + throw new InvalidTaskException( + "Beskrivningen får innehålla högst 500 tecken."); + } + + Task task = new Task( + UUID.randomUUID(), + title, + description, + TaskStatus.WAITING, + Instant.now(clock)); + + return TaskResponse.from(taskRepository.save(task)); + } + + private static String normalizeDescription(String requestedDescription) { + if (requestedDescription == null) { + return null; + } + + String description = requestedDescription.trim(); + return description.isEmpty() ? null : description; + } + + private static int codePointLength(String value) { + return value.codePointCount(0, value.length()); + } +} + diff --git a/backend/src/main/java/se/rubble/hemhub/task/TaskStatus.java b/backend/src/main/java/se/rubble/hemhub/task/TaskStatus.java new file mode 100644 index 0000000..b7276d3 --- /dev/null +++ b/backend/src/main/java/se/rubble/hemhub/task/TaskStatus.java @@ -0,0 +1,8 @@ +package se.rubble.hemhub.task; + +public enum TaskStatus { + WAITING, + IN_PROGRESS, + COMPLETED +} + diff --git a/backend/src/main/resources/db/migration/V2__create_tasks.sql b/backend/src/main/resources/db/migration/V2__create_tasks.sql new file mode 100644 index 0000000..9662b08 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__create_tasks.sql @@ -0,0 +1,8 @@ +CREATE TABLE task ( + id UUID PRIMARY KEY, + title VARCHAR(100) NOT NULL, + description VARCHAR(500), + status VARCHAR(20) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL +); + diff --git a/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java b/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java new file mode 100644 index 0000000..cc6fdba --- /dev/null +++ b/backend/src/test/java/se/rubble/hemhub/task/TaskApiTest.java @@ -0,0 +1,107 @@ +package se.rubble.hemhub.task; + +import java.time.Instant; +import java.util.UUID; + +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 TaskApiTest { + + @Autowired + private WebApplicationContext context; + + @Autowired + private TaskRepository taskRepository; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + taskRepository.deleteAll(); + mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + @Test + void createsWaitingTaskWithTrimmedValues() throws Exception { + mockMvc.perform(post("/api/tasks") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": " Dammsuga ", + "description": " Bottenvåningen " + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isString()) + .andExpect(jsonPath("$.title").value("Dammsuga")) + .andExpect(jsonPath("$.description").value("Bottenvåningen")) + .andExpect(jsonPath("$.status").value("WAITING")) + .andExpect(jsonPath("$.createdAt").isString()); + } + + @Test + void storesBlankDescriptionAsNull() throws Exception { + mockMvc.perform(post("/api/tasks") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title": "Dammsuga", "description": " "} + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.description").value((Object) null)); + } + + @Test + void rejectsBlankAndTooLongTitles() throws Exception { + assertInvalidTask(""" + {"title": " "} + """); + assertInvalidTask("{\"title\": \"%s\"}".formatted("a".repeat(101))); + } + + @Test + void rejectsTooLongDescription() throws Exception { + assertInvalidTask(""" + {"title": "Dammsuga", "description": "%s"} + """.formatted("a".repeat(501))); + } + + @Test + void listsTasksOldestFirstWithIdAsTieBreaker() throws Exception { + Instant older = Instant.parse("2026-07-24T10:00:00Z"); + Instant newer = Instant.parse("2026-07-24T11:00:00Z"); + UUID firstId = UUID.fromString("00000000-0000-0000-0000-000000000001"); + UUID secondId = UUID.fromString("00000000-0000-0000-0000-000000000002"); + UUID newestId = UUID.fromString("00000000-0000-0000-0000-000000000003"); + + taskRepository.save(new Task(newestId, "Nyast", null, TaskStatus.WAITING, newer)); + taskRepository.save(new Task(secondId, "Andra", null, TaskStatus.IN_PROGRESS, older)); + taskRepository.save(new Task(firstId, "Första", null, TaskStatus.COMPLETED, older)); + + mockMvc.perform(get("/api/tasks")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].title").value("Första")) + .andExpect(jsonPath("$[1].title").value("Andra")) + .andExpect(jsonPath("$[2].title").value("Nyast")); + } + + private void assertInvalidTask(String body) throws Exception { + mockMvc.perform(post("/api/tasks") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_TASK")); + } +} diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 560cca5..beef821 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, expect, test, vi } from 'vitest' import App from './App' @@ -15,6 +15,30 @@ const users = [ }, ] +const tasks = [ + { + id: '00000000-0000-0000-0000-000000000001', + title: 'Dammsuga', + description: 'Bottenvåningen', + status: 'WAITING', + createdAt: '2026-07-24T10:00:00Z', + }, + { + id: '00000000-0000-0000-0000-000000000002', + title: 'Diska', + description: null, + status: 'IN_PROGRESS', + createdAt: '2026-07-24T10:01:00Z', + }, + { + id: '00000000-0000-0000-0000-000000000003', + title: 'Vattna blommor', + description: null, + status: 'COMPLETED', + createdAt: '2026-07-24T10:02:00Z', + }, +] + beforeEach(() => { window.localStorage.clear() }) @@ -42,13 +66,14 @@ test('befintliga användare utan lokalt val visar Vem är du', async () => { expect(screen.getByRole('button', { name: 'Anna' })).toBeInTheDocument() }) -test('val av användare lagrar id och visar startsidan', async () => { - mockJsonResponse(users) +test('val av användare lagrar id och visar brädan', async () => { + mockUsersAndTasks(users, []) render() fireEvent.click(await screen.findByRole('button', { name: 'Urban' })) - expect(screen.getByRole('heading', { name: 'Hej Urban' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { name: 'Uppgifter' })).toBeInTheDocument() + expect(screen.getByText('Urban')).toBeInTheDocument() expect(window.localStorage.getItem('hemhub.activeUserId')).toBe(users[0].id) }) @@ -57,22 +82,23 @@ test('skapad användare blir automatiskt aktiv', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse([])) fetchMock.mockResolvedValueOnce(jsonResponse(createdUser, 201)) + fetchMock.mockResolvedValueOnce(jsonResponse([])) 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(await screen.findByRole('heading', { name: 'Uppgifter' })).toBeInTheDocument() expect(window.localStorage.getItem('hemhub.activeUserId')).toBe(createdUser.id) - expect(fetchMock).toHaveBeenLastCalledWith('/api/users', { + expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Urban' }), }) }) -test('skapandefel visas utan att namnet försvinner', async () => { +test('skapandefel för användare visas utan att namnet försvinner', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(jsonResponse([])) fetchMock.mockResolvedValueOnce( @@ -97,18 +123,20 @@ test('skapandefel visas utan att namnet försvinner', async () => { expect(input).toHaveValue('Urban') }) -test('Byt användare rensar valt id och visar användarvalet', async () => { +test('Logga ut rensar valt id och väljer inte användaren automatiskt', async () => { window.localStorage.setItem('hemhub.activeUserId', users[0].id) - mockJsonResponse(users) + mockUsersAndTasks(users, []) render() - fireEvent.click(await screen.findByRole('button', { name: 'Byt användare' })) + fireEvent.click(await screen.findByRole('button', { name: 'Logga ut' })) expect(screen.getByRole('heading', { name: 'Vem är du?' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Urban' })).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Uppgifter' })).not.toBeInTheDocument() expect(window.localStorage.getItem('hemhub.activeUserId')).toBeNull() }) -test('hämtningsfel visas som fel och kan återförsökas', async () => { +test('hämtningsfel för användare 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)) @@ -133,6 +161,147 @@ test('ogiltigt lagrat användar-id rensas', async () => { expect(window.localStorage.getItem('hemhub.activeUserId')).toBeNull() }) +test('brädan visar tre kolumner och grupperar hämtade uppgifter', async () => { + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + mockUsersAndTasks(users, tasks) + + render() + + const waiting = await screen.findByRole('region', { name: 'Väntande' }) + const inProgress = screen.getByRole('region', { name: 'Pågående' }) + const completed = screen.getByRole('region', { name: 'Klart' }) + + expect(within(waiting).getByText('Dammsuga')).toBeInTheDocument() + expect(within(waiting).getByText('Bottenvåningen')).toBeInTheDocument() + expect(within(inProgress).getByText('Diska')).toBeInTheDocument() + expect(within(completed).getByText('Vattna blommor')).toBeInTheDocument() + expect(screen.queryByText(/Inga uppgifter/i)).not.toBeInTheDocument() +}) + +test('Ny uppgift öppnar modalen med fokus i titelfältet', async () => { + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + mockUsersAndTasks(users, []) + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Ny uppgift' })) + + expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument() + expect(screen.getByLabelText('Titel')).toHaveFocus() +}) + +test.each([ + { + sätt: 'stängningskrysset', + close: () => fireEvent.click(screen.getByRole('button', { name: 'Stäng' })), + }, + { + sätt: 'Escape', + close: () => fireEvent.keyDown(window, { key: 'Escape' }), + }, + { + sätt: 'modalens bakgrund', + close: () => { + const backdrop = screen.getByRole('dialog', { name: 'Skapa ny uppgift' }).parentElement + + if (!backdrop) { + throw new Error('Modalens bakgrund saknas') + } + + fireEvent.mouseDown(backdrop) + }, + }, +])('modalen stängs med $sätt och rensar formuläret', async ({ close }) => { + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + mockUsersAndTasks(users, []) + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Ny uppgift' })) + fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Dammsuga' } }) + fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), { + target: { value: 'Bottenvåningen' }, + }) + + close() + + expect(screen.queryByRole('dialog', { name: 'Skapa ny uppgift' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' })) + + expect(screen.getByLabelText('Titel')).toHaveValue('') + expect(screen.getByLabelText('Beskrivning (valfri)')).toHaveValue('') +}) + +test('en skapad uppgift visas längst ned i Väntande och modalen stängs', async () => { + const createdTask = { + id: '00000000-0000-0000-0000-000000000004', + title: 'Putsa fönster', + description: 'Köket', + status: 'WAITING', + createdAt: '2026-07-24T10:03:00Z', + } + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(jsonResponse(users)) + fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]])) + fetchMock.mockResolvedValueOnce(jsonResponse(createdTask, 201)) + + render() + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) + fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' })) + fireEvent.change(screen.getByLabelText('Titel'), { target: { value: ' Putsa fönster ' } }) + fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), { + target: { value: ' Köket ' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) + + await waitFor(() => + expect(screen.queryByRole('dialog', { name: 'Skapa ny uppgift' })).not.toBeInTheDocument(), + ) + const waiting = screen.getByRole('region', { name: 'Väntande' }) + expect(within(waiting).getAllByRole('article').map((card) => card.textContent)).toEqual([ + 'DammsugaBottenvåningen', + 'Putsa fönsterKöket', + ]) + expect(fetchMock).toHaveBeenLastCalledWith('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Putsa fönster', description: 'Köket' }), + }) +}) + +test('formulärdata bevaras när skapande av uppgift misslyckas', async () => { + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(jsonResponse(users)) + fetchMock.mockResolvedValueOnce(jsonResponse([])) + fetchMock.mockResolvedValueOnce( + jsonResponse({ code: 'INVALID_TASK', message: 'Uppgiften är ogiltig.' }, 400), + ) + + render() + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) + fireEvent.click(screen.getByRole('button', { name: 'Ny uppgift' })) + const title = screen.getByLabelText('Titel') + const description = screen.getByLabelText('Beskrivning (valfri)') + fireEvent.change(title, { target: { value: 'Dammsuga' } }) + fireEvent.change(description, { target: { value: 'Bottenvåningen' } }) + fireEvent.click(screen.getByRole('button', { name: 'Skapa uppgift' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('Uppgiften är ogiltig.') + expect(screen.getByRole('dialog', { name: 'Skapa ny uppgift' })).toBeInTheDocument() + expect(title).toHaveValue('Dammsuga') + expect(description).toHaveValue('Bottenvåningen') +}) + +function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) { + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(jsonResponse(userResponse)) + fetchMock.mockResolvedValueOnce(jsonResponse(taskResponse)) + return fetchMock +} + function mockJsonResponse(body: unknown, status = 200) { return vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(body, status)) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2098cf8..d81b2cb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { FormEvent, useEffect, useRef, useState } from 'react' +import TaskBoard from './TaskBoard' type User = { id: string @@ -55,19 +56,19 @@ function App() { setActiveUser(user) } - const switchUser = () => { + const logOut = () => { window.localStorage.removeItem(ACTIVE_USER_KEY) setActiveUser(null) setShowCreateUser(false) } if (loadState === 'loading') { - return
Laddar HemHub…
+ return
Laddar HemHub…
} if (loadState === 'error') { return ( -
+

HemHub

Kunde inte ansluta till HemHub.

-
- ) + return } if (showCreateUser) { @@ -104,7 +96,7 @@ function App() { } return ( -
+

HemHub

Vem är du?

@@ -183,7 +175,7 @@ function CreateUserForm({ hasExistingUsers, onCancel, onCreated }: CreateUserFor } return ( -
+

HemHub

Skapa användare

void submit(event)}> diff --git a/frontend/src/TaskBoard.tsx b/frontend/src/TaskBoard.tsx new file mode 100644 index 0000000..720041a --- /dev/null +++ b/frontend/src/TaskBoard.tsx @@ -0,0 +1,249 @@ +import { FormEvent, MouseEvent, useEffect, useRef, useState } from 'react' + +type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED' + +type Task = { + id: string + title: string + description: string | null + status: TaskStatus + createdAt: string +} + +type ApiError = { + message?: string +} + +type TaskBoardProps = { + activeUserName: string + onLogOut: () => void +} + +const columns: { status: TaskStatus; title: string }[] = [ + { status: 'WAITING', title: 'Väntande' }, + { status: 'IN_PROGRESS', title: 'Pågående' }, + { status: 'COMPLETED', title: 'Klart' }, +] + +function TaskBoard({ activeUserName, onLogOut }: TaskBoardProps) { + const [tasks, setTasks] = useState([]) + const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading') + const [showCreateTask, setShowCreateTask] = useState(false) + + const loadTasks = async () => { + setLoadState('loading') + + try { + const response = await fetch('/api/tasks') + + if (!response.ok) { + throw new Error('Kunde inte hämta uppgifter') + } + + setTasks((await response.json()) as Task[]) + setLoadState('ready') + } catch { + setLoadState('error') + } + } + + useEffect(() => { + void loadTasks() + }, []) + + return ( +
+
+
+

HemHub

+

Uppgifter

+
+
+ {activeUserName} + +
+
+ +
+
+ {loadState === 'loading' && 'Laddar uppgifter…'} + {loadState === 'error' && ( + <> + Kunde inte hämta uppgifter. + + + )} +
+ +
+ +
+ {columns.map((column) => ( +
+

{column.title}

+
+ {tasks + .filter((task) => task.status === column.status) + .map((task) => ( +
+

{task.title}

+ {task.description &&

{task.description}

} +
+ ))} +
+
+ ))} +
+ + {showCreateTask && ( + setShowCreateTask(false)} + onCreated={(task) => { + setTasks((currentTasks) => [...currentTasks, task]) + setShowCreateTask(false) + }} + /> + )} +
+ ) +} + +type CreateTaskModalProps = { + onClose: () => void + onCreated: (task: Task) => void +} + +function CreateTaskModal({ onClose, onCreated }: CreateTaskModalProps) { + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [error, setError] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const isSubmittingRef = useRef(false) + + useEffect(() => { + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !isSubmittingRef.current) { + onClose() + } + } + + window.addEventListener('keydown', closeOnEscape) + return () => window.removeEventListener('keydown', closeOnEscape) + }, [onClose]) + + const closeFromBackdrop = (event: MouseEvent) => { + if (event.target === event.currentTarget && !isSubmittingRef.current) { + onClose() + } + } + + const submit = async (event: FormEvent) => { + event.preventDefault() + + if (isSubmittingRef.current) { + return + } + + const trimmedTitle = title.trim() + const trimmedDescription = description.trim() + + if (!trimmedTitle || [...trimmedTitle].length > 100) { + setError('Titeln måste innehålla mellan 1 och 100 tecken.') + return + } + + if ([...trimmedDescription].length > 500) { + setError('Beskrivningen får innehålla högst 500 tecken.') + return + } + + setError('') + isSubmittingRef.current = true + setIsSubmitting(true) + + try { + const response = await fetch('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: trimmedTitle, + description: trimmedDescription || null, + }), + }) + + if (!response.ok) { + const apiError = (await response.json().catch(() => ({}))) as ApiError + setError(apiError.message ?? 'Det gick inte att skapa uppgiften. Försök igen.') + return + } + + onCreated((await response.json()) as Task) + } catch { + setError('Det gick inte att skapa uppgiften. Försök igen.') + } finally { + isSubmittingRef.current = false + setIsSubmitting(false) + } + } + + return ( +
+
+
+

Skapa ny uppgift

+ +
+ void submit(event)}> + + setTitle(event.target.value)} + /> + + +