5 Commits

Author SHA1 Message Date
443f686c20 feat: add task editing 2026-07-28 11:18:13 +02:00
b6460a3924 docs: close feature 7 2026-07-27 22:06:08 +02:00
5df0146672 Merge pull request 'feat: add task deletion' (#11) from feature/007-task-deletion into main
Reviewed-on: #11
2026-07-27 21:27:25 +02:00
f296d15446 feat: add task deletion 2026-07-27 21:26:19 +02:00
5dea4c4027 docs: close feature 6 2026-07-27 13:21:17 +02:00
16 changed files with 2428 additions and 54 deletions

View File

@ -10,9 +10,10 @@ Backend använder en lokal H2-databas i minnet. Databasschemat hanteras med
Flyway, och lokal utvecklingsdata återställs när backend startas om.
API:t innehåller endpoints under `/api/users` för användare och `/api/tasks` för
att skapa, lista, tilldela och ändra status på gemensamma hushållsuppgifter.
Uppgiftskort kan flyttas mellan brädans statuskolumner med drag-and-drop eller
med de befintliga statusknapparna.
att skapa, lista, redigera, tilldela, ändra status på och permanent radera
gemensamma hushållsuppgifter. Uppgiftskort kan flyttas mellan brädans
statuskolumner med drag-and-drop eller med de befintliga statusknapparna.
Redigering och radering genomförs först när backend har bekräftat operationen.
## Starta backend

View File

@ -112,6 +112,17 @@ class Task {
status = targetStatus;
}
void changeDetails(String title, String description, int points) {
if (points < 1 || points > 99) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
this.title = title;
this.description = description;
this.points = points;
}
Instant getCreatedAt() {
return createdAt;
}

View File

@ -4,6 +4,7 @@ import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
@ -65,4 +66,25 @@ public class TaskController {
request.parsedStatus(),
request.parsedActiveUserId().value());
}
@PutMapping("/{taskId}/details")
public TaskResponse updateDetails(
@PathVariable UUID taskId,
@RequestBody(required = false) UpdateTaskDetailsRequest request) {
if (request == null) {
throw new InvalidTaskException("Requesten måste innehålla uppgiftsdetaljer.");
}
return taskService.updateDetails(
taskId,
request.parsedTitle(),
request.parsedDescription(),
request.parsedPoints());
}
@DeleteMapping("/{taskId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable UUID taskId) {
taskService.delete(taskId);
}
}

View File

@ -43,23 +43,9 @@ class TaskService {
String requestedDescription,
Integer requestedPoints,
UUID requestedAssigneeId) {
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.");
}
if (requestedPoints == null) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
String title = validateTitle(requestedTitle);
String description = validateDescription(requestedDescription);
int points = validatePoints(requestedPoints);
User assignee = findAssignee(requestedAssigneeId);
Task task = new Task(
@ -67,7 +53,7 @@ class TaskService {
title,
description,
TaskStatus.WAITING,
requestedPoints,
points,
assignee,
Instant.now(clock));
@ -104,6 +90,29 @@ class TaskService {
return TaskResponse.from(task);
}
@Transactional
TaskResponse updateDetails(
UUID taskId,
String requestedTitle,
String requestedDescription,
Integer requestedPoints) {
Task task = taskRepository.findOneById(taskId)
.orElseThrow(TaskNotFoundException::new);
String title = validateTitle(requestedTitle);
String description = validateDescription(requestedDescription);
int points = validatePoints(requestedPoints);
task.changeDetails(title, description, points);
return TaskResponse.from(task);
}
@Transactional
void delete(UUID taskId) {
Task task = taskRepository.findById(taskId)
.orElseThrow(TaskNotFoundException::new);
taskRepository.delete(task);
}
private User findAssignee(UUID requestedAssigneeId) {
if (requestedAssigneeId == null) {
return null;
@ -113,13 +122,37 @@ class TaskService {
.orElseThrow(AssigneeNotFoundException::new);
}
private static String normalizeDescription(String requestedDescription) {
private static String validateTitle(String requestedTitle) {
String title = requestedTitle == null ? "" : requestedTitle.trim();
if (title.isEmpty() || codePointLength(title) > 100) {
throw new InvalidTaskException(
"Titeln måste innehålla mellan 1 och 100 tecken.");
}
return title;
}
private static String validateDescription(String requestedDescription) {
if (requestedDescription == null) {
return null;
}
String description = requestedDescription.trim();
return description.isEmpty() ? null : description;
if (description.isEmpty()) {
return null;
}
if (codePointLength(description) > 500) {
throw new InvalidTaskException(
"Beskrivningen får innehålla högst 500 tecken.");
}
return description;
}
private static int validatePoints(Integer requestedPoints) {
if (requestedPoints == null || requestedPoints < 1 || requestedPoints > 99) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
return requestedPoints;
}
private static int codePointLength(String value) {

View File

@ -0,0 +1,40 @@
package se.rubble.hemhub.task;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.JsonNodeType;
public record UpdateTaskDetailsRequest(
JsonNode title,
JsonNode description,
JsonNode points) {
String parsedTitle() {
if (title == null || title.getNodeType() != JsonNodeType.STRING) {
throw new InvalidTaskException(
"Titeln måste innehålla mellan 1 och 100 tecken.");
}
return title.stringValue();
}
String parsedDescription() {
if (description == null) {
throw new InvalidTaskException("Fältet description måste anges.");
}
if (description.isNull()) {
return null;
}
if (description.getNodeType() != JsonNodeType.STRING) {
throw new InvalidTaskException(
"Beskrivningen får innehålla högst 500 tecken.");
}
return description.stringValue();
}
Integer parsedPoints() {
if (points == null || !points.isIntegralNumber() || !points.canConvertToInt()) {
throw new InvalidTaskException(
"Poäng måste vara ett heltal mellan 1 och 99.");
}
return points.intValue();
}
}

View File

@ -0,0 +1,128 @@
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.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
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 se.rubble.hemhub.user.User;
import se.rubble.hemhub.user.UserRepository;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
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.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
class TaskDeletionApiTest {
@Autowired
private WebApplicationContext context;
@Autowired
private TaskRepository taskRepository;
@Autowired
private UserRepository userRepository;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
taskRepository.deleteAll();
userRepository.deleteAll();
mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@ParameterizedTest
@EnumSource(TaskStatus.class)
void deletesTaskInEveryStatus(TaskStatus statusValue) throws Exception {
User assignee = createUser();
Task task = saveTask(statusValue, assignee);
mockMvc.perform(delete("/api/tasks/{taskId}", task.getId()))
.andExpect(status().isNoContent())
.andExpect(content().string(""));
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
org.junit.jupiter.api.Assertions.assertTrue(userRepository.existsById(assignee.getId()));
}
@Test
void deletesOnlyRequestedTask() throws Exception {
User assignee = createUser();
Task deleted = saveTask(TaskStatus.WAITING, assignee);
Task remaining = saveTask(TaskStatus.COMPLETED, null);
mockMvc.perform(delete("/api/tasks/{taskId}", deleted.getId()))
.andExpect(status().isNoContent());
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(remaining.getId().toString()))
.andExpect(jsonPath("$[0].status").value("COMPLETED"));
org.junit.jupiter.api.Assertions.assertTrue(userRepository.existsById(assignee.getId()));
}
@Test
void returnsNotFoundForUnknownAndAlreadyDeletedTask() throws Exception {
Task task = saveTask(TaskStatus.WAITING, null);
mockMvc.perform(delete("/api/tasks/{taskId}", task.getId()))
.andExpect(status().isNoContent());
mockMvc.perform(delete("/api/tasks/{taskId}", task.getId()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"));
mockMvc.perform(delete(
"/api/tasks/{taskId}",
"00000000-0000-0000-0000-000000000099"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"));
}
@Test
void keepsExistingBadRequestForInvalidUuid() throws Exception {
mockMvc.perform(delete("/api/tasks/{taskId}", "inte-ett-uuid"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT"));
}
private User createUser() throws Exception {
String response = mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "Urban"}
"""))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
String id = com.jayway.jsonpath.JsonPath.read(response, "$.id");
return userRepository.findById(UUID.fromString(id)).orElseThrow();
}
private Task saveTask(TaskStatus statusValue, User assignee) {
return taskRepository.save(new Task(
UUID.randomUUID(),
"Dammsuga",
"Bottenvåningen",
statusValue,
7,
assignee,
Instant.parse("2026-07-28T09:00:00Z")));
}
}

View File

@ -0,0 +1,245 @@
package se.rubble.hemhub.task;
import java.time.Instant;
import java.util.UUID;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
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 se.rubble.hemhub.user.User;
import se.rubble.hemhub.user.UserRepository;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
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.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
class TaskEditingApiTest {
private static final Instant CREATED_AT = Instant.parse("2026-07-27T09:00:00Z");
@Autowired
private WebApplicationContext context;
@Autowired
private TaskRepository taskRepository;
@Autowired
private UserRepository userRepository;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
taskRepository.deleteAll();
userRepository.deleteAll();
mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@ParameterizedTest
@EnumSource(TaskStatus.class)
void updatesDetailsInEveryStatusAndPreservesOtherFields(TaskStatus statusValue) throws Exception {
User assignee = createUser();
Task task = saveTask(statusValue, assignee, "Före", "Gammal", 3);
mockMvc.perform(put("/api/tasks/{taskId}/details", task.getId())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": " Efter ",
"description": " Ny beskrivning ",
"points": 7
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(task.getId().toString()))
.andExpect(jsonPath("$.title").value("Efter"))
.andExpect(jsonPath("$.description").value("Ny beskrivning"))
.andExpect(jsonPath("$.points").value(7))
.andExpect(jsonPath("$.status").value(statusValue.name()))
.andExpect(jsonPath("$.assignee.id").value(assignee.getId().toString()))
.andExpect(jsonPath("$.assignee.name").value(assignee.getName()))
.andExpect(jsonPath("$.createdAt").value(CREATED_AT.toString()));
Task updated = taskRepository.findOneById(task.getId()).orElseThrow();
assertEquals(statusValue, updated.getStatus());
assertEquals(assignee.getId(), updated.getAssignee().getId());
assertEquals(CREATED_AT, updated.getCreatedAt());
assertTrue(userRepository.existsById(assignee.getId()));
}
@Test
void acceptsBoundariesBlankDescriptionAndUnchangedValues() throws Exception {
String title = "å".repeat(100);
String description = "å".repeat(500);
Task task = saveTask(TaskStatus.WAITING, null, title, description, 1);
updateDetails(task.getId(), title, description, 99)
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value(title))
.andExpect(jsonPath("$.description").value(description))
.andExpect(jsonPath("$.points").value(99));
updateDetails(task.getId(), title, " ", 1)
.andExpect(status().isOk())
.andExpect(jsonPath("$.description").doesNotExist())
.andExpect(jsonPath("$.points").value(1));
updateDetails(task.getId(), title, null, 1)
.andExpect(status().isOk())
.andExpect(jsonPath("$.description").doesNotExist());
}
@ParameterizedTest(name = "{1}")
@MethodSource("invalidRequests")
void rejectsInvalidOrIncompleteRequests(String request, String description) throws Exception {
Task task = saveTask(TaskStatus.WAITING, null, "Före", null, 3);
mockMvc.perform(put("/api/tasks/{taskId}/details", task.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(request))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK"));
}
@Test
void returnsNotFoundAndKeepsOtherTaskUnchanged() throws Exception {
Task otherTask = saveTask(TaskStatus.COMPLETED, null, "Annan", "Oförändrad", 9);
UUID unknownId = UUID.randomUUID();
updateDetails(unknownId, "Ny", null, 4)
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"));
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(otherTask.getId().toString()))
.andExpect(jsonPath("$[0].title").value("Annan"))
.andExpect(jsonPath("$[0].description").value("Oförändrad"))
.andExpect(jsonPath("$[0].points").value(9));
}
@Test
void keepsExistingBadRequestForInvalidUuid() throws Exception {
mockMvc.perform(put("/api/tasks/{taskId}/details", "inte-ett-uuid")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"title": "Ny", "description": null, "points": 4}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_TASK_ASSIGNMENT"));
}
private org.springframework.test.web.servlet.ResultActions updateDetails(
UUID taskId,
String title,
String description,
int points) throws Exception {
return mockMvc.perform(put("/api/tasks/{taskId}/details", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": %s,
"description": %s,
"points": %d
}
""".formatted(jsonString(title), jsonString(description), points)));
}
private User createUser() throws Exception {
String response = mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "Urban"}
"""))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getContentAsString();
String id = com.jayway.jsonpath.JsonPath.read(response, "$.id");
return userRepository.findById(UUID.fromString(id)).orElseThrow();
}
private Task saveTask(
TaskStatus statusValue,
User assignee,
String title,
String description,
int points) {
return taskRepository.save(new Task(
UUID.randomUUID(),
title,
description,
statusValue,
points,
assignee,
CREATED_AT));
}
private static Stream<Arguments> invalidRequests() {
return Stream.of(
Arguments.of("{}", "alla fält saknas"),
Arguments.of("""
{"description": null, "points": 1}
""", "titel saknas"),
Arguments.of("""
{"title": null, "description": null, "points": 1}
""", "titel är null"),
Arguments.of("""
{"title": " ", "description": null, "points": 1}
""", "titel är blank"),
Arguments.of("""
{"title": "%s", "description": null, "points": 1}
""".formatted("a".repeat(101)), "titel är för lång"),
Arguments.of("""
{"title": "Titel", "description": "%s", "points": 1}
""".formatted("a".repeat(501)), "beskrivning är för lång"),
Arguments.of("""
{"title": "Titel", "points": 1}
""", "beskrivning saknas"),
Arguments.of("""
{"title": "Titel", "description": null}
""", "poäng saknas"),
Arguments.of("""
{"title": "Titel", "description": null, "points": null}
""", "poäng är null"),
Arguments.of("""
{"title": "Titel", "description": null, "points": "7"}
""", "poäng är text"),
Arguments.of("""
{"title": "Titel", "description": null, "points": 1.5}
""", "poäng är decimal"),
Arguments.of("""
{"title": "Titel", "description": null, "points": 0}
""", "poäng är noll"),
Arguments.of("""
{"title": "Titel", "description": null, "points": -1}
""", "poäng är negativ"),
Arguments.of("""
{"title": "Titel", "description": null, "points": 100}
""", "poäng är för hög"));
}
private static String jsonString(String value) {
if (value == null) {
return "null";
}
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
}

View File

@ -5,6 +5,7 @@ import java.util.UUID;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class TaskTest {
@ -29,6 +30,25 @@ class TaskTest {
Instant.parse("2026-07-26T12:00:00Z")));
}
@Test
void changesOnlyEditableDetailsAndProtectsPointsInvariant() {
Task task = taskWithPoints(3);
UUID id = task.getId();
TaskStatus status = task.getStatus();
Instant createdAt = task.getCreatedAt();
task.changeDetails("Ny titel", "Ny beskrivning", 7);
assertEquals(id, task.getId());
assertEquals("Ny titel", task.getTitle());
assertEquals("Ny beskrivning", task.getDescription());
assertEquals(7, task.getPoints());
assertEquals(status, task.getStatus());
assertEquals(createdAt, task.getCreatedAt());
assertThrows(InvalidTaskException.class, () -> task.changeDetails("Titel", null, 0));
assertThrows(InvalidTaskException.class, () -> task.changeDetails("Titel", null, 100));
}
private Task taskWithPoints(int points) {
return new Task(
UUID.randomUUID(),

View File

@ -29,9 +29,11 @@ dnd-kit-ekosystemets aktuella React-adapter. Den ansvarar för:
- hämtning och presentation av användare och uppgifter;
- lokalt val av aktiv användare;
- formulär för att skapa användare och uppgifter;
- serverbekräftad redigering av uppgifters titel, beskrivning och poäng;
- val och visning av ansvarig användare på uppgifter;
- serverbekräftade statusändringar genom knappar på uppgiftskorten;
- optimistiska statusflyttar genom drag-and-drop mellan brädans kolumner;
- bekräftad och serverbekräftad permanent radering av uppgifter;
- klientnära validering och begripliga felmeddelanden;
- uppgiftsbrädan med kolumnerna Väntande, Pågående och Klart.
@ -65,6 +67,8 @@ Aktuella endpoints:
- `POST /api/tasks`
- `PUT /api/tasks/{taskId}/assignee`
- `PUT /api/tasks/{taskId}/status`
- `PUT /api/tasks/{taskId}/details`
- `DELETE /api/tasks/{taskId}`
### Databas och migreringar
@ -129,6 +133,10 @@ Alla direkta statusövergångar är tillåtna och samma målstatus är idempoten
frontend aktiv användares id, och backend tilldelar användaren och ändrar status
i samma transaktion. En befintlig ansvarig byts aldrig av statusoperationen.
Uppgifter raderas fysiskt genom task-repositoryt. Det finns ingen
mjukraderingsflagga, papperskorg eller återställningsmodell. Radering av en
uppgift påverkar inte dess ansvariga användare.
### Aktiv användare
Användarlistan hämtas från backend. Frontend lagrar endast den valda
@ -156,6 +164,12 @@ backendens fullständiga respons. Status- och tilldelningsanrop delar låsning p
task-id, så det berörda kortet blockeras utan att resten av brädan låses.
Drag-and-drop återanvänder backendens befintliga status-API oförändrat.
Radering är serverbekräftad och använder samma låsning per task-id. Kortet och
bekräftelsedialogen ligger kvar tills backend svarar. Vid `204 No Content`
tas kortet bort lokalt. Ett `404`-svar tas endast som bekräftelse på att kortet
redan saknas när felkoden är `TASK_NOT_FOUND`; övriga fel behåller kortet och
dialogen för ett nytt försök.
### Teststrategi
Backend har JUnit 5-tester:

View File

@ -2,7 +2,7 @@
## Status
Färdig och verifierad på feature-branchen, ännu inte mergad till `main`.
Färdig och mergad till main.
## Bakgrund
@ -455,8 +455,7 @@ docs/architecture.md
docs/roadmap.md
```
Roadmapen behåller statusen `Pågående` tills featuren har mergats, eftersom
roadmapens status `Klar` även kräver merge.
Roadmapen markerar Feature 6 som `Klar` efter verifiering och merge.
Ett nytt ADR behövs endast om biblioteksvalet bedöms vara ett övergripande,
långlivat frontendbeslut som påverkar fler delar av applikationen än Feature
@ -543,3 +542,10 @@ Kod, tester och relevant dokumentation ska uppdateras tillsammans.
Codex ska inte committa, pusha, skapa pull request eller merga utan uttrycklig
instruktion.
## Relaterade commits
- Feature-commit:
`c3c64482c062f144fd6cb6036e3c9db0afa5ec1e`
- Merge-commit till `main`:
`2696195e741c155a197ae9838d1938bdf2148cc2`

View File

@ -0,0 +1,442 @@
# Feature 7 Radera uppgift
## Status
Färdig och mergad till main.
## Bakgrund
HemHub stödjer skapande, visning, tilldelning, statusändring och drag-and-drop
av uppgifter. Det saknas möjlighet att ta bort uppgifter som inte längre är
relevanta eller som skapats av misstag.
Feature 7 inför permanent radering av en enskild uppgift. Radering hålls
separat från generell redigering så att det destruktiva flödet, dess
bekräftelse och felhantering kan implementeras och verifieras isolerat.
## Mål
Feature 7 ska:
- införa ett backend-API för permanent radering av en uppgift;
- låta användaren initiera radering från uppgiftskortet;
- kräva en tydlig bekräftelse före radering;
- ta bort kortet från brädan först efter serverbekräftelse;
- återanvända befintlig låsning och felhantering per task-id;
- fungera tillsammans med statusändring, tilldelning och drag-and-drop utan
parallella state- eller requestflöden.
## Omfattning
Feature 7 omfattar endast permanent radering av en befintlig uppgift.
En uppgift får raderas oavsett om dess status är `WAITING`, `IN_PROGRESS` eller
`COMPLETED`. Uppgiftens ansvariga användare och aktiv browseranvändare påverkar
inte möjligheten att radera. Det lokala användarvalet är inte autentisering
eller behörighetskontroll.
## Avgränsningar
Feature 7 ska inte införa:
- mjuk radering, papperskorg, återställning eller undo;
- arkivering, versions-, status- eller poänghistorik;
- generell redigering;
- batchradering eller markering av flera kort;
- radering av användare;
- behörigheter eller ägarskap;
- realtidsuppdatering mellan browsers;
- automatisk gallring;
- persistent kortordning;
- nya relationer till uppgifter;
- generell cascade-logik för framtida modeller.
## Permanent radering
Radering är permanent. När användaren har bekräftat raderingen tas uppgiften
bort ur databasen. Ingen `deleted`-flagga, `deletedAt`, dold arkiveringsmodell
eller annan form av mjuk radering införs.
HemHub är en liten familjeapplikation utan revisionslogg, papperskorg eller
återställningsflöde. En mjukraderingsmodell skulle därför öka komplexiteten
utan ett tydligt nuvarande produktvärde.
Om framtida features för återkommande uppgifter eller poänghistorik behöver
bevara information efter radering ska deras datamodeller och raderingsregler
beslutas i respektive feature.
## Tillåtna statusar
Samtliga uppgifter får raderas oavsett status. Det krävs inte att en
`IN_PROGRESS`-uppgift först flyttas till `WAITING`, och en `COMPLETED`-uppgift
behandlas inte annorlunda än övriga uppgifter.
Bekräftelseflödet är samma för alla statusar. Ingen extra varning eller
ytterligare bekräftelse införs för pågående uppgifter.
## Raderingskontroll
Raderingskontrollen ska visas som en diskret sopkorgsikon direkt på det
befintliga uppgiftskortet, uppe till höger i ett eget åtgärdsområde. Feature 7
inför inget kompakt eller expanderat kortläge.
Kontrollen ska:
- visas på befintliga uppgiftskort;
- ha en tillgänglig etikett som identifierar uppgiften, exempelvis
`Radera Töm diskmaskinen`;
- öppna bekräftelsedialogen;
- inte starta drag-and-drop;
- ha en rimlig klickyta för touch;
- ha neutral stil i normalläge och tydlig hover- och fokusmarkering;
- vara inaktiverad när samma uppgift har en pågående operation.
Sopkorgen implementeras som inline-SVG enligt projektets befintliga
ikonmönster. Feature 7 lägger inte till något ikonbibliotek.
Den destruktiva visuella betoningen ska primärt ligga i
bekräftelsedialogen. Kontrollen ska kunna flyttas till en framtida meny eller
detaljdialog utan att backend-API eller delete-flödet behöver göras om.
## Bekräftelsedialog
Radering bekräftas i en separat delete-modal. Den ska följa beteendemönstret i
`CreateTaskModal`, men Feature 7 inför ingen gemensam modalkomponent och gör
ingen bred modalrefaktorering. Dialogen ska visa:
- rubriken `Radera uppgift?`;
- uppgiftens titel;
- tydlig information om att raderingen är permanent;
- knappen `Avbryt`;
- den destruktivt utformade knappen `Radera`.
Exempel:
> Är du säker på att du vill radera **Töm diskmaskinen**? Uppgiften raderas
> permanent och kan inte återställas.
Delete-modalen har inget stängningskryss. Innan delete-anropet har startat ska
den kunna stängas med `Avbryt`, Escape eller klick på bakgrunden. Under
pågående delete-anrop blockeras samtliga stängningsvägar.
Dialogen ska följa projektets befintliga modalstruktur och fokusprinciper.
`Avbryt` får initialt fokus när dialogen öppnas; den destruktiva knappen
`Radera` får inte initialt fokus. Båda knapparna ska vara
tangentbordsåtkomliga.
## Backend-API
Radering sker genom:
```http
DELETE /api/tasks/{taskId}
```
### Lyckad radering
När uppgiften finns och raderas svarar backend med `204 No Content` utan body.
### Okänd uppgift
Om uppgiften inte finns svarar backend med `404 Not Found` och projektets
befintliga felformat:
```text
TASK_NOT_FOUND
```
Det gäller även om samma task-id tidigare har raderats. Ett andra delete-anrop
mot samma id ger därför `404 TASK_NOT_FOUND`.
### Ogiltigt task-id
Ett task-id som inte kan tolkas som UUID ger `400 Bad Request` med repositoryts
nuvarande requestfel och felformat. Den befintliga felkoden
`INVALID_TASK_ASSIGNMENT` ändras inte inom Feature 7. Feature 7 inför ingen
separat felmodell för UUID-fel.
### Konflikter och transaktion
Radering är tillåten för samtliga statusar och oavsett ansvarig. Feature 7 har
därför inget domänfall som ger `409 Conflict`.
Raderingen ska ske inom backendens normala transaktionsgräns och endast ta bort
den identifierade uppgiften. Den får inte ändra eller radera ansvarig
användare, andra användare eller andra uppgifter.
## Databas
Feature 7 raderar raden permanent ur tabellen `task`.
Nuvarande datamodell har inga dokumenterade beroendeentiteter som kräver en ny
migrering eller särskild cascade-policy. Den befintliga relationen från
`task.assignee_id` till `app_user.id` ska verifieras så att den inte hindrar
radering av uppgiften. Den ansvariga användaren ska finnas kvar.
Ingen databasmigrering ska skapas om det faktiska schemat redan stödjer
radering. Framtida relationer till uppgifter får definiera sin delete-policy
när de införs.
## Frontendens uppdateringsstrategi
Frontend använder serverbekräftad radering. När användaren bekräftar ska
frontend:
1. markera uppgiften som upptagen;
2. behålla kortet i dess nuvarande kolumn;
3. behålla bekräftelsedialogen öppen;
4. skicka delete-anropet;
5. vänta på serverns svar;
6. vid `204 No Content` ta bort uppgiften ur den lokala task-listan;
7. stänga dialogen;
8. frigöra låsningen för task-id.
Kortet ska inte tas bort optimistiskt. Ingen rollback-modell behövs eftersom
kortet ligger kvar under anropet.
## Vänteläge
När delete-anropet pågår ska:
- bekräftelsedialogen ligga kvar öppen;
- `Radera` och `Avbryt` vara inaktiverade;
- Escape och bakgrundsklick inte kunna stänga dialogen;
- kortet ligga kvar i sin kolumn och tonas ned lätt;
- alla interaktiva kontroller på samma kort vara inaktiverade.
Ingen spinner eller text som `Raderar…` krävs.
## Låsning och samspel med andra operationer
Delete ska återanvända den befintliga låsningen per task-id. När uppgiften har
en pågående status-, tilldelnings- eller dragoperation ska radering inte kunna
initieras.
När delete-anropet pågår ska samma uppgift inte kunna dras, ändra status, ändra
ansvarig, öppna en ny raderingsdialog eller skicka ytterligare delete-anrop.
Andra kort ska förbli interaktiva och kunna ha egna samtidiga operationer.
Feature 7 inför inget globalt vänteläge, separat delete-lås eller parallell
requestmodell.
## Felhantering
### Vanliga delete-fel
Vid nätverksfel, serverfel eller annat vanligt delete-fel ska:
- kortet ligga kvar oförändrat;
- dialogen ligga kvar öppen;
- vänteläget avslutas;
- kontrollerna aktiveras igen;
- felmeddelandet
`Det gick inte att radera uppgiften. Försök igen.` visas i dialogen;
- användaren kunna försöka igen eller avbryta.
Delete-felet ska inte blandas med status- eller tilldelningsfel på kortet.
### `404 TASK_NOT_FOUND`
Om backend svarar med `404 TASK_NOT_FOUND` betraktas kortet som inaktuellt.
Frontend ska då ta bort uppgiften ur den lokala task-listan, stänga dialogen
och frigöra låsningen utan att visa det generella delete-felet.
Frontendens generella `ApiError`-typ utökas med ett valfritt `code`. Delete-
flödet ska använda `code === "TASK_NOT_FOUND"` och status `404` för detta fall
och får inte tolka meddelandetexten.
Andra typer av `404` ska inte behandlas som en redan borttagen uppgift.
## Frontendtester
Frontendtesterna ska minst verifiera:
- att sopkorgsknappen visas direkt på det befintliga uppgiftskortet;
- att ikonen är inline-SVG och inte kräver ett ikonbibliotek;
- tillgänglig etikett och rätt uppgift i bekräftelsedialogen;
- information om permanent radering;
- initialt fokus på `Avbryt`, aldrig på `Radera`;
- att modalen saknar stängningskryss;
- stängning med `Avbryt`, Escape och bakgrundsklick före anrop;
- `DELETE /api/tasks/{taskId}` först efter bekräftelse;
- att kort och dialog ligger kvar under anropet;
- att dialogen inte kan stängas medan anropet pågår;
- gemensam låsning för status, tilldelning, drag och radering;
- att andra kort förblir interaktiva;
- blockering av dubbla delete-anrop;
- att `204 No Content` tar bort rätt kort och stänger dialogen;
- att vanliga fel behåller kort och dialog samt kan återförsökas;
- att `404 TASK_NOT_FOUND` tar bort det inaktuella kortet;
- att ett annat `404`-fel inte feltolkas som `TASK_NOT_FOUND`;
- att raderingskontrollen inte bryter drag-and-drop;
- grundläggande tangentbordsfokus och knappaktivering.
Testerna ska verifiera beteende och state, inte exakt ikonplacering, färg eller
pixelmått.
## Backendtester
Backendtesterna ska minst verifiera:
- radering i `WAITING`, `IN_PROGRESS` och `COMPLETED`;
- `204 No Content` utan body;
- att den raderade uppgiften inte längre finns i `GET /api/tasks`;
- att andra uppgifter och den ansvariga användaren finns kvar oförändrade;
- `404 TASK_NOT_FOUND` för okänt id och ett andra delete-anrop;
- projektets befintliga `400`-fel för ogiltigt UUID-format.
Testerna ska följa repositoryts befintliga integrationsteststil.
## Manuell verifiering
Följande ska verifieras manuellt:
1. Sopkorgsknappens placering uppe till höger i ett eget åtgärdsområde,
neutrala normalläge, touchyta, hover, fokus och tillgängliga etikett.
2. Radering av uppgifter i samtliga tre statusar.
3. Initialt fokus på `Avbryt`, inget stängningskryss samt avbrytande med knapp,
Escape och bakgrundsklick före anrop.
4. Rätt titel och information om permanent radering.
5. Titel nära maximal längd.
6. Blockering av dubbla delete-anrop.
7. Vänteläge för kort och dialog under fördröjt svar.
8. Låsning av drag, status, tilldelning och ny radering för samma kort.
9. Fortsatt interaktion med andra kort.
10. Vanligt serverfel, visat felmeddelande och nytt försök.
11. `404 TASK_NOT_FOUND` och lokal borttagning av inaktuellt kort.
12. Desktop- och mobilbredd samt tangentbordsaktivering.
13. Omladdning efter lyckad radering så att uppgiften inte återkommer.
## Dokumentation
Feature 7 dokumenteras i:
```text
docs/features/007-task-deletion.md
```
Vid implementation ska `README.md`, `docs/architecture.md` och
`docs/roadmap.md` uppdateras när det är relevant.
Roadmapen ska markera Feature 7 som `Klar` först efter implementation,
automatiska tester, manuell verifiering och merge.
Ett nytt ADR behövs inte för permanent radering. Beslutet gäller den nuvarande
task-livscykeln och etablerar inte en generell raderingspolicy för framtida
entiteter.
## Acceptanskriterier
Feature 7 är klar när:
- en uppgift kan raderas permanent med `DELETE /api/tasks/{taskId}`;
- lyckad radering ger `204 No Content`;
- okänd eller redan raderad uppgift ger `404 TASK_NOT_FOUND`;
- ogiltigt UUID-format följer befintlig felhantering;
- alla tre statusar kan raderas oavsett ansvarig eller aktiv användare;
- radering kräver en egen bekräftelsedialog;
- dialogen visar rätt titel och anger att raderingen inte kan återställas;
- `Avbryt` får initialt fokus och `Radera` får inte initialt fokus;
- modalen saknar stängningskryss och blockerar alla stängningsvägar under
anropet;
- en diskret inline-SVG-sopkorg visas direkt på befintliga uppgiftskort;
- raderingskontrollen startar inte drag;
- kortet tas bort först efter serverbekräftelse;
- samma task-id låses för status, tilldelning, drag och ny radering;
- andra kort förblir interaktiva;
- vanliga fel behåller kort och dialog och kan återförsökas;
- `404 TASK_NOT_FOUND` tar bort det inaktuella lokala kortet;
- ingen mjukradering, återställningsmodell eller onödig migrering införs;
- backend- och frontendtester täcker centrala flöden;
- manuell verifiering genomförs;
- relevant dokumentation uppdateras.
## Implementerad lösning
Backendens task-controller och task-service har utökats med fysisk radering via
`DELETE /api/tasks/{taskId}`. Servicen hämtar först uppgiften för att
återanvända `TaskNotFoundException` och raderar därefter entiteten inom en
transaktion. Ingen entitet, exception handler eller Flyway-migrering behövde
ändras.
Frontendens `TaskBoard` använder samma per-task-lås som status- och
tilldelningsoperationerna. Radering är serverbekräftad: kortet och dialogen
ligger kvar medan anropet pågår, och kortet tas bort först efter `204 No
Content`. Endast ett svar med både status `404` och felkoden
`TASK_NOT_FOUND` tar bort ett känt inaktuellt kort. Övriga fel behåller kortet
och dialogen så att användaren kan försöka igen.
`TaskCard` har inget kompakt eller expanderat läge. En neutral
inline-SVG-knapp ligger direkt i kortets övre högra åtgärdsområde och stoppar
pointer-händelsen innan den når dragytan. Den separata delete-modalen följer
`CreateTaskModal`-mönstret utan en gemensam modalabstraktion. `Avbryt` får
initialt fokus, modalen saknar stängningskryss och samtliga stängningsvägar
blockeras under delete-anropet.
Frontendens generella `ApiError` innehåller nu ett valfritt `code`. Den
befintliga backendhanteringen av felaktigt UUID är oförändrad och returnerar
fortsatt `400 INVALID_TASK_ASSIGNMENT`.
## Tester och verifiering
Automatiskt verifierat:
- backendens fullständiga testsvit: 47 tester passerade;
- frontendens fullständiga testsvit: 49 tester passerade;
- frontendens produktionsbygge och TypeScript-kompilering passerade;
- `git diff --check` passerade.
Backendtesterna ligger i den separata integrationstestklassen
`TaskDeletionApiTest`. Frontendens delete-flöden testas tillsammans med övriga
brädbeteenden i `App.test.tsx`.
Manuell browserverifiering genomfördes mot lokalt körande frontend och backend
i Chrome. Följande verifierades:
- permanent radering och kvarstående borttagning efter omladdning för
`WAITING`, `IN_PROGRESS` och `COMPLETED`;
- lång titel, radbrytning och korrekt uppgiftstitel i dialogen;
- initialt fokus på `Avbryt`, tabb-ordning till `Radera`, Escape och
bakgrundsklick före anrop samt avsaknad av stängningskryss;
- fördröjd delete-respons med kvarvarande och nedtonat kort, öppen låst modal
och inaktiverade stängningsvägar;
- gemensam låsning av drag, status, ansvarig och ny radering för samma kort,
samtidigt som andra kort förblev interaktiva;
- snabbt dubbelklick på `Radera` utan dubbla delete-anrop;
- vanligt serverfel där kort och modal låg kvar, felet visades och ett nytt
försök lyckades;
- `404 TASK_NOT_FOUND`, där det inaktuella kortet togs bort lokalt;
- neutral sopkorgsknapp med 40 × 40 pixlars klickyta, inline-SVG och
pointer-hantering som inte startade drag;
- desktopbredd 1440 × 1000 och mobilbredd 390 × 844 utan horisontell
scrollning.
Inga problem upptäcktes i Feature 7-flödena.
## Relaterade commits
- Feature-commit: `f296d15`
- Merge-commit: `5df0146`
## Implementationsprinciper
Före implementation ska Codex läsa:
```text
AGENTS.md
README.md
docs/architecture.md
docs/development.md
docs/roadmap.md
docs/decisions/
docs/features/005-task-status.md
docs/features/006-task-drag-and-drop.md
```
Codex ska även läsa relevant backendkod, frontendkod och befintliga tester.
Repositoryts faktiska kod, tester och dokumentation har företräde framför
antaganden i detta dokument.
Implementation, tester och relevant dokumentation ska uppdateras tillsammans.
Codex ska inte committa, pusha, skapa pull request eller merga utan uttrycklig
instruktion.

View File

@ -0,0 +1,493 @@
# Feature 8 Redigera uppgift
## Status
Implementerad och automatiskt verifierad på feature-branchen. Manuell
browserverifiering och merge till `main` återstår.
## Bakgrund
HemHub stödjer skapande, visning, tilldelning, statusändring, drag-and-drop och
permanent radering av uppgifter.
Det saknas fortfarande möjlighet att korrigera eller uppdatera en befintlig
uppgifts grundläggande innehåll. Feature 8 inför därför redigering av:
- titel;
- beskrivning;
- poäng.
Redigering hålls separat från de specialiserade flödena för ansvarig, status
och radering.
## Mål
Feature 8 ska:
- låta användaren öppna en redigeringsdialog från uppgiftskortet;
- låta användaren ändra titel, beskrivning och poäng;
- återanvända samma valideringsregler som vid skapande;
- införa ett avgränsat backend-API för uppgiftens redigerbara detaljfält;
- använda serverbekräftad uppdatering;
- återanvända befintlig låsning och felhantering per task-id;
- fungera tillsammans med status, tilldelning, drag-and-drop och radering;
- behålla uppgiftens kolumn och ordning efter redigering.
## Omfattning
Feature 8 omfattar redigering av `title`, `description` och `points`.
Följande egenskaper ska inte kunna ändras genom redigeringsflödet:
- `id`;
- `status`;
- `assignee`;
- `createdAt`.
Ansvarig ska fortsatt ändras genom tilldelningsflödet. Status ska fortsatt
ändras genom status-API:t och drag-and-drop-flödet.
## Avgränsningar
Feature 8 ska inte införa:
- statusändring eller ändring av ansvarig i redigeringsformuläret;
- inline-redigering eller en generell detaljvy;
- deadline, återkommande uppgifter, kategorier, etiketter, kommentarer eller
bilagor;
- status-, poäng- eller versionshistorik eller revisionslogg;
- behörigheter, batchredigering eller autosave;
- realtidsuppdatering mellan browsers;
- en generell formulär- eller modalplattform;
- en ny global state-lösning;
- `updatedAt`;
- en ny åtgärdsmeny på uppgiftskortet.
## Redigeringsflöde
Redigering sker i en separat modal med:
- titel;
- beskrivning;
- poäng;
- knappen `Avbryt`;
- knappen `Spara`;
- ett stängningskryss.
Inline-redigering direkt på kortet ingår inte. En separat modal väljs eftersom
fälten redigeras som en sammanhållen operation, kortets layout ska förbli
stabil, inline-formulär skulle störa dragytan och ett serverbekräftat
spara-/avbrytflöde kan hanteras isolerat.
## Initiering från uppgiftskortet
Varje uppgiftskort får en separat synlig redigeringsknapp bredvid den befintliga
sopkorgsknappen. Den ska:
- ligga i kortets befintliga åtgärdsområde uppe till höger;
- använda en neutral inline-SVG-ikon;
- ha ungefär samma klickyta som raderingsknappen;
- ha en tillgänglig etikett som identifierar uppgiften, exempelvis
`Redigera Töm diskmaskinen`;
- kunna aktiveras med tangentbord;
- inte initiera drag-and-drop;
- stoppa relevanta pointer-händelser innan de når dragytan;
- vara inaktiverad när samma uppgift har en pågående operation.
Feature 8 inför ingen åtgärdsmeny. Om kortåtgärder senare flyttas till en meny
ska redigerings-API:t och det underliggande redigeringsflödet kunna behållas.
## Separat redigeringsmodal
Redigeringen implementeras som en separat komponent, exempelvis
`EditTaskModal`. Komponenten ska följa samma visuella och beteendemässiga
mönster som den befintliga skapandemodalen.
Feature 8 kräver inte att skapande- och redigeringsmodalerna slås ihop till en
generell komponent med flera lägen. Mindre gemensamma valideringsfunktioner
eller formulärhjälpare får brytas ut om repositoryts faktiska kod tjänar på
det.
## Formulärets initiala värden
När redigeringsmodalen öppnas fylls den med uppgiftens aktuella titel,
beskrivning och poäng. En beskrivning som är `null` visas som tom sträng.
Titelfältet får initialt fokus. Texten markeras inte automatiskt. Formuläret
baseras på task-versionen i frontend-state när dialogen öppnas.
Om dialogen stängs utan att spara kastas lokala ändringar. När den öppnas igen
hämtas initialvärdena på nytt från den aktuella uppgiften i frontend-state.
Ingen särskild synkronisering eller versionshantering införs om task-data skulle
ändras medan modalen är öppen.
## Tillåtna statusar och användare
Titel, beskrivning och poäng får redigeras i `WAITING`, `IN_PROGRESS` och
`COMPLETED`, oavsett ansvarig eller aktiv browseranvändare. Aktiv användare är
ett lokalt browserval och inte autentisering eller behörighetskontroll.
Att poäng kan ändras på en slutförd uppgift är accepterat i nuvarande modell
eftersom HemHub ännu saknar poänghistorik. En framtida historikfeature ska
besluta om intjänade poäng använder ett snapshot eller uppgiftens aktuella
poängvärde.
## Stängningsbeteende
Innan save-anropet har startat ska redigeringsmodalen kunna stängas med:
- `Avbryt`;
- Escape;
- klick på modalens bakgrund;
- stängningskrysset.
Osparade ändringar kastas utan extra bekräftelse.
Under pågående save-anrop ska samtliga stängningsvägar blockeras:
- `Avbryt` och `Spara` är inaktiverade;
- stängningskrysset är inaktiverat eller otillgängligt;
- Escape ignoreras;
- klick på bakgrunden ignoreras.
Modalen ligger kvar öppen tills backend-anropet har slutförts.
## Validering
Redigering använder samma valideringsregler och användarmeddelanden som
skapande. Backend är alltid slutlig garant.
### Titel
Titeln trimmas, är obligatorisk och får innehålla högst 100
Unicode-kodpunkter. Tom eller enbart blank titel är ogiltig.
### Beskrivning
Beskrivningen trimmas, är valfri och får innehålla högst 500
Unicode-kodpunkter. Den skickas och lagras som `null` när den är tom efter
trimning.
### Poäng
Poäng är obligatoriskt, måste vara ett heltal mellan 1 och 99 och får inte
ersättas med ett backend-defaultvärde.
Frontend blockerar submit vid tom eller för lång titel, för lång beskrivning,
tomt poängfält, text eller decimaltal samt poäng utanför 199.
## Oförändrad submit
`Spara` är tillgänglig även när användaren inte har ändrat något. Frontend
skickar ett normalt uppdateringsanrop och backend behandlar samma värden som en
giltig idempotent uppdatering. Ingen dirty-state införs.
## Backend-API
Redigering sker genom:
```http
PUT /api/tasks/{taskId}/details
```
Endpointen ändrar endast uppgiftens redigerbara detaljfält.
### Request
Requesten innehåller alltid hela den redigerbara uppsättningen:
```json
{
"title": "Töm diskmaskinen",
"description": "Ställ in allt i rätt skåp",
"points": 3
}
```
Samtliga tre fält ska finnas. `description` får vara `null`. Backend ska inte
implementera patchsemantik för saknade fält.
### Lyckad uppdatering
En lyckad uppdatering ger `200 OK` och hela den uppdaterade uppgiften i samma
task-format som övriga task-operationer. Serverns fullständiga respons är
slutlig sanning.
### Fel
- okänd uppgift ger `404 Not Found` och `TASK_NOT_FOUND`;
- ogiltigt task-id använder repositoryts befintliga hantering för ogiltiga
path-parametrar;
- ogiltig titel, beskrivning eller poäng ger `400 Bad Request` med den
befintliga task-valideringen och normalt felkoden `INVALID_TASK`.
Repositoryts faktiska implementation har företräde.
## Backendens uppdateringsregler
Backend hämtar först den befintliga uppgiften och uppdaterar uttryckligen endast
`title`, `description` och `points`.
Operationen får inte ändra `id`, `status`, `assignee` eller `createdAt`. Den ska
vara transaktionell, tillåten i samtliga statusar, idempotent för samma värden,
inte påverka andra uppgifter och använda samma trimning och normalisering som
skapandeflödet. Ingen `updatedAt` införs.
## Databas
Den befintliga task-tabellen innehåller redan titel, beskrivning och poäng.
Feature 8 ska därför inte kräva någon Flyway-migrering.
Implementation ska verifiera kolumnlängder, `points NOT NULL`,
poängconstrainten 199, nullhantering för beskrivning och att övriga kolumner
inte påverkas. Ingen ny kolumn eller relation införs.
## Frontendens uppdateringsstrategi
Redigering är serverbekräftad. När användaren trycker `Spara` ska frontend:
1. validera formuläret;
2. markera uppgiften som upptagen genom låsningen per task-id;
3. behålla kortets tidigare värden och modalen öppen;
4. inaktivera formuläret och samtliga stängningsvägar;
5. skicka `PUT /api/tasks/{taskId}/details`;
6. vid framgång ersätta uppgiften med serverns fullständiga respons på samma
plats i task-listan;
7. stänga modalen och frigöra låsningen.
Frontend visar inte de redigerade värdena optimistiskt. Ingen rollback behövs
eftersom kortet behåller sina tidigare värden tills servern svarar.
## Vänteläge och gemensam låsning
Feature 8 återanvänder den befintliga låsningen per task-id. Under save ska:
- formulärfält, knappar och stängningsvägar vara inaktiverade;
- kortet ligga kvar i samma kolumn och tonas ned;
- samma uppgift inte kunna dras, ändra status eller ansvarig, raderas, öppnas
för ny redigering eller skicka dubbla save-anrop;
- andra uppgifter förbli interaktiva.
Ingen separat redigeringslåsning, global vänteläge eller parallell
requesthantering införs. En öppen modal låser inte tasken innan `Spara`.
## Samspel med befintliga flöden
Redigeringsknappen använder samma pointer-hantering som raderingsknappen och
startar inte drag-and-drop.
Drag-and-drop förblir optimistiskt med rollback, medan redigering är
serverbekräftad. Status ändras fortsatt genom statusknappar eller drag-and-drop.
Ansvarig ändras fortsatt genom tilldelningsflödet. Raderingsknappen ligger
bredvid redigeringsikonen. Samtliga flöden delar låsningen per task-id.
## Kortets ordning och kolumn
En lyckad redigering ersätter uppgiften på befintlig plats i frontendens
task-lista utan omsortering. Status ändras inte, så kortet ligger normalt kvar i
samma kolumn. Serverns fullständiga task-respons ersätter ändå det lokala
värdet i sin helhet.
## Felhantering
### Frontendvalideringsfel
Vid frontendvalideringsfel skickas inget API-anrop. Modalen och inmatningen
behålls, ett begripligt fel visas och användaren kan korrigera och försöka igen.
### Vanliga API-fel
Vid backendvalideringsfel, nätverksfel, serverfel eller oväntad respons ligger
kortet kvar oförändrat. Modalen och inmatningen behålls, vänteläget avslutas,
kontrollerna aktiveras och användaren kan försöka igen eller avbryta.
Generellt meddelande:
> Det gick inte att spara ändringarna. Försök igen.
Frontend använder strukturerad felkod och HTTP-status där relevant och tolkar
inte meddelandetext.
### `404 TASK_NOT_FOUND`
Endast kombinationen HTTP `404` och `code === "TASK_NOT_FOUND"` behandlas som
ett inaktuellt lokalt kort. Frontend tar då bort uppgiften, stänger modalen och
frigör låsningen utan generellt redigeringsfel. Andra 404-fel behandlas som
vanliga fel.
## Frontendtester
Frontendtesterna ska verifiera beteende och state, inte exakt CSS eller intern
komponentstruktur. De ska minst täcka:
- redigeringsknapp, inline-SVG, tillgänglig etikett, tangentbordsaktivering och
skydd mot dragstart;
- att en låst uppgift inte kan öppnas;
- rätt uppgift och initialvärden, inklusive `null` som tom beskrivning;
- initialt fokus i titelfältet;
- stängning med `Avbryt`, Escape, bakgrund och kryss;
- att osparade ändringar kastas och aktuell task-data används vid nästa
öppning;
- frontendvalidering av titel, beskrivning och poäng;
- rätt endpoint och fullständigt requestformat med trimmade värden och tom
beskrivning som `null`;
- oförändrad submit;
- serverbekräftat vänteläge, blockerade stängningsvägar, gemensam task-låsning
och blockerade dubbla save-anrop;
- att andra kort förblir interaktiva;
- fullständig serverrespons, bibehållen plats, ordning och kolumn;
- vanliga fel med bevarad modal/inmatning och fungerande återförsök;
- `404 TASK_NOT_FOUND` samt att andra 404-fel behandlas som vanliga fel.
## Backendtester
Backendtesterna bör ligga i en separat integrationstestklass, exempelvis
`TaskEditingApiTest`, om det passar repositoryts teststruktur.
Testerna ska minst täcka:
- samtidig ändring och trimning av titel, beskrivning och poäng;
- tom beskrivning som `null`;
- gränsvärdena 1/99 poäng, 100 kodpunkter i titel och 500 i beskrivning;
- idempotent uppdatering;
- redigering i samtliga tre statusar och fullständig `200 OK`-respons;
- saknad, null, tom eller för lång titel;
- för lång beskrivning;
- saknat, null, text, decimal eller poäng utanför 199;
- saknade fält i det fullständiga requestobjektet;
- okänt task-id och ogiltigt UUID;
- att id, status, ansvarig och `createdAt` bevaras;
- att andra uppgifter och ansvarig användare är oförändrade.
## Implementerad lösning
Backend exponerar `PUT /api/tasks/{taskId}/details`. Requestmodellen kräver
`title`, `description` och `points`; explicit `null` är endast tillåtet för
beskrivningen. Service-lagret återanvänder skapandeflödets trimning och
validering och uppdaterar en hämtad entitet genom en avgränsad
`changeDetails`-operation. ID, status, ansvarig och skapandetid bevaras.
Frontend visar en neutral redigeringsknapp med inline-SVG bredvid
raderingsknappen. Den separata `EditTaskModal` fylls från aktuell task,
fokuserar titeln, validerar fälten och blockerar samtliga stängningsvägar under
save. Uppdateringen är serverbekräftad och återanvänder samma låsning per
task-id som status, tilldelning, drag-and-drop och radering. En fullständig
serverrespons ersätter tasken på dess befintliga plats. Endast ett strukturerat
`404 TASK_NOT_FOUND` tar bort ett inaktuellt lokalt kort.
Ingen Flyway-migrering behövdes eftersom befintliga kolumner och constraints
täcker de redigerbara fälten.
## Automatisk verifiering
- Backendens riktade redigeringstester: 20 passerade.
- Fullständig backendtestsvit: 68 passerade.
- Frontendtester: 61 passerade.
- Frontendens TypeScript-kompilering och produktionsbygge passerade.
- `git diff --check` passerade.
En verifierad begränsning i den lokala H2-databasen är att `VARCHAR` räknar
UTF-16-kodenheter för vissa tecken utanför BMP. Applikationen validerar enligt
Unicode-kodpunkter, men en titel med 100 sådana astrala tecken kan därför
avvisas av H2-kolumnen. Feature 8 ändrar inte databasschemat; PostgreSQL-målet
ska verifiera denna skillnad när produktionsdatabasen införs.
## Manuell verifiering
Följande ska verifieras manuellt:
1. Redigeringsikonen, klickytan, stilen, etiketten och skyddet mot dragstart.
2. Klick- och tangentbordsöppning av rätt uppgift.
3. Redigering i `WAITING`, `IN_PROGRESS` och `COMPLETED`.
4. Initialvärden, tom beskrivning och initialt fokus.
5. Gränser och fel för titel, beskrivning och poäng.
6. Stängning med `Avbryt`, Escape, bakgrund och kryss samt kastade osparade
ändringar.
7. Oförändrad submit.
8. Fördröjt svar med gamla kortvärden, låst modal och nedtonat kort.
9. Gemensam låsning och fortsatt interaktion med andra kort.
10. Vanligt serverfel, bevarad inmatning och lyckat återförsök.
11. `404 TASK_NOT_FOUND` och annat 404-fel.
12. Bibehållen kolumn, ordning, status och ansvarig.
13. Sparade värden efter omladdning.
14. Desktop, mobil, touch och tangentbordsordning.
## Dokumentation
Feature 8 dokumenteras i:
```text
docs/features/008-task-editing.md
```
Vid implementation uppdateras `README.md`, `docs/architecture.md`,
`docs/roadmap.md` och `docs/development.md` när relevant.
Roadmapen markerar Feature 8 som `Klar` först efter implementation, automatiska
tester, produktionsbygge, manuell verifiering, merge till `main` och slutlig
dokumentationsuppdatering.
Ett nytt ADR behövs normalt inte. Separat modal, redigeringsikon,
`PUT /api/tasks/{taskId}/details` och serverbekräftad uppdatering är lokala
beslut för Feature 8.
## Acceptanskriterier
Feature 8 är klar när:
- varje kort har en tangentbordsåtkomlig redigeringskontroll som inte startar
drag;
- titel, beskrivning och poäng kan redigeras i en separat modal;
- aktuella värden fylls i, titeln får fokus och `null` beskrivning visas tom;
- redigering fungerar i samtliga statusar utan behörighetsregler;
- skapande och redigering använder samma valideringsregler;
- backend använder `PUT /api/tasks/{taskId}/details` med hela fältuppsättningen;
- samma värden accepteras idempotent;
- endast titel, beskrivning och poäng ändras;
- `200 OK` returnerar hela task-responsen;
- frontend är serverbekräftad och behåller gamla kortvärden under anropet;
- modal och task är låsta under save genom befintlig per-task-låsning;
- andra uppgifter förblir interaktiva;
- serverresponsen ersätter tasken på befintlig plats och kolumn;
- vanliga fel behåller modal och inmatning och kan återförsökas;
- endast `404 TASK_NOT_FOUND` tar bort ett inaktuellt lokalt kort;
- stängningsvägar fungerar före och blockeras under anrop;
- ingen inline-redigering, generell modalplattform, Flyway-migrering eller
`updatedAt` införs;
- automatiska och manuella kontroller genomförs;
- relevant dokumentation uppdateras.
## Implementationsprinciper
Före implementation ska Codex läsa repositoryts faktiska:
```text
AGENTS.md
README.md
docs/architecture.md
docs/development.md
docs/roadmap.md
docs/decisions/
docs/features/002-task-creation.md
docs/features/003-task-points.md
docs/features/004-task-assignment.md
docs/features/005-task-status.md
docs/features/006-task-drag-and-drop.md
docs/features/007-task-deletion.md
```
Codex ska även läsa relevant backendkod, frontendkod och befintliga tester och
särskilt verifiera entitet, controller, service, repository, request/response,
validering, schema, felmodell, task-listans ordning, modal- och kortstruktur,
per-task-låsning samt befintliga status-, tilldelnings-, drag- och deleteflöden.
Repositoryts faktiska kod, tester och dokumentation har företräde framför
antaganden i detta dokument. Implementation, tester och relevant dokumentation
ska uppdateras tillsammans.
Codex ska inte committa, pusha, skapa pull request eller merga utan uttrycklig
instruktion.
## Relaterade commits
Fylls i efter implementation och merge.

View File

@ -34,9 +34,9 @@ Följande statusvärden används:
## Nuvarande läge
Feature 05 är klara. Feature 6 är implementerad och verifierad på sin
feature-branch men ännu inte mergad. Den aktuella applikationen på
feature-branchen har:
Feature 07 är klara och finns på `main`. Feature 8 är implementerad på sin
feature-branch och inväntar manuell verifiering och merge. Den aktuella
applikationen på feature-branchen har:
- ett monorepo med separat React/Vite-frontend och Spring Boot-backend;
- centralt lagrade användare och ett lokalt browserval av aktiv användare;
@ -48,17 +48,18 @@ feature-branchen har:
- backendstyrda statusändringar mellan `WAITING`, `IN_PROGRESS` och `COMPLETED`;
- automatisk tilldelning till aktiv användare när en otilldelad uppgift påbörjas;
- drag-and-drop mellan statuskolumner med optimistisk flytt och rollback;
- serverbekräftad permanent radering med bekräftelsedialog;
- serverbekräftad redigering av titel, beskrivning och poäng;
- en bräda med Väntande, Pågående och Klart;
- nya uppgifter som alltid skapas med status `WAITING`.
Tilldelning och status är separata egenskaper; tilldelningsflödet ändrar inte
uppgiftens status. Alla direkta statusövergångar är tillåtna och
`IN_PROGRESS` kräver ansvarig. Det finns ännu ingen redigering, radering,
deadline eller återkommande uppgift. Nuvarande användarval är inte
autentisering.
`IN_PROGRESS` kräver ansvarig. Det finns ännu ingen deadline eller återkommande
uppgift. Nuvarande användarval är inte autentisering.
**Feature 6 Drag-and-drop är färdig och verifierad på feature-branchen men
står kvar som Pågående tills den har mergats.**
**Feature 8 Redigera uppgift är pågående. Ingen senare produktfeature utses
som nästa innan Feature 8 har verifierats och mergats.**
## Featureöversikt
@ -70,9 +71,9 @@ står kvar som Pågående tills den har mergats.**
| 3 Uppgiftspoäng | Klar | 2 | Poäng på uppgifter |
| 4 Tilldelning | Klar | 12 | Valfri ansvarig användare |
| 5 Statusändring | Klar | 4 | Backendstyrda statusövergångar |
| 6 Drag-and-drop | Pågående | 5 | Kortflytt via status-API |
| 7 Radera uppgift | Planerad | 2 | Bekräftad radering |
| 8 Redigera uppgift | Planerad | 3 | Titel, beskrivning och poäng |
| 6 Drag-and-drop | Klar | 5 | Kortflytt via status-API |
| 7 Radera uppgift | Klar | 2 | Bekräftad permanent radering |
| 8 Redigera uppgift | Pågående | 3 | Titel, beskrivning och poäng |
| 9 Deadline | Planerad | 2 | Valfri deadline och förseningsmarkering |
| 10 Sökning och filtrering | Planerad | 2; 4 för ansvarig; 9 för deadline | Sökning och filter på brädan |
| 11 Design av återkommande uppgifter | Planerad | 35, 9 | Beslut och plan, ingen produktionskod |
@ -182,7 +183,7 @@ inte tas bort medan uppgiften är pågående.
### Feature 6 Drag-and-drop
**Status:** Pågående
**Status:** Klar
**Beroenden:** Feature 5
@ -201,9 +202,8 @@ hela den tidigare task-versionen. En otilldelad uppgift som dras till Pågående
använder Feature 5:s befintliga automatiska tilldelning till aktiv användare.
Serverns fullständiga task-respons ersätter alltid det optimistiska värdet.
Statusknapparna förblir tills vidare serverbekräftade. Implementation och
automatisk samt manuell verifiering är färdiga på feature-branchen; statusen
förblir `Pågående` tills merge eftersom `Klar` enligt roadmapen även kräver att
featuren är mergad.
automatisk samt manuell verifiering är genomförda, och featuren är mergad till
`main`.
## Fas 2 Hantering av uppgifter
@ -212,7 +212,7 @@ modellen och statusreglerna finns.
### Feature 7 Radera uppgift
**Status:** Planerad
**Status:** Klar
**Beroenden:** Feature 2
@ -225,13 +225,17 @@ modellen och statusreglerna finns.
Radering hålls separat från redigering så att databorttagning och dess
konsekvenser kan verifieras isolerat.
**Öppen fråga:**
- permanent radering eller mjuk radering.
Feature 7 använder permanent fysisk radering genom
`DELETE /api/tasks/{taskId}`. En bekräftelsemodal visas före anropet och
frontend behåller kortet tills backend har bekräftat raderingen. Operationen
använder samma låsning per task-id som status, tilldelning och drag-and-drop.
Ett `404 TASK_NOT_FOUND` tar bort ett känt inaktuellt lokalt kort.
Implementation samt automatisk och manuell verifiering är genomförda, och
featuren är mergad till `main`.
### Feature 8 Redigera uppgift
**Status:** Planerad
**Status:** Pågående
**Beroenden:** Feature 3
@ -245,6 +249,12 @@ Featuren ligger efter poäng för att redigeringsflödet ska omfatta den då
aktuella uppgiftsmodellen. Ansvarig ska fortsatt ändras genom
tilldelningsflödet från Feature 4 och status genom statusflödet från Feature 5.
Den implementerade lösningen använder `PUT /api/tasks/{taskId}/details` och
uppdaterar endast titel, beskrivning och poäng. Frontend använder en separat
redigeringsmodal och serverbekräftad uppdatering genom den gemensamma låsningen
per task-id. Implementation och automatiska kontroller är genomförda på
feature-branchen; manuell browserverifiering och merge återstår.
### Feature 9 Deadline
**Status:** Planerad
@ -433,7 +443,6 @@ Nuvarande aktiva användarval är uttryckligen inte autentisering.
## Öppna tvärgående frågor
- Ska uppgifter raderas permanent eller mjukt?
- Hur ska datum, tider och tidszoner representeras?
- Ska H2 behållas för lokal utveckling efter PostgreSQL-införandet?
- Hur ska användare senare kunna redigeras eller raderas, särskilt när de är
@ -444,8 +453,14 @@ Nuvarande aktiva användarval är uttryckligen inte autentisering.
## Ändringshistorik
- 2026-07-27: Feature 6 implementerades och verifierades automatiskt och
manuellt på feature-branchen. Den behåller statusen Pågående tills merge.
- 2026-07-27: Feature 8 implementerades och verifierades automatiskt
feature-branchen. Manuell verifiering och merge återstår.
- 2026-07-27: Feature 7 verifierades och mergades. Permanent,
serverbekräftad radering infördes, och Feature 8 blev nästa planerade
produktfeature.
- 2026-07-27: Feature 6 verifierades och mergades. Optimistisk drag-and-drop
med full rollback infördes, och Feature 7 blev nästa planerade
produktfeature.
- 2026-07-27: Feature 5 verifierades och mergades. Backendstyrda
statusövergångar, automatisk tilldelning vid påbörjande och statusberoende
tilldelningsregler infördes. Feature 6 blev nästa planerade produktfeature.

View File

@ -5,6 +5,7 @@ import App from './App'
const dragAndDrop = vi.hoisted(() => ({
onTaskDrop: null as ((taskId: string, status: string) => void) | null,
disabledTaskIds: new Set<string>(),
}))
vi.mock('./TaskDragAndDrop', () => ({
@ -18,10 +19,18 @@ vi.mock('./TaskDragAndDrop', () => ({
dragAndDrop.onTaskDrop = onTaskDrop
return children
},
useTaskDraggable: () => ({
ref: () => {},
isDragging: false,
}),
useTaskDraggable: (taskId: string, disabled: boolean) => {
if (disabled) {
dragAndDrop.disabledTaskIds.add(taskId)
} else {
dragAndDrop.disabledTaskIds.delete(taskId)
}
return {
ref: () => {},
isDragging: false,
}
},
useTaskColumnDropTarget: () => ({
ref: () => {},
isDropTarget: false,
@ -74,6 +83,7 @@ const tasks = [
beforeEach(() => {
window.localStorage.clear()
dragAndDrop.onTaskDrop = null
dragAndDrop.disabledTaskIds.clear()
})
afterEach(() => {
@ -540,6 +550,394 @@ test('statusfel behåller tidigare status och ansvarig och visas på kortet', as
expect(within(card).getByText('Ta uppgift')).toBeInTheDocument()
})
test('redigeringsknappen öppnar rätt uppgift med aktuella värden och titelfokus', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = mockUsersAndTasks(users, [tasks[0], tasks[1]])
render(<App />)
const editButton = await screen.findByRole('button', { name: 'Redigera Dammsuga' })
expect(editButton.querySelector('svg')).toBeInTheDocument()
fireEvent.pointerDown(editButton)
fireEvent.click(editButton)
const dialog = screen.getByRole('dialog', { name: 'Redigera uppgift' })
expect(within(dialog).getByLabelText('Titel')).toHaveValue('Dammsuga')
expect(within(dialog).getByLabelText('Titel')).toHaveFocus()
expect(within(dialog).getByLabelText('Beskrivning (valfri)')).toHaveValue('Bottenvåningen')
expect(within(dialog).getByLabelText('Poäng')).toHaveValue(7)
expect(fetchMock).toHaveBeenCalledTimes(2)
fireEvent.click(within(dialog).getByRole('button', { name: 'Avbryt' }))
fireEvent.click(screen.getByRole('button', { name: 'Redigera Diska' }))
expect(screen.getByLabelText('Beskrivning (valfri)')).toHaveValue('')
})
test('redigeringsmodal stängs normalt och kastar osparade ändringar', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = mockUsersAndTasks(users, [tasks[0]])
const { container } = render(<App />)
const editButton = await screen.findByRole('button', { name: 'Redigera Dammsuga' })
fireEvent.click(editButton)
fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Osparad' } })
fireEvent.click(screen.getByRole('button', { name: 'Stäng' }))
expect(screen.queryByRole('dialog', { name: 'Redigera uppgift' })).not.toBeInTheDocument()
fireEvent.click(editButton)
expect(screen.getByLabelText('Titel')).toHaveValue('Dammsuga')
fireEvent.keyDown(window, { key: 'Escape' })
expect(screen.queryByRole('dialog', { name: 'Redigera uppgift' })).not.toBeInTheDocument()
fireEvent.click(editButton)
fireEvent.mouseDown(container.querySelector('.modal-backdrop')!)
expect(screen.queryByRole('dialog', { name: 'Redigera uppgift' })).not.toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
test.each([
{
field: 'Titel',
value: ' ',
message: 'Titeln måste innehålla mellan 1 och 100 tecken.',
},
{
field: 'Titel',
value: 'a'.repeat(101),
message: 'Titeln måste innehålla mellan 1 och 100 tecken.',
},
{
field: 'Beskrivning (valfri)',
value: 'a'.repeat(501),
message: 'Beskrivningen får innehålla högst 500 tecken.',
},
{
field: 'Poäng',
value: '1.5',
message: 'Poäng måste vara ett heltal mellan 1 och 99.',
},
{
field: 'Poäng',
value: '',
message: 'Poäng måste vara ett heltal mellan 1 och 99.',
},
{
field: 'Poäng',
value: '100',
message: 'Poäng måste vara ett heltal mellan 1 och 99.',
},
])('ogiltig redigeringsdata i $field blockerar API-anrop', async ({ field, value, message }) => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = mockUsersAndTasks(users, [tasks[0]])
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Redigera Dammsuga' }))
fireEvent.change(screen.getByLabelText(field), { target: { value } })
fireEvent.click(screen.getByRole('button', { name: 'Spara' }))
expect(await screen.findByRole('alert')).toHaveTextContent(message)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
test('redigering är serverbekräftad, låser samma task och ersätter hela svaret på samma plats', async () => {
const otherTask = {
...tasks[0],
id: '00000000-0000-0000-0000-000000000010',
title: 'Putsa fönster',
}
const serverTask = {
...tasks[0],
title: 'Dammsuga övervåningen',
description: null,
points: 9,
assignee: { id: users[1].id, name: users[1].name },
}
let resolveEdit!: (response: Response) => void
const editResponse = new Promise<Response>((resolve) => {
resolveEdit = resolve
})
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0], otherTask]))
fetchMock.mockReturnValueOnce(editResponse)
const { container } = render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Redigera Dammsuga' }))
fireEvent.change(screen.getByLabelText('Titel'), {
target: { value: ' Dammsuga övervåningen ' },
})
fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), {
target: { value: ' ' },
})
fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '9' } })
const save = screen.getByRole('button', { name: 'Spara' })
fireEvent.click(save)
fireEvent.click(save)
const oldCard = screen.getByText('Dammsuga').closest('article')!
const otherCard = screen.getByText('Putsa fönster').closest('article')!
const dialog = screen.getByRole('dialog', { name: 'Redigera uppgift' })
expect(oldCard).toHaveAttribute('aria-busy', 'true')
expect(within(oldCard).getByRole('button', { name: 'Redigera Dammsuga' })).toBeDisabled()
expect(within(oldCard).getByRole('button', { name: 'Radera Dammsuga' })).toBeDisabled()
expect(within(oldCard).getByRole('button', { name: 'Påbörja' })).toBeDisabled()
expect(
within(oldCard).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }),
).toBeDisabled()
expect(dragAndDrop.disabledTaskIds.has(tasks[0].id)).toBe(true)
expect(dragAndDrop.disabledTaskIds.has(otherTask.id)).toBe(false)
expect(within(otherCard).getByRole('button', { name: 'Redigera Putsa fönster' })).toBeEnabled()
expect(within(dialog).getByLabelText('Titel')).toBeDisabled()
expect(within(dialog).getByRole('button', { name: 'Stäng' })).toBeDisabled()
expect(within(dialog).getByRole('button', { name: 'Avbryt' })).toBeDisabled()
expect(within(dialog).getByRole('button', { name: 'Sparar…' })).toBeDisabled()
expect(screen.getByText('Dammsuga')).toBeInTheDocument()
expect(screen.queryByText('Dammsuga övervåningen')).not.toBeInTheDocument()
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/details`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Dammsuga övervåningen',
description: null,
points: 9,
}),
})
expect(fetchMock).toHaveBeenCalledTimes(3)
fireEvent.keyDown(window, { key: 'Escape' })
fireEvent.mouseDown(container.querySelector('.modal-backdrop')!)
expect(screen.getByRole('dialog', { name: 'Redigera uppgift' })).toBeInTheDocument()
await act(async () => resolveEdit(jsonResponse(serverTask)))
expect(screen.queryByRole('dialog', { name: 'Redigera uppgift' })).not.toBeInTheDocument()
const waitingCards = within(screen.getByRole('region', { name: 'Väntande' })).getAllByRole(
'article',
)
expect(within(waitingCards[0]).getByText('Dammsuga övervåningen')).toBeInTheDocument()
expect(within(waitingCards[0]).getByText('Anna')).toBeInTheDocument()
expect(within(waitingCards[1]).getByText('Putsa fönster')).toBeInTheDocument()
})
test('oförändrad redigering skickar alla detaljfälten', async () => {
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(tasks[0]))
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Redigera Dammsuga' }))
fireEvent.click(screen.getByRole('button', { name: 'Spara' }))
await waitFor(() =>
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/details`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Dammsuga',
description: 'Bottenvåningen',
points: 7,
}),
}),
)
})
test('vanligt redigeringsfel behåller inmatning och kan återförsökas', async () => {
const updatedTask = { ...tasks[0], title: 'Ny titel' }
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({ message: 'Serverfel' }, 500))
fetchMock.mockResolvedValueOnce(jsonResponse(updatedTask))
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Redigera Dammsuga' }))
fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Ny titel' } })
fireEvent.click(screen.getByRole('button', { name: 'Spara' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Serverfel')
expect(screen.getByLabelText('Titel')).toHaveValue('Ny titel')
expect(screen.getByRole('button', { name: 'Spara' })).toBeEnabled()
expect(screen.getByText('Dammsuga')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Spara' }))
expect(await screen.findByText('Ny titel')).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledTimes(4)
})
test('endast 404 TASK_NOT_FOUND tar bort ett inaktuellt kort vid redigering', async () => {
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({ code: 'OTHER_NOT_FOUND', message: 'Annat fel' }, 404),
)
fetchMock.mockResolvedValueOnce(
jsonResponse({ code: 'TASK_NOT_FOUND', message: 'Uppgiften finns inte.' }, 404),
)
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Redigera Dammsuga' }))
fireEvent.click(screen.getByRole('button', { name: 'Spara' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Annat fel')
expect(screen.getByText('Dammsuga')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Spara' }))
await waitFor(() =>
expect(screen.queryByRole('button', { name: 'Redigera Dammsuga' })).not.toBeInTheDocument(),
)
expect(screen.queryByRole('dialog', { name: 'Redigera uppgift' })).not.toBeInTheDocument()
})
test('sopkorgsknappen öppnar delete-modal med Avbryt i fokus och utan delete-anrop', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = mockUsersAndTasks(users, [tasks[0]])
render(<App />)
const deleteButton = await screen.findByRole('button', { name: 'Radera Dammsuga' })
expect(deleteButton.querySelector('svg')).toBeInTheDocument()
fireEvent.pointerDown(deleteButton)
fireEvent.click(deleteButton)
const dialog = screen.getByRole('dialog', { name: 'Radera uppgift?' })
expect(within(dialog).getByText('Dammsuga')).toBeInTheDocument()
expect(within(dialog).getByText(/raderas permanent och kan inte återställas/i))
.toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Avbryt' })).toHaveFocus()
expect(within(dialog).getByRole('button', { name: 'Radera' })).not.toHaveFocus()
expect(within(dialog).queryByRole('button', { name: 'Stäng' })).not.toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
test('delete-modal kan stängas med Avbryt, Escape och bakgrundsklick före anrop', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = mockUsersAndTasks(users, [tasks[0]])
const { container } = render(<App />)
const deleteButton = await screen.findByRole('button', { name: 'Radera Dammsuga' })
fireEvent.click(deleteButton)
fireEvent.click(screen.getByRole('button', { name: 'Avbryt' }))
expect(screen.queryByRole('dialog', { name: 'Radera uppgift?' })).not.toBeInTheDocument()
fireEvent.click(deleteButton)
fireEvent.keyDown(window, { key: 'Escape' })
expect(screen.queryByRole('dialog', { name: 'Radera uppgift?' })).not.toBeInTheDocument()
fireEvent.click(deleteButton)
fireEvent.mouseDown(container.querySelector('.modal-backdrop')!)
expect(screen.queryByRole('dialog', { name: 'Radera uppgift?' })).not.toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
test('delete är serverbekräftad och låser bara det berörda kortet och modalen', async () => {
const otherTask = {
...tasks[0],
id: '00000000-0000-0000-0000-000000000010',
title: 'Putsa fönster',
}
let resolveDelete!: (response: Response) => void
const deleteResponse = new Promise<Response>((resolve) => {
resolveDelete = resolve
})
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(users))
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0], otherTask]))
fetchMock.mockReturnValueOnce(deleteResponse)
const { container } = render(<App />)
const deleteButton = await screen.findByRole('button', { name: 'Radera Dammsuga' })
fireEvent.click(deleteButton)
const dialog = screen.getByRole('dialog', { name: 'Radera uppgift?' })
const confirm = within(dialog).getByRole('button', { name: 'Radera' })
fireEvent.click(confirm)
fireEvent.click(confirm)
const card = screen.getByRole('button', { name: 'Radera Dammsuga' }).closest('article')!
const otherCard = screen.getByText('Putsa fönster').closest('article')!
expect(card).toBeInTheDocument()
expect(card).toHaveAttribute('aria-busy', 'true')
expect(within(card).getByRole('button', { name: 'Radera Dammsuga' })).toBeDisabled()
expect(dragAndDrop.disabledTaskIds.has(tasks[0].id)).toBe(true)
expect(dragAndDrop.disabledTaskIds.has(otherTask.id)).toBe(false)
expect(within(card).getByRole('button', { name: 'Påbörja' })).toBeDisabled()
expect(
within(card).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }),
).toBeDisabled()
expect(within(otherCard).getByRole('button', { name: 'Påbörja' })).toBeEnabled()
expect(within(otherCard).getByRole('button', { name: 'Radera Putsa fönster' })).toBeEnabled()
expect(within(dialog).getByRole('button', { name: 'Avbryt' })).toBeDisabled()
expect(confirm).toBeDisabled()
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}`, {
method: 'DELETE',
})
expect(fetchMock).toHaveBeenCalledTimes(3)
fireEvent.keyDown(window, { key: 'Escape' })
fireEvent.mouseDown(container.querySelector('.modal-backdrop')!)
expect(screen.getByRole('dialog', { name: 'Radera uppgift?' })).toBeInTheDocument()
await act(async () => resolveDelete(emptyResponse(204)))
expect(screen.queryByRole('button', { name: 'Radera Dammsuga' })).not.toBeInTheDocument()
expect(screen.queryByRole('dialog', { name: 'Radera uppgift?' })).not.toBeInTheDocument()
expect(screen.getByText('Putsa fönster')).toBeInTheDocument()
})
test('vanligt delete-fel behåller kort och dialog och kan återförsökas', async () => {
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({ message: 'Serverfel' }, 500))
fetchMock.mockResolvedValueOnce(emptyResponse(204))
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Radera Dammsuga' }))
fireEvent.click(screen.getByRole('button', { name: 'Radera' }))
const dialog = await screen.findByRole('dialog', { name: 'Radera uppgift?' })
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Det gick inte att radera uppgiften. Försök igen.',
)
expect(screen.getByRole('button', { name: 'Radera Dammsuga' })).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Avbryt' })).toBeEnabled()
fireEvent.click(within(dialog).getByRole('button', { name: 'Radera' }))
await waitFor(() =>
expect(screen.queryByRole('button', { name: 'Radera Dammsuga' })).not.toBeInTheDocument(),
)
expect(fetchMock).toHaveBeenCalledTimes(4)
})
test('404 TASK_NOT_FOUND tar bort inaktuellt kort men andra 404-fel gör det inte', async () => {
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({ code: 'OTHER_NOT_FOUND', message: 'Annat fel' }, 404),
)
fetchMock.mockResolvedValueOnce(
jsonResponse({ code: 'TASK_NOT_FOUND', message: 'Uppgiften finns inte.' }, 404),
)
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Radera Dammsuga' }))
fireEvent.click(screen.getByRole('button', { name: 'Radera' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Det gick inte att radera uppgiften. Försök igen.',
)
expect(screen.getByRole('button', { name: 'Radera Dammsuga' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Radera' }))
await waitFor(() =>
expect(screen.queryByRole('button', { name: 'Radera Dammsuga' })).not.toBeInTheDocument(),
)
expect(screen.queryByRole('dialog', { name: 'Radera uppgift?' })).not.toBeInTheDocument()
})
test('drag flyttar optimistiskt, låser kortet och använder hela serverresponsen', async () => {
const otherTask = {
...tasks[0],
@ -903,3 +1301,7 @@ function jsonResponse(body: unknown, status = 200) {
headers: { 'Content-Type': 'application/json' },
})
}
function emptyResponse(status: number) {
return new Response(null, { status })
}

View File

@ -24,9 +24,16 @@ type Task = {
}
type ApiError = {
code?: string
message?: string
}
type TaskDetails = {
title: string
description: string | null
points: number
}
type TaskBoardProps = {
activeUserId: string
activeUserName: string
@ -44,6 +51,10 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
const [tasks, setTasks] = useState<Task[]>([])
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [showCreateTask, setShowCreateTask] = useState(false)
const [editingTask, setEditingTask] = useState<Task | null>(null)
const [editError, setEditError] = useState('')
const [deletingTask, setDeletingTask] = useState<Task | null>(null)
const [deleteError, setDeleteError] = useState('')
const [editingAssigneeTaskId, setEditingAssigneeTaskId] = useState<string | null>(null)
const [pendingTaskIds, setPendingTaskIds] = useState<Set<string>>(new Set())
const [taskErrors, setTaskErrors] = useState<Record<string, string>>({})
@ -194,6 +205,108 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
void updateStatus(task, status, 'optimistic')
}
const openEditTask = (task: Task) => {
if (pendingTaskIdsRef.current.has(task.id)) {
return
}
setEditError('')
setEditingTask(task)
}
const closeEditTask = () => {
if (editingTask && pendingTaskIdsRef.current.has(editingTask.id)) {
return
}
setEditError('')
setEditingTask(null)
}
const updateDetails = async (task: Task, details: TaskDetails) => {
if (!beginTaskRequest(task.id)) {
return
}
setEditError('')
try {
const response = await fetch(`/api/tasks/${task.id}/details`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(details),
})
if (!response.ok) {
const apiError = (await response.json().catch(() => ({}))) as ApiError
if (response.status === 404 && apiError.code === 'TASK_NOT_FOUND') {
setTasks((current) => current.filter((candidate) => candidate.id !== task.id))
setEditingTask(null)
return
}
setEditError(apiError.message ?? 'Det gick inte att spara ändringarna. Försök igen.')
return
}
replaceTask((await response.json()) as Task)
setEditingTask(null)
} catch {
setEditError('Det gick inte att spara ändringarna. Försök igen.')
} finally {
finishTaskRequest(task.id)
}
}
const openDeleteTask = (task: Task) => {
if (pendingTaskIdsRef.current.has(task.id)) {
return
}
setDeleteError('')
setDeletingTask(task)
}
const closeDeleteTask = () => {
if (deletingTask && pendingTaskIdsRef.current.has(deletingTask.id)) {
return
}
setDeleteError('')
setDeletingTask(null)
}
const deleteTask = async (task: Task) => {
if (!beginTaskRequest(task.id)) {
return
}
setDeleteError('')
try {
const response = await fetch(`/api/tasks/${task.id}`, { method: 'DELETE' })
if (response.status === 204) {
setTasks((current) => current.filter((candidate) => candidate.id !== task.id))
setDeletingTask(null)
return
}
const apiError = (await response.json().catch(() => ({}))) as ApiError
if (response.status === 404 && apiError.code === 'TASK_NOT_FOUND') {
setTasks((current) => current.filter((candidate) => candidate.id !== task.id))
setDeletingTask(null)
return
}
setDeleteError('Det gick inte att radera uppgiften. Försök igen.')
} catch {
setDeleteError('Det gick inte att radera uppgiften. Försök igen.')
} finally {
finishTaskRequest(task.id)
}
}
return (
<main className="task-app">
<header className="app-header">
@ -241,6 +354,8 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
onChangeStatus={(task, status) =>
void updateStatus(task, status, 'server-confirmed')
}
onEdit={openEditTask}
onDelete={openDeleteTask}
/>
))}
</section>
@ -256,6 +371,26 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
}}
/>
)}
{editingTask && (
<EditTaskModal
task={editingTask}
pending={pendingTaskIds.has(editingTask.id)}
error={editError}
onClose={closeEditTask}
onSave={(details) => void updateDetails(editingTask, details)}
/>
)}
{deletingTask && (
<DeleteTaskModal
task={deletingTask}
pending={pendingTaskIds.has(deletingTask.id)}
error={deleteError}
onClose={closeDeleteTask}
onConfirm={() => void deleteTask(deletingTask)}
/>
)}
</main>
)
}
@ -270,6 +405,8 @@ type TaskColumnProps = {
onEditAssignee: (taskId: string) => void
onChangeAssignee: (task: Task, assigneeId: string) => void
onChangeStatus: (task: Task, status: TaskStatus) => void
onEdit: (task: Task) => void
onDelete: (task: Task) => void
}
function TaskColumn({
@ -282,6 +419,8 @@ function TaskColumn({
onEditAssignee,
onChangeAssignee,
onChangeStatus,
onEdit,
onDelete,
}: TaskColumnProps) {
const { ref, isDropTarget } = useTaskColumnDropTarget(column.status)
@ -304,6 +443,8 @@ function TaskColumn({
onEditAssignee={() => onEditAssignee(task.id)}
onChangeAssignee={(assigneeId) => onChangeAssignee(task, assigneeId)}
onChangeStatus={(status) => onChangeStatus(task, status)}
onEdit={() => onEdit(task)}
onDelete={() => onDelete(task)}
/>
))}
</div>
@ -320,6 +461,8 @@ type TaskCardProps = {
onEditAssignee: () => void
onChangeAssignee: (assigneeId: string) => void
onChangeStatus: (status: TaskStatus) => void
onEdit: () => void
onDelete: () => void
}
function TaskCard({
@ -331,6 +474,8 @@ function TaskCard({
onEditAssignee,
onChangeAssignee,
onChangeStatus,
onEdit,
onDelete,
}: TaskCardProps) {
const { ref, isDragging } = useTaskDraggable(task.id, pending)
@ -345,7 +490,29 @@ function TaskCard({
>
<div className="task-card-header">
<h3>{task.title}</h3>
<span className="points-badge">{task.points} p</span>
<div className="task-card-actions">
<span className="points-badge">{task.points} p</span>
<button
type="button"
className="task-edit-button"
aria-label={`Redigera ${task.title}`}
disabled={pending}
onPointerDown={(event) => event.stopPropagation()}
onClick={onEdit}
>
<EditIcon />
</button>
<button
type="button"
className="task-delete-button"
aria-label={`Radera ${task.title}`}
disabled={pending}
onPointerDown={(event) => event.stopPropagation()}
onClick={onDelete}
>
<TrashIcon />
</button>
</div>
</div>
{task.description && <p>{task.description}</p>}
<AssigneeControl
@ -366,6 +533,267 @@ function TaskCard({
)
}
function EditIcon() {
return (
<svg
viewBox="0 0 24 24"
width="19"
height="19"
aria-hidden="true"
focusable="false"
>
<path
d="m4 20 4.5-1 10-10a2.1 2.1 0 0 0-3-3l-10 10L4 20Zm10-12 3 3"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
function TrashIcon() {
return (
<svg
viewBox="0 0 24 24"
width="19"
height="19"
aria-hidden="true"
focusable="false"
>
<path
d="M4 7h16M9 7V4h6v3m-8 0 1 13h8l1-13M10 11v5m4-5v5"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
type EditTaskModalProps = {
task: Task
pending: boolean
error: string
onClose: () => void
onSave: (details: TaskDetails) => void
}
function EditTaskModal({ task, pending, error, onClose, onSave }: EditTaskModalProps) {
const [title, setTitle] = useState(task.title)
const [description, setDescription] = useState(task.description ?? '')
const [points, setPoints] = useState(String(task.points))
const [validationError, setValidationError] = useState('')
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !pending) {
onClose()
}
}
window.addEventListener('keydown', closeOnEscape)
return () => window.removeEventListener('keydown', closeOnEscape)
}, [onClose, pending])
const closeFromBackdrop = (event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget && !pending) {
onClose()
}
}
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (pending) {
return
}
const trimmedTitle = title.trim()
const trimmedDescription = description.trim()
const numericPoints = Number(points)
if (!trimmedTitle || [...trimmedTitle].length > 100) {
setValidationError('Titeln måste innehålla mellan 1 och 100 tecken.')
return
}
if ([...trimmedDescription].length > 500) {
setValidationError('Beskrivningen får innehålla högst 500 tecken.')
return
}
if (
!points.trim() ||
!Number.isInteger(numericPoints) ||
numericPoints < 1 ||
numericPoints > 99
) {
setValidationError('Poäng måste vara ett heltal mellan 1 och 99.')
return
}
setValidationError('')
onSave({
title: trimmedTitle,
description: trimmedDescription || null,
points: numericPoints,
})
}
return (
<div className="modal-backdrop" onMouseDown={closeFromBackdrop}>
<section
className="modal edit-task-modal"
role="dialog"
aria-modal="true"
aria-labelledby="edit-task-title"
>
<div className="modal-header">
<h2 id="edit-task-title">Redigera uppgift</h2>
<button
type="button"
className="close-button"
aria-label="Stäng"
disabled={pending}
onClick={onClose}
>
×
</button>
</div>
<form noValidate onSubmit={submit}>
<label htmlFor="edit-task-title-field">Titel</label>
<input
id="edit-task-title-field"
autoFocus
value={title}
disabled={pending}
onChange={(event) => setTitle(event.target.value)}
/>
<label htmlFor="edit-task-description">Beskrivning (valfri)</label>
<textarea
id="edit-task-description"
rows={5}
value={description}
disabled={pending}
onChange={(event) => setDescription(event.target.value)}
/>
<label htmlFor="edit-task-points">Poäng</label>
<input
id="edit-task-points"
type="number"
min="1"
max="99"
step="1"
value={points}
disabled={pending}
onChange={(event) => setPoints(event.target.value)}
/>
{(validationError || error) && (
<p className="error" role="alert">
{validationError || error}
</p>
)}
<div className="edit-task-actions">
<button
type="button"
className="secondary compact"
disabled={pending}
onClick={onClose}
>
Avbryt
</button>
<button type="submit" disabled={pending}>
{pending ? 'Sparar…' : 'Spara'}
</button>
</div>
</form>
</section>
</div>
)
}
type DeleteTaskModalProps = {
task: Task
pending: boolean
error: string
onClose: () => void
onConfirm: () => void
}
function DeleteTaskModal({
task,
pending,
error,
onClose,
onConfirm,
}: DeleteTaskModalProps) {
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !pending) {
onClose()
}
}
window.addEventListener('keydown', closeOnEscape)
return () => window.removeEventListener('keydown', closeOnEscape)
}, [onClose, pending])
const closeFromBackdrop = (event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget && !pending) {
onClose()
}
}
return (
<div className="modal-backdrop" onMouseDown={closeFromBackdrop}>
<section
className="modal delete-task-modal"
role="dialog"
aria-modal="true"
aria-labelledby="delete-task-title"
>
<div className="modal-header">
<h2 id="delete-task-title">Radera uppgift?</h2>
</div>
<p>
Är du säker att du vill radera <strong>{task.title}</strong>? Uppgiften
raderas permanent och kan inte återställas.
</p>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
<div className="delete-task-actions">
<button
type="button"
className="secondary compact"
autoFocus
disabled={pending}
onClick={onClose}
>
Avbryt
</button>
<button
type="button"
className="danger"
disabled={pending}
onClick={onConfirm}
>
Radera
</button>
</div>
</section>
</div>
)
}
type AssigneeControlProps = {
task: Task
users: UserSummary[]

View File

@ -260,6 +260,46 @@ textarea {
gap: 0.75rem;
}
.task-card-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.35rem;
}
.task-edit-button,
.task-delete-button {
display: inline-grid;
width: 2.5rem;
height: 2.5rem;
padding: 0;
place-items: center;
color: #64748b;
background: transparent;
}
.task-edit-button:hover,
.task-edit-button:focus-visible {
color: #1d4ed8;
background: #dbeafe;
}
.task-edit-button:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.task-delete-button:hover,
.task-delete-button:focus-visible {
color: #991b1b;
background: #fee2e2;
}
.task-delete-button:focus-visible {
outline: 2px solid #dc2626;
outline-offset: 2px;
}
.points-badge {
flex: 0 0 auto;
padding: 0.2rem 0.5rem;
@ -327,6 +367,40 @@ textarea {
margin: 0;
}
.delete-task-modal p {
margin: 0 0 1rem;
}
.edit-task-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1rem;
}
.edit-task-actions .secondary {
margin-top: 0;
}
.delete-task-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.delete-task-actions .secondary {
margin-top: 0;
}
.danger {
background: #b91c1c;
}
.danger:hover,
.danger:focus-visible {
background: #991b1b;
}
.close-button {
padding: 0.2rem 0.55rem;
color: #475569;