4 Commits

Author SHA1 Message Date
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
11 changed files with 1027 additions and 32 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, 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. Radering kräver
bekräftelse och genomförs först när backend har bekräftat operationen.
## Starta backend

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

View File

@ -104,6 +104,13 @@ class TaskService {
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;

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

@ -32,6 +32,7 @@ dnd-kit-ekosystemets aktuella React-adapter. Den ansvarar för:
- 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 +66,7 @@ Aktuella endpoints:
- `POST /api/tasks`
- `PUT /api/tasks/{taskId}/assignee`
- `PUT /api/tasks/{taskId}/status`
- `DELETE /api/tasks/{taskId}`
### Databas och migreringar
@ -129,6 +131,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 +162,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

@ -34,9 +34,7 @@ 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`. Den aktuella applikationen 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 +46,16 @@ 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;
- 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 redigering, 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 nästa planerade produktfeature.**
## Featureöversikt
@ -70,8 +67,8 @@ 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 |
| 6 Drag-and-drop | Klar | 5 | Kortflytt via status-API |
| 7 Radera uppgift | Klar | 2 | Bekräftad permanent radering |
| 8 Redigera uppgift | Planerad | 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 |
@ -182,7 +179,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 +198,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 +208,7 @@ modellen och statusreglerna finns.
### Feature 7 Radera uppgift
**Status:** Planerad
**Status:** Klar
**Beroenden:** Feature 2
@ -225,9 +221,13 @@ 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
@ -433,7 +433,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 +443,12 @@ 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 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,153 @@ test('statusfel behåller tidigare status och ansvarig och visas på kortet', as
expect(within(card).getByText('Ta uppgift')).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 +1060,7 @@ function jsonResponse(body: unknown, status = 200) {
headers: { 'Content-Type': 'application/json' },
})
}
function emptyResponse(status: number) {
return new Response(null, { status })
}

View File

@ -24,6 +24,7 @@ type Task = {
}
type ApiError = {
code?: string
message?: string
}
@ -44,6 +45,8 @@ 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 [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 +197,55 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
void updateStatus(task, status, 'optimistic')
}
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 +293,7 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
onChangeStatus={(task, status) =>
void updateStatus(task, status, 'server-confirmed')
}
onDelete={openDeleteTask}
/>
))}
</section>
@ -256,6 +309,16 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
}}
/>
)}
{deletingTask && (
<DeleteTaskModal
task={deletingTask}
pending={pendingTaskIds.has(deletingTask.id)}
error={deleteError}
onClose={closeDeleteTask}
onConfirm={() => void deleteTask(deletingTask)}
/>
)}
</main>
)
}
@ -270,6 +333,7 @@ type TaskColumnProps = {
onEditAssignee: (taskId: string) => void
onChangeAssignee: (task: Task, assigneeId: string) => void
onChangeStatus: (task: Task, status: TaskStatus) => void
onDelete: (task: Task) => void
}
function TaskColumn({
@ -282,6 +346,7 @@ function TaskColumn({
onEditAssignee,
onChangeAssignee,
onChangeStatus,
onDelete,
}: TaskColumnProps) {
const { ref, isDropTarget } = useTaskColumnDropTarget(column.status)
@ -304,6 +369,7 @@ function TaskColumn({
onEditAssignee={() => onEditAssignee(task.id)}
onChangeAssignee={(assigneeId) => onChangeAssignee(task, assigneeId)}
onChangeStatus={(status) => onChangeStatus(task, status)}
onDelete={() => onDelete(task)}
/>
))}
</div>
@ -320,6 +386,7 @@ type TaskCardProps = {
onEditAssignee: () => void
onChangeAssignee: (assigneeId: string) => void
onChangeStatus: (status: TaskStatus) => void
onDelete: () => void
}
function TaskCard({
@ -331,6 +398,7 @@ function TaskCard({
onEditAssignee,
onChangeAssignee,
onChangeStatus,
onDelete,
}: TaskCardProps) {
const { ref, isDragging } = useTaskDraggable(task.id, pending)
@ -345,7 +413,19 @@ 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-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 +446,103 @@ function TaskCard({
)
}
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 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,34 @@ textarea {
gap: 0.75rem;
}
.task-card-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.35rem;
}
.task-delete-button {
display: inline-grid;
width: 2.5rem;
height: 2.5rem;
padding: 0;
place-items: center;
color: #64748b;
background: transparent;
}
.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 +355,29 @@ textarea {
margin: 0;
}
.delete-task-modal p {
margin: 0 0 1rem;
}
.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;