3 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
14 changed files with 1426 additions and 45 deletions

View File

@ -10,10 +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. 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 API:t innehåller endpoints under `/api/users` för användare och `/api/tasks` för
att skapa, lista, tilldela, ändra status på och permanent radera gemensamma att skapa, lista, redigera, tilldela, ändra status på och permanent radera
hushållsuppgifter. Uppgiftskort kan flyttas mellan brädans statuskolumner med gemensamma hushållsuppgifter. Uppgiftskort kan flyttas mellan brädans
drag-and-drop eller med de befintliga statusknapparna. Radering kräver statuskolumner med drag-and-drop eller med de befintliga statusknapparna.
bekräftelse och genomförs först när backend har bekräftat operationen. Redigering och radering genomförs först när backend har bekräftat operationen.
## Starta backend ## Starta backend

View File

@ -112,6 +112,17 @@ class Task {
status = targetStatus; 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() { Instant getCreatedAt() {
return createdAt; return createdAt;
} }

View File

@ -67,6 +67,21 @@ public class TaskController {
request.parsedActiveUserId().value()); 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}") @DeleteMapping("/{taskId}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable UUID taskId) { public void delete(@PathVariable UUID taskId) {

View File

@ -43,23 +43,9 @@ class TaskService {
String requestedDescription, String requestedDescription,
Integer requestedPoints, Integer requestedPoints,
UUID requestedAssigneeId) { UUID requestedAssigneeId) {
String title = requestedTitle == null ? "" : requestedTitle.trim(); String title = validateTitle(requestedTitle);
String description = normalizeDescription(requestedDescription); String description = validateDescription(requestedDescription);
int points = validatePoints(requestedPoints);
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.");
}
User assignee = findAssignee(requestedAssigneeId); User assignee = findAssignee(requestedAssigneeId);
Task task = new Task( Task task = new Task(
@ -67,7 +53,7 @@ class TaskService {
title, title,
description, description,
TaskStatus.WAITING, TaskStatus.WAITING,
requestedPoints, points,
assignee, assignee,
Instant.now(clock)); Instant.now(clock));
@ -104,6 +90,22 @@ class TaskService {
return TaskResponse.from(task); 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 @Transactional
void delete(UUID taskId) { void delete(UUID taskId) {
Task task = taskRepository.findById(taskId) Task task = taskRepository.findById(taskId)
@ -120,13 +122,37 @@ class TaskService {
.orElseThrow(AssigneeNotFoundException::new); .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) { if (requestedDescription == null) {
return null; return null;
} }
String description = requestedDescription.trim(); 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) { 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,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 org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
class TaskTest { class TaskTest {
@ -29,6 +30,25 @@ class TaskTest {
Instant.parse("2026-07-26T12:00:00Z"))); 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) { private Task taskWithPoints(int points) {
return new Task( return new Task(
UUID.randomUUID(), UUID.randomUUID(),

View File

@ -29,6 +29,7 @@ dnd-kit-ekosystemets aktuella React-adapter. Den ansvarar för:
- hämtning och presentation av användare och uppgifter; - hämtning och presentation av användare och uppgifter;
- lokalt val av aktiv användare; - lokalt val av aktiv användare;
- formulär för att skapa användare och uppgifter; - 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; - val och visning av ansvarig användare på uppgifter;
- serverbekräftade statusändringar genom knappar på uppgiftskorten; - serverbekräftade statusändringar genom knappar på uppgiftskorten;
- optimistiska statusflyttar genom drag-and-drop mellan brädans kolumner; - optimistiska statusflyttar genom drag-and-drop mellan brädans kolumner;
@ -66,6 +67,7 @@ Aktuella endpoints:
- `POST /api/tasks` - `POST /api/tasks`
- `PUT /api/tasks/{taskId}/assignee` - `PUT /api/tasks/{taskId}/assignee`
- `PUT /api/tasks/{taskId}/status` - `PUT /api/tasks/{taskId}/status`
- `PUT /api/tasks/{taskId}/details`
- `DELETE /api/tasks/{taskId}` - `DELETE /api/tasks/{taskId}`
### Databas och migreringar ### Databas och migreringar

View File

@ -2,7 +2,7 @@
## Status ## Status
Implementerad och verifierad på feature-branchen. Merge återstår. Färdig och mergad till main.
## Bakgrund ## Bakgrund
@ -379,7 +379,7 @@ fortsatt `400 INVALID_TASK_ASSIGNMENT`.
## Tester och verifiering ## Tester och verifiering
Automatiskt verifierat på feature-branchen: Automatiskt verifierat:
- backendens fullständiga testsvit: 47 tester passerade; - backendens fullständiga testsvit: 47 tester passerade;
- frontendens fullständiga testsvit: 49 tester passerade; - frontendens fullständiga testsvit: 49 tester passerade;
@ -411,12 +411,12 @@ i Chrome. Följande verifierades:
- desktopbredd 1440 × 1000 och mobilbredd 390 × 844 utan horisontell - desktopbredd 1440 × 1000 och mobilbredd 390 × 844 utan horisontell
scrollning. scrollning.
Inga problem upptäcktes i Feature 7-flödena. Featuren ska ändå inte markeras Inga problem upptäcktes i Feature 7-flödena.
som mergad eller `Klar` i roadmapen förrän merge är genomförd.
## Relaterade commits ## Relaterade commits
Fylls i efter commit och merge. - Feature-commit: `f296d15`
- Merge-commit: `5df0146`
## Implementationsprinciper ## Implementationsprinciper

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 ## Nuvarande läge
Feature 06 är klara. Feature 7 är implementerad och verifierad Feature 07 är klara och finns på `main`. Feature 8 är implementerad på sin
feature-branchen; merge återstår. Den aktuella applikationen på feature-branch och inväntar manuell verifiering och merge. Den aktuella
feature-branchen har: applikationen på feature-branchen har:
- ett monorepo med separat React/Vite-frontend och Spring Boot-backend; - ett monorepo med separat React/Vite-frontend och Spring Boot-backend;
- centralt lagrade användare och ett lokalt browserval av aktiv användare; - centralt lagrade användare och ett lokalt browserval av aktiv användare;
@ -49,15 +49,17 @@ feature-branchen har:
- automatisk tilldelning till aktiv användare när en otilldelad uppgift påbörjas; - automatisk tilldelning till aktiv användare när en otilldelad uppgift påbörjas;
- drag-and-drop mellan statuskolumner med optimistisk flytt och rollback; - drag-and-drop mellan statuskolumner med optimistisk flytt och rollback;
- serverbekräftad permanent radering med bekräftelsedialog; - 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; - en bräda med Väntande, Pågående och Klart;
- nya uppgifter som alltid skapas med status `WAITING`. - nya uppgifter som alltid skapas med status `WAITING`.
Tilldelning och status är separata egenskaper; tilldelningsflödet ändrar inte Tilldelning och status är separata egenskaper; tilldelningsflödet ändrar inte
uppgiftens status. Alla direkta statusövergångar är tillåtna och uppgiftens status. Alla direkta statusövergångar är tillåtna och
`IN_PROGRESS` kräver ansvarig. Det finns ännu ingen redigering, deadline eller `IN_PROGRESS` kräver ansvarig. Det finns ännu ingen deadline eller återkommande
återkommande uppgift. Nuvarande användarval är inte autentisering. uppgift. Nuvarande användarval är inte autentisering.
**Feature 7 Radera uppgift är pågående tills merge är genomförd.** **Feature 8 Redigera uppgift är pågående. Ingen senare produktfeature utses
som nästa innan Feature 8 har verifierats och mergats.**
## Featureöversikt ## Featureöversikt
@ -70,8 +72,8 @@ uppgiftens status. Alla direkta statusövergångar är tillåtna och
| 4 Tilldelning | Klar | 12 | Valfri ansvarig användare | | 4 Tilldelning | Klar | 12 | Valfri ansvarig användare |
| 5 Statusändring | Klar | 4 | Backendstyrda statusövergångar | | 5 Statusändring | Klar | 4 | Backendstyrda statusövergångar |
| 6 Drag-and-drop | Klar | 5 | Kortflytt via status-API | | 6 Drag-and-drop | Klar | 5 | Kortflytt via status-API |
| 7 Radera uppgift | Pågående | 2 | Bekräftad permanent radering | | 7 Radera uppgift | Klar | 2 | Bekräftad permanent radering |
| 8 Redigera uppgift | Planerad | 3 | Titel, beskrivning och poäng | | 8 Redigera uppgift | Pågående | 3 | Titel, beskrivning och poäng |
| 9 Deadline | Planerad | 2 | Valfri deadline och förseningsmarkering | | 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 | | 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 | | 11 Design av återkommande uppgifter | Planerad | 35, 9 | Beslut och plan, ingen produktionskod |
@ -210,7 +212,7 @@ modellen och statusreglerna finns.
### Feature 7 Radera uppgift ### Feature 7 Radera uppgift
**Status:** Pågående **Status:** Klar
**Beroenden:** Feature 2 **Beroenden:** Feature 2
@ -223,14 +225,17 @@ modellen och statusreglerna finns.
Radering hålls separat från redigering så att databorttagning och dess Radering hålls separat från redigering så att databorttagning och dess
konsekvenser kan verifieras isolerat. konsekvenser kan verifieras isolerat.
Feature 7 använder permanent fysisk radering. Frontend behåller kortet tills Feature 7 använder permanent fysisk radering genom
backend har bekräftat raderingen och använder samma låsning per task-id som `DELETE /api/tasks/{taskId}`. En bekräftelsemodal visas före anropet och
status, tilldelning och drag-and-drop. Implementation, automatiska tester och frontend behåller kortet tills backend har bekräftat raderingen. Operationen
manuell browserverifiering är färdiga på feature-branchen; merge återstår. 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 ### Feature 8 Redigera uppgift
**Status:** Planerad **Status:** Pågående
**Beroenden:** Feature 3 **Beroenden:** Feature 3
@ -244,6 +249,12 @@ Featuren ligger efter poäng för att redigeringsflödet ska omfatta den då
aktuella uppgiftsmodellen. Ansvarig ska fortsatt ändras genom aktuella uppgiftsmodellen. Ansvarig ska fortsatt ändras genom
tilldelningsflödet från Feature 4 och status genom statusflödet från Feature 5. 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 ### Feature 9 Deadline
**Status:** Planerad **Status:** Planerad
@ -442,8 +453,11 @@ Nuvarande aktiva användarval är uttryckligen inte autentisering.
## Ändringshistorik ## Ändringshistorik
- 2026-07-27: Feature 7 implementerades och verifierades på feature-branchen - 2026-07-27: Feature 8 implementerades och verifierades automatiskt på
med permanent, serverbekräftad radering. Merge återstår. 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 - 2026-07-27: Feature 6 verifierades och mergades. Optimistisk drag-and-drop
med full rollback infördes, och Feature 7 blev nästa planerade med full rollback infördes, och Feature 7 blev nästa planerade
produktfeature. produktfeature.

View File

@ -550,6 +550,247 @@ test('statusfel behåller tidigare status och ansvarig och visas på kortet', as
expect(within(card).getByText('Ta uppgift')).toBeInTheDocument() 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 () => { test('sopkorgsknappen öppnar delete-modal med Avbryt i fokus och utan delete-anrop', async () => {
window.localStorage.setItem('hemhub.activeUserId', users[0].id) window.localStorage.setItem('hemhub.activeUserId', users[0].id)
const fetchMock = mockUsersAndTasks(users, [tasks[0]]) const fetchMock = mockUsersAndTasks(users, [tasks[0]])

View File

@ -28,6 +28,12 @@ type ApiError = {
message?: string message?: string
} }
type TaskDetails = {
title: string
description: string | null
points: number
}
type TaskBoardProps = { type TaskBoardProps = {
activeUserId: string activeUserId: string
activeUserName: string activeUserName: string
@ -45,6 +51,8 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading') const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
const [showCreateTask, setShowCreateTask] = useState(false) const [showCreateTask, setShowCreateTask] = useState(false)
const [editingTask, setEditingTask] = useState<Task | null>(null)
const [editError, setEditError] = useState('')
const [deletingTask, setDeletingTask] = useState<Task | null>(null) const [deletingTask, setDeletingTask] = useState<Task | null>(null)
const [deleteError, setDeleteError] = useState('') const [deleteError, setDeleteError] = useState('')
const [editingAssigneeTaskId, setEditingAssigneeTaskId] = useState<string | null>(null) const [editingAssigneeTaskId, setEditingAssigneeTaskId] = useState<string | null>(null)
@ -197,6 +205,59 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
void updateStatus(task, status, 'optimistic') 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) => { const openDeleteTask = (task: Task) => {
if (pendingTaskIdsRef.current.has(task.id)) { if (pendingTaskIdsRef.current.has(task.id)) {
return return
@ -293,6 +354,7 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
onChangeStatus={(task, status) => onChangeStatus={(task, status) =>
void updateStatus(task, status, 'server-confirmed') void updateStatus(task, status, 'server-confirmed')
} }
onEdit={openEditTask}
onDelete={openDeleteTask} onDelete={openDeleteTask}
/> />
))} ))}
@ -310,6 +372,16 @@ 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 && ( {deletingTask && (
<DeleteTaskModal <DeleteTaskModal
task={deletingTask} task={deletingTask}
@ -333,6 +405,7 @@ type TaskColumnProps = {
onEditAssignee: (taskId: string) => void onEditAssignee: (taskId: string) => void
onChangeAssignee: (task: Task, assigneeId: string) => void onChangeAssignee: (task: Task, assigneeId: string) => void
onChangeStatus: (task: Task, status: TaskStatus) => void onChangeStatus: (task: Task, status: TaskStatus) => void
onEdit: (task: Task) => void
onDelete: (task: Task) => void onDelete: (task: Task) => void
} }
@ -346,6 +419,7 @@ function TaskColumn({
onEditAssignee, onEditAssignee,
onChangeAssignee, onChangeAssignee,
onChangeStatus, onChangeStatus,
onEdit,
onDelete, onDelete,
}: TaskColumnProps) { }: TaskColumnProps) {
const { ref, isDropTarget } = useTaskColumnDropTarget(column.status) const { ref, isDropTarget } = useTaskColumnDropTarget(column.status)
@ -369,6 +443,7 @@ function TaskColumn({
onEditAssignee={() => onEditAssignee(task.id)} onEditAssignee={() => onEditAssignee(task.id)}
onChangeAssignee={(assigneeId) => onChangeAssignee(task, assigneeId)} onChangeAssignee={(assigneeId) => onChangeAssignee(task, assigneeId)}
onChangeStatus={(status) => onChangeStatus(task, status)} onChangeStatus={(status) => onChangeStatus(task, status)}
onEdit={() => onEdit(task)}
onDelete={() => onDelete(task)} onDelete={() => onDelete(task)}
/> />
))} ))}
@ -386,6 +461,7 @@ type TaskCardProps = {
onEditAssignee: () => void onEditAssignee: () => void
onChangeAssignee: (assigneeId: string) => void onChangeAssignee: (assigneeId: string) => void
onChangeStatus: (status: TaskStatus) => void onChangeStatus: (status: TaskStatus) => void
onEdit: () => void
onDelete: () => void onDelete: () => void
} }
@ -398,6 +474,7 @@ function TaskCard({
onEditAssignee, onEditAssignee,
onChangeAssignee, onChangeAssignee,
onChangeStatus, onChangeStatus,
onEdit,
onDelete, onDelete,
}: TaskCardProps) { }: TaskCardProps) {
const { ref, isDragging } = useTaskDraggable(task.id, pending) const { ref, isDragging } = useTaskDraggable(task.id, pending)
@ -415,6 +492,16 @@ function TaskCard({
<h3>{task.title}</h3> <h3>{task.title}</h3>
<div className="task-card-actions"> <div className="task-card-actions">
<span className="points-badge">{task.points} p</span> <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 <button
type="button" type="button"
className="task-delete-button" className="task-delete-button"
@ -446,6 +533,27 @@ 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() { function TrashIcon() {
return ( return (
<svg <svg
@ -467,6 +575,149 @@ function TrashIcon() {
) )
} }
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 = { type DeleteTaskModalProps = {
task: Task task: Task
pending: boolean pending: boolean

View File

@ -267,6 +267,7 @@ textarea {
gap: 0.35rem; gap: 0.35rem;
} }
.task-edit-button,
.task-delete-button { .task-delete-button {
display: inline-grid; display: inline-grid;
width: 2.5rem; width: 2.5rem;
@ -277,6 +278,17 @@ textarea {
background: transparent; 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:hover,
.task-delete-button:focus-visible { .task-delete-button:focus-visible {
color: #991b1b; color: #991b1b;
@ -359,6 +371,17 @@ textarea {
margin: 0 0 1rem; 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 { .delete-task-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;