From c3c64482c062f144fd6cb6036e3c9db0afa5ec1e Mon Sep 17 00:00:00 2001 From: Urban Modig Date: Mon, 27 Jul 2026 13:15:39 +0200 Subject: [PATCH] feat: add task drag and drop --- README.md | 2 + docs/architecture.md | 16 +- docs/features/006-task-drag-and-drop.md | 545 ++++++++++++++++++++++++ docs/roadmap.md | 32 +- frontend/package.json | 2 + frontend/pnpm-lock.yaml | 74 +++- frontend/src/App.test.tsx | 205 ++++++++- frontend/src/TaskBoard.tsx | 209 +++++++-- frontend/src/TaskDragAndDrop.test.ts | 18 + frontend/src/TaskDragAndDrop.tsx | 90 ++++ frontend/src/styles.css | 15 + frontend/src/test/setup.ts | 10 + 12 files changed, 1155 insertions(+), 63 deletions(-) create mode 100644 docs/features/006-task-drag-and-drop.md create mode 100644 frontend/src/TaskDragAndDrop.test.ts create mode 100644 frontend/src/TaskDragAndDrop.tsx diff --git a/README.md b/README.md index 095952d..2aa16bc 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ 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. ## Starta backend diff --git a/docs/architecture.md b/docs/architecture.md index 9f83243..cca018f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,14 +23,15 @@ byggprocess. ### Frontend -Frontend finns i `frontend/` och använder React 19, TypeScript, Vite och pnpm. -Den ansvarar för: +Frontend finns i `frontend/` och använder React 19, TypeScript, Vite, pnpm och +dnd-kit-ekosystemets aktuella React-adapter. Den ansvarar för: - hämtning och presentation av användare och uppgifter; - lokalt val av aktiv användare; - formulär för att skapa användare och uppgifter; - 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; - klientnära validering och begripliga felmeddelanden; - uppgiftsbrädan med kolumnerna Väntande, Pågående och Klart. @@ -148,7 +149,12 @@ otillåtna tilldelningsändringar till `409 Conflict`. Frontend skiljer mellan fel vid hämtning och skapande. Hämtfel kan återförsökas. Formulärfel visas nära formuläret och inmatningen behålls vid misslyckade API-anrop. Status- och tilldelningsfel visas lokalt på berört kort; -kortet uppdateras först med backendens bekräftade respons. +statusknappar och tilldelning uppdateras först med backendens bekräftade +respons. Drag-and-drop flyttar kortet optimistiskt men återställer hela den +tidigare uppgiften vid fel. Vid framgång ersätts alltid det lokala värdet med +backendens fullständiga respons. Status- och tilldelningsanrop delar låsning per +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. ### Teststrategi @@ -160,7 +166,9 @@ Backend har JUnit 5-tester: Frontend använder Vitest, jsdom och React Testing Library. `fetch` och `localStorage` ersätts i testerna, så frontendtesterna kräver inte en körande -backend. Produktionsbygget kör TypeScript-kompilering följt av Vite. +backend. Drag-and-drop-adaptern översätter bibliotekshändelser till task-id och +status, så stateflöden kan testas utan att simulera fysisk layout i jsdom. +Produktionsbygget kör TypeScript-kompilering följt av Vite. ### Produktionsdeployment diff --git a/docs/features/006-task-drag-and-drop.md b/docs/features/006-task-drag-and-drop.md new file mode 100644 index 0000000..333848c --- /dev/null +++ b/docs/features/006-task-drag-and-drop.md @@ -0,0 +1,545 @@ +# Feature 6 – Drag-and-drop + +## Status + +Färdig och verifierad på feature-branchen, ännu inte mergad till `main`. + +## Bakgrund + +Feature 5 införde backendstyrda statusövergångar mellan `WAITING`, +`IN_PROGRESS` och `COMPLETED`, ett särskilt status-API samt ett första +knappbaserat gränssnitt för statusändring. + +Feature 6 ska komplettera detta med drag-and-drop mellan brädans +statuskolumner. Featuren ska återanvända den befintliga statusmodellen, +status-API:t, tilldelningsreglerna och frontendens hantering av vänteläge och +lokala fel. Den ska inte skapa en parallell statusmekanism. + +## Mål + +Feature 6 ska: + +- låta användaren dra uppgiftskort mellan statuskolumner; +- använda status-API:t från Feature 5; +- ge omedelbar visuell återkoppling genom optimistisk flytt; +- återställa kortet om backend avvisar statusändringen; +- hantera automatisk tilldelning när en otilldelad uppgift dras till Pågående; +- behålla befintliga statusknappar som ett tillfälligt alternativ; +- fungera med mus och på en rimlig nivå med touch; +- hålla dragmekanik, statuslogik och kortlayout tillräckligt separerade för + framtida ändringar. + +## Omfattning + +Feature 6 är primärt en frontendfeature. + +Backendens befintliga endpoint används: + +```http +PUT /api/tasks/{taskId}/status +``` + +Requesten innehåller målstatus och kan innehålla aktiv användares id: + +```json +{ + "status": "IN_PROGRESS", + "activeUserId": "d56b54dd-31b0-4d71-8a10-82464be59a61" +} +``` + +Backend ska endast ändras om granskning av den faktiska implementationen visar +att en mindre korrigering behövs för att det befintliga kontraktet ska kunna +återanvändas korrekt. + +## Implementerad lösning + +Frontend använder `@dnd-kit/react` 0.5.0 som aktuell React-adapter och +`@dnd-kit/dom` 0.5.0 för en konfigurerad pointer-sensor. Legacy-paketen +`@dnd-kit/core` och `@dnd-kit/sortable` används inte. Sortable-stöd behövs inte +eftersom kort inte ordnas inom kolumner. + +`TaskDragAndDrop.tsx` avgränsar bibliotekskopplingen. Adaptern: + +- registrerar uppgiftskort som draggable och statuskolumner som droppable; +- översätter ett lyckat drop-event till task-id och målstatus; +- ignorerar avbrutna dragningar och ogiltiga mål; +- använder sex pixlars aktiveringsavstånd för mus och penna; +- använder 250 millisekunders fördröjning med åtta pixlars tolerans för touch; +- behåller dnd-kits standardplugins och tangentbordssensor; +- förlitar sig på sensorns standardskydd mot dragstart från interaktiva + element. + +Ingen särskild `touch-action`, collision detector eller drag-overlay har lagts +till. Den befintliga responsiva layouten är oförändrad. + +`TaskBoard` har fortsatt en gemensam requestväg och låsning per task-id för +statusändringar. Ett presentationsval avgör beteendet: + +- statusknappar använder `server-confirmed` och flyttar inte kortet före svar; +- drag-and-drop använder `optimistic`, sparar hela tidigare uppgiften och + flyttar kortet direkt; +- lyckade anrop ersätter uppgiften med hela serverresponsen; +- misslyckade optimistiska anrop återställer hela rollback-värdet och visar + felet lokalt. + +När en otilldelad uppgift dras till `IN_PROGRESS` visar det optimistiska värdet +den aktiva användarens id och namn. En befintlig ansvarig behålls. Backendens +befintliga status-API och Feature 5-regler återanvänds utan backendändringar. + +Frontendens 44 tester, TypeScript-kompilering, Vites produktionsbygge och +`git diff --check` passerar på feature-branchen. Manuell browserverifiering av +de centrala drag-, server- och rollbackflödena är genomförd. + +## Avgränsningar + +Feature 6 ska inte införa: + +- en ny statusmodell; +- ett nytt eller parallellt status-API; +- manuell sortering inom en kolumn; +- persistent kortordning; +- generell redigering av uppgifter; +- radering; +- deadlines; +- återkommande uppgifter; +- status- eller poänghistorik; +- undo-funktion; +- flera ansvariga; +- autentisering eller behörigheter; +- realtidsuppdatering mellan browsers; +- en ny global state-lösning; +- horisontell scrollning som ett särskilt mobilkoncept; +- ett specialbyggt tangentbordsflöde för drag-and-drop. + +Att flytta ett kort inom samma kolumn ska inte ändra någon ordning och ska inte +ge ett backend-anrop. + +## Befintliga statusregler + +Feature 6 ska behandla följande regler som redan beslutade: + +- statusarna är `WAITING`, `IN_PROGRESS` och `COMPLETED`; +- alla direkta statusövergångar är tillåtna; +- samma målstatus är giltig och idempotent; +- `IN_PROGRESS` kräver ansvarig; +- `WAITING` och `COMPLETED` får vara otilldelade; +- när en otilldelad uppgift sätts till `IN_PROGRESS` skickar frontend aktiv + användares id; +- backend tilldelar då användaren och ändrar status atomärt; +- om uppgiften redan har en ansvarig behålls denna; +- statusoperationen byter aldrig en befintlig ansvarig; +- backend returnerar hela den uppdaterade uppgiften; +- serverns fullständiga task-respons är slutlig sanning. + +## Uppdateringsstrategi + +Drag-and-drop använder en kontrollerad optimistisk uppdatering. + +När ett kort släpps i en annan statuskolumn ska frontend: + +1. spara hela den nuvarande task-versionen som rollback-värde; +2. skapa ett optimistiskt lokalt task-läge; +3. visa kortet omedelbart i målkolumnen; +4. markera kortet som upptaget; +5. skicka statusanropet; +6. vid framgång ersätta den optimistiska uppgiften med serverns fullständiga + respons; +7. vid fel återställa hela den tidigare task-versionen; +8. visa felet lokalt på det återställda kortet. + +Rollback ska återställa hela uppgiften, inte enbart statusfältet. Detta är +viktigt eftersom en optimistisk flytt till `IN_PROGRESS` även kan innehålla en +tillfällig antagen ansvarig. + +Ingen generell infrastruktur för optimistiska uppdateringar ska införas. +Lösningen ska hållas lokal till uppgiftslistan och drag-and-drop-flödet. +Feature 5:s statusknappar ska fortsatt vara serverbekräftade: kortet ligger kvar +i sin aktuella kolumn tills backend svarar. + +## Otilldelad uppgift till Pågående + +När en otilldelad uppgift dras till `IN_PROGRESS` ska frontend: + +- skicka målstatus `IN_PROGRESS`; +- skicka aktiv användares id; +- optimistiskt visa aktiv användare som ansvarig; +- låta backend tilldela användaren och ändra status i samma transaktion. + +Om uppgiften redan har en ansvarig ska frontend inte optimistiskt ersätta denna +med den aktiva användaren. Serverresponsen ersätter alltid det optimistiska +antagandet. + +Drag-and-drop ska inte visa ett nytt ansvarigval. + +## Drag-and-drop-bibliotek + +Feature 6 ska använda dnd-kit-ekosystemets aktuella stabila React-lösning. +Featuredokumentet låser inte exakta paket eller API:n. Codex ska vid +implementation verifiera den aktuella officiella dokumentationen, vilka paket +som är aktuella respektive legacy, kompatibilitet med repositoryts +React-version samt påverkan på Vitest/jsdom och React Testing Library. + +Den valda lösningen ska stödja: + +- draggable-kort; +- droppable-statuskolumner; +- pointer- och touchinteraktion; +- aktiveringsvillkor; +- avbruten dragning; +- identifiering av målkolumn. + +Biblioteket ska inte användas för: + +- sortering inom kolumner; +- persistent ordning; +- egen domänmodell; +- generell state-hantering; +- parallell statuslogik. + +Dragbibliotekets händelser ska översättas till en gemensam, +bibliotekoberoende ingång till statusflödet. + +## Statusknapparnas roll + +Feature 5:s statusknappar behålls tills vidare som ett fullt fungerande +alternativ. De betraktas inte som en permanent del av målbilden. Den +ursprungliga visuella designen innehåller inte statusknappar, och de ska därför +enkelt kunna tas bort efter utvärdering. + +Implementation ska följa dessa principer: + +- drag-and-drop får inte byggas ovanpå knappkomponenterna; +- draglogik får inte placeras i knapparna; +- statuslogik får inte dupliceras mellan knappar och drag-and-drop; +- båda ska dela underliggande requestlogik, låsning per task-id, felhantering + och ersättning med serverrespons; +- drag-and-drop använder optimistisk visuell flytt, medan statusknapparna + förblir serverbekräftade och låter kortet ligga kvar tills backend svarar; +- den visuella presentationsstrategin får därför skilja sig mellan + interaktionerna; +- vänteläge, felhantering och serverrespons ska höra till uppgiften och det + delade statusflödet, inte dupliceras per kontroll; +- kortlayouten får inte strukturellt förutsätta att knapparna alltid finns. + +Att ta bort statusknapparna senare ska inte kräva ändringar i status-API, +dragmekanik, rollback eller task-state. Statusarkitekturen får inte vara kopplad +till att knapparna finns kvar. + +## Vänteläge + +När statusanropet pågår ska det optimistiskt flyttade kortet tonas ned lätt. +Ingen text som `Flyttar…` och ingen spinner krävs i första versionen. + +Under vänteläget ska just detta kort inte kunna: + +- dras igen; +- initiera en ny statusändring; +- ändra ansvarig. + +Övriga kort ska förbli interaktiva. + +Flera olika kort får ha statusanrop pågående samtidigt. Låsning, nedtoning, +rollback och fel hanteras per task-id. + +## Fel och återställning + +Vid ett misslyckat statusanrop ska: + +- hela den tidigare task-versionen återställas; +- kortet återgå till ursprungskolumnen; +- tidigare ansvarig återställas; +- nedtoningen tas bort; +- felet visas lokalt på kortet; +- målkolumnen inte behålla någon tillfällig markering. + +Tidigare klientdata ska inte delvis blandas med det misslyckade optimistiska +tillståndet. Ett fel på ett kort ska inte blockera resten av brädan. + +## Serverrespons + +Vid lyckat statusanrop ska frontend alltid ersätta den optimistiska uppgiften +med hela serverresponsen. + +Det gäller även om serverresponsen skiljer sig från frontendens antagande vad +gäller exempelvis: + +- status; +- ansvarig; +- andra returnerade task-fält. + +Servern är slutlig sanning. + +## Dragyta + +Kortets icke-interaktiva yta ska fungera som dragyta. Knappar, select och andra +interaktiva element ska inte initiera dragning. + +Implementation får avgöra sensoruppsättning, aktiveringströskel samt avstånd, +fördröjning och tolerans. Vanliga klick och små fingerrörelser får inte +oavsiktligt starta dragning, och kortets interaktiva kontroller ska fungera +normalt. + +Dragmekaniken ska implementeras så att ett separat draghandtag senare kan +införas utan att statusoperation, API-anrop, rollback eller task-state behöver +ändras. Det bör räcka att flytta bibliotekets draglisteners och tillhörande +attribut från kortets rot till handtaget. + +## Målkolumner + +Kolumnerna behöver ingen stark eller permanent drop-markering. Den kolumn som +kortet befinner sig över kan vid behov få en mycket diskret hover-effekt, +exempelvis: + +- en svag bakgrundsförändring; +- en tunn kant; +- annan lågmäld visuell återkoppling. + +Feature 6 ska inte införa stora färgade drop-zoner eller en generell redesign +av brädan. Om manuell verifiering visar att ingen markering behövs kan även den +diskreta hover-effekten utelämnas. + +## Drop i samma kolumn + +Om ett kort släpps i kolumnen som motsvarar dess nuvarande status ska +operationen vara no-op. + +Det innebär: + +- inget API-anrop; +- ingen statusändring; +- ingen ändring av ordning; +- inget vänteläge; +- inget felmeddelande. + +## Avbruten dragning + +Om en dragning avbryts eller avslutas utanför en giltig målkolumn ska +operationen vara no-op. Kortet ska återgå till sin normala position utan +API-anrop eller felindikering. + +## Desktop och touch + +Desktop är det primära användningsfallet för Feature 6. + +Touch ska fungera på en rimlig grundnivå, men featuren ska inte införa en +särskild mobil Kanban-design. Brädans befintliga responsiva layout ska +behållas. Feature 6 ska inte införa horisontell scrollning. På smala skärmar +får kolumnerna fortsätta använda repositoryts nuvarande responsiva layout, +även om de staplas vertikalt. + +Implementation får avgöra sensoruppsättning, aktiveringsvillkor, eventuell +`touch-action`, collision detection och drag-overlay. Valen måste bevara normal +vertikal scrollning på mobil och får inte göra interaktiva kortkontroller +svåranvända. + +Draglogiken ska: + +- identifiera mål genom status, inte genom en fast skärmposition; +- inte förutsätta att kolumnerna ligger horisontellt; +- hållas separerad från layout-CSS; +- kunna fortsätta fungera om kolumnlayouten senare ändras. + +Om manuell verifiering visar att dragning mellan staplade kolumner fungerar +dåligt kan mobilbeteendet ändras senare utan att status- eller rollbacklogiken +görs om. Statusknapparna finns kvar som alternativ, särskilt där dragning är +opraktisk. + +## Tangentbord och tillgänglighet + +Tangentbordsstyrd drag-and-drop ingår inte som grundkrav i Feature 6. +Statusknapparna ska fortsatt ge en fungerande tangentbordsväg för +statusändring. Drag-and-drop får därför inte vara den enda möjliga vägen. + +Feature 6 ska ändå uppfylla grundläggande tillgänglighetskrav: + +- interaktiva kontroller ska fortsatt gå att nå med tangentbord; +- ett upptaget kort ska inte kunna aktiveras igen; +- fokus ska inte tappas oförklarligt efter lyckad operation eller rollback; +- begripliga etiketter ska bevaras; +- dragbibliotekets standard-ARIA får användas; +- ingen omfattande speciallösning för tangentbordsdragning ska byggas. + +Förbättrat tangentbordsstöd för själva dragningen kan införas i en senare +uppdatering. + +## Gemensamt statusflöde + +Frontend ska ha ett gemensamt, kontrolloberoende statusflöde för den +underliggande statusändringen. + +Det delade flödet ska ansvara för: + +- kontroll av pågående operation för task-id; +- requestformat; +- aktiv användares id vid behov; +- per-kort-vänteläge; +- ersättning med serverrespons; +- lokalt statusfel. + +Drag-and-drop-flödet ska därutöver beräkna den optimistiska task-versionen, +spara rollback-värdet och återställa hela den tidigare uppgiften vid fel. +Statusknapparna ska inte göra en optimistisk flytt. + +Drag-and-drop och statusknappar är separata sätt att ange målstatus till det +delade flödet, men får använda olika visuell presentationsstrategi. +Tilldelningskontrollen ska återanvända samma per-kort-låsning så att status och +tilldelning inte kan ändras samtidigt på samma uppgift. + +## Frontendtester + +Frontendtesterna verifierar beteende och state utan att förutsätta fysisk +layout eller exakta pointer-koordinater i jsdom. Dragadaptern mockas i +brädtesterna, medan den bibliotekoberoende mappningen från draghändelse till +task-id och målstatus testas separat. + +Testerna täcker bland annat: + +- korrekt statusanrop och optimistisk flytt till en annan kolumn; +- låsning och nedtoning per task-id medan anropet pågår; +- att andra kort kan ha samtidiga operationer; +- automatisk optimistisk tilldelning till aktiv användare; +- att en befintlig ansvarig behålls; +- att hela serverresponsen ersätter det optimistiska värdet; +- fullständig rollback och lokalt fel vid misslyckande; +- no-op för samma status, avbruten dragning och ogiltigt mål; +- att statusknapparnas befintliga serverbekräftade beteende är bevarat. + +Totalt passerar 44 frontendtester. TypeScript-kompileringen och Vites +produktionsbygge passerar också. + +## Backendtester + +Backend ändrades inte. Feature 5:s befintliga tester fortsätter därför att +utgöra verifiering av: + +- direkta statusövergångar; +- idempotens; +- automatisk tilldelning; +- bevarad befintlig ansvarig; +- statusvalidering; +- statusberoende tilldelningsregler; +- oförändrade övriga task-fält. + +## Manuell verifiering + +Manuell browserverifiering är genomförd. Följande verifierades: + +- drag-and-drop mellan statuskolumner fungerar; +- kortet flyttas optimistiskt och tonas ned under statusanropet; +- en otilldelad uppgift som flyttas till Pågående får aktiv användare som + ansvarig; +- en befintlig ansvarig behålls; +- serverns svar ersätter det optimistiska värdet; +- statusknapparna fungerar fortsatt; +- ett blockerat statusanrop visar + `Det gick inte att ändra status. Försök igen.`; +- kortet återställs till ursprungskolumnen vid fel; +- tidigare ansvarig återställs, nedtoningen försvinner och kortet blir + interaktivt igen; +- övriga kort förblir interaktiva under operationen. + +## Dokumentation + +Feature 6 ska dokumenteras i: + +```text +docs/features/006-task-drag-and-drop.md +``` + +Vid implementation ska även följande uppdateras när det är relevant: + +```text +README.md +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. + +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 +6. Om `dnd-kit` endast används lokalt för denna feature bör beslutet normalt +dokumenteras i feature- och arkitekturdokumentationen. + +## Acceptanskriterier + +Feature 6 är klar när: + +- kort kan dras mellan olika statuskolumner; +- dragningen använder Feature 5:s status-API; +- kortet flyttas optimistiskt till målkolumnen; +- kortet tonas ned under serveranropet; +- samma kort är låst för status, dragning och tilldelning under anropet; +- andra kort förblir interaktiva; +- otilldelad uppgift till Pågående använder aktiv användares id; +- befintlig ansvarig behålls; +- serverresponsen ersätter det optimistiska tillståndet; +- fel återställer hela tidigare task-versionen; +- kortet återgår till ursprungskolumnen vid fel; +- felet visas lokalt på kortet; +- drop i samma kolumn är no-op; +- avbruten dragning är no-op; +- ingen manuell eller persistent kortordning har införts; +- statusknapparna fungerar fortsatt men är arkitekturellt frikopplade; +- statusknapparna förblir serverbekräftade medan drag-and-drop är optimistisk; +- statusknapparna kan tas bort senare utan att drag- eller statuslogiken byggs + om; +- kortets icke-interaktiva yta fungerar som dragyta; +- interaktiva kortkontroller initierar inte dragning; +- ett senare draghandtag kan införas utan ändring av statusflödet; +- normal vertikal scrollning på mobil bevaras; +- ingen horisontell scrollning har införts; +- desktop fungerar väl; +- touch fungerar på rimlig grundnivå; +- tangentbordsdragning inte krävs; +- statusändring fortsatt är möjlig med tangentbord genom statusknapparna; +- frontendtesterna täcker centrala stateövergångar och felfall; +- manuell verifiering täcker dragkänsla, touch, layout och rollback; +- backend är oförändrad om inget konkret behov av justering hittas; +- relevant dokumentation är uppdaterad. + +## Implementationsprinciper + +Vid implementationen användes följande dokumentation som tekniskt underlag: + +```text +AGENTS.md +README.md +docs/architecture.md +docs/development.md +docs/roadmap.md +docs/decisions/ +docs/features/004-task-assignment.md +docs/features/005-task-status.md +``` + +Relevant frontendkod och tester granskades särskilt avseende: + +- aktuell React-version; +- frontendens installerade beroenden; +- aktuell officiell dnd-kit-dokumentation; +- vilka dnd-kit-paket som är aktuella respektive legacy; +- dnd-kit-lösningens kompatibilitet med React-versionen; +- påverkan på Vitest/jsdom och React Testing Library; +- aktuell task-typ; +- brädans kolumnstruktur; +- uppgiftskortets komponentstruktur; +- befintliga statusknappar; +- statusanropets requestformat; +- hur aktiv användare representeras; +- hur tasks ersätts i state; +- det gemensamma vänteläget per kort; +- status- och tilldelningsfel; +- tilldelningskontrollens inaktiveringslogik; +- aktuell teststil; +- vilka pointer- och draghändelser testmiljön stödjer. + +Repositoryts faktiska kod och dokumentation har företräde framför antaganden i +denna featurebeskrivning. + +Kod, tester och relevant dokumentation ska uppdateras tillsammans. + +Codex ska inte committa, pusha, skapa pull request eller merga utan uttrycklig +instruktion. diff --git a/docs/roadmap.md b/docs/roadmap.md index 043b193..f88421b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -34,7 +34,9 @@ Följande statusvärden används: ## Nuvarande läge -Feature 0–5 är klara. Den aktuella applikationen har: +Feature 0–5 är klara. Feature 6 är implementerad och verifierad på sin +feature-branch men ännu inte mergad. Den aktuella applikationen på +feature-branchen har: - ett monorepo med separat React/Vite-frontend och Spring Boot-backend; - centralt lagrade användare och ett lokalt browserval av aktiv användare; @@ -45,16 +47,18 @@ Feature 0–5 är klara. Den aktuella applikationen har: - borttagning av ansvarig i `WAITING` och `COMPLETED`; - 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; - 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 drag-and-drop, redigering, -radering, deadline eller återkommande uppgift. -Nuvarande användarval är inte autentisering. +`IN_PROGRESS` kräver ansvarig. Det finns ännu ingen redigering, radering, +deadline eller återkommande uppgift. Nuvarande användarval är inte +autentisering. -**Feature 6 – Drag-and-drop är nästa planerade produktfeature.** +**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översikt @@ -66,7 +70,7 @@ Nuvarande användarval är inte autentisering. | 3 – Uppgiftspoäng | Klar | 2 | Poäng på uppgifter | | 4 – Tilldelning | Klar | 1–2 | Valfri ansvarig användare | | 5 – Statusändring | Klar | 4 | Backendstyrda statusövergångar | -| 6 – Drag-and-drop | Planerad | 5 | Kortflytt via status-API | +| 6 – Drag-and-drop | Pågående | 5 | Kortflytt via status-API | | 7 – Radera uppgift | Planerad | 2 | Bekräftad radering | | 8 – Redigera uppgift | Planerad | 3 | Titel, beskrivning och poäng | | 9 – Deadline | Planerad | 2 | Valfri deadline och förseningsmarkering | @@ -178,7 +182,7 @@ inte tas bort medan uppgiften är pågående. ### Feature 6 – Drag-and-drop -**Status:** Planerad +**Status:** Pågående **Beroenden:** Feature 5 @@ -192,10 +196,14 @@ inte tas bort medan uppgiften är pågående. Drag-and-drop kommer efter det enklare statusflödet för att återanvända verifierade backendregler. -**Öppna frågor:** - -- optimistisk eller serverbekräftad uppdatering; -- exakt tilldelningsflöde vid flytt till Pågående. +Drag-and-drop använder en kontrollerad optimistisk flytt. Vid fel återställs +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. ## Fas 2 – Hantering av uppgifter @@ -436,6 +444,8 @@ 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 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. diff --git a/frontend/package.json b/frontend/package.json index 8ba4617..5914480 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,8 @@ "test": "vitest run" }, "dependencies": { + "@dnd-kit/dom": "0.5.0", + "@dnd-kit/react": "0.5.0", "react": "19.2.8", "react-dom": "19.2.8" }, diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 8a09af2..54696f0 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@dnd-kit/dom': + specifier: 0.5.0 + version: 0.5.0 + '@dnd-kit/react': + specifier: 0.5.0 + version: 0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: 19.2.8 version: 19.2.8 @@ -115,6 +121,27 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@dnd-kit/abstract@0.5.0': + resolution: {integrity: sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA==} + + '@dnd-kit/collision@0.5.0': + resolution: {integrity: sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ==} + + '@dnd-kit/dom@0.5.0': + resolution: {integrity: sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw==} + + '@dnd-kit/geometry@0.5.0': + resolution: {integrity: sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A==} + + '@dnd-kit/react@0.5.0': + resolution: {integrity: sha512-abQPLI8lmfVE+v/n+pqy5WFxrw6T2Yg0UQZsL78dp5DKci7dKTVDjvLWqvass+XTFtzJmsZEjk1NdqE6xG8Jiw==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@dnd-kit/state@0.5.0': + resolution: {integrity: sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w==} + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -145,6 +172,9 @@ packages: '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@preact/signals-core@1.14.4': + resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} + '@rolldown/binding-android-arm64@1.1.5': resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -961,6 +991,45 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dnd-kit/abstract@0.5.0': + dependencies: + '@dnd-kit/geometry': 0.5.0 + '@dnd-kit/state': 0.5.0 + tslib: 2.8.1 + + '@dnd-kit/collision@0.5.0': + dependencies: + '@dnd-kit/abstract': 0.5.0 + '@dnd-kit/geometry': 0.5.0 + tslib: 2.8.1 + + '@dnd-kit/dom@0.5.0': + dependencies: + '@dnd-kit/abstract': 0.5.0 + '@dnd-kit/collision': 0.5.0 + '@dnd-kit/geometry': 0.5.0 + '@dnd-kit/state': 0.5.0 + tslib: 2.8.1 + + '@dnd-kit/geometry@0.5.0': + dependencies: + '@dnd-kit/state': 0.5.0 + tslib: 2.8.1 + + '@dnd-kit/react@0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@dnd-kit/abstract': 0.5.0 + '@dnd-kit/dom': 0.5.0 + '@dnd-kit/state': 0.5.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + tslib: 2.8.1 + + '@dnd-kit/state@0.5.0': + dependencies: + '@preact/signals-core': 1.14.4 + tslib: 2.8.1 + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -990,6 +1059,8 @@ snapshots: '@oxc-project/types@0.139.0': {} + '@preact/signals-core@1.14.4': {} + '@rolldown/binding-android-arm64@1.1.5': optional: true @@ -1476,8 +1547,7 @@ snapshots: dependencies: punycode: 2.3.1 - tslib@2.8.1: - optional: true + tslib@2.8.1: {} typescript@7.0.2: optionalDependencies: diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index dfbfe15..201f685 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,7 +1,33 @@ -import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import type { ReactNode } from 'react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, expect, test, vi } from 'vitest' import App from './App' +const dragAndDrop = vi.hoisted(() => ({ + onTaskDrop: null as ((taskId: string, status: string) => void) | null, +})) + +vi.mock('./TaskDragAndDrop', () => ({ + TaskDragDropProvider: ({ + children, + onTaskDrop, + }: { + children: ReactNode + onTaskDrop: (taskId: string, status: string) => void + }) => { + dragAndDrop.onTaskDrop = onTaskDrop + return children + }, + useTaskDraggable: () => ({ + ref: () => {}, + isDragging: false, + }), + useTaskColumnDropTarget: () => ({ + ref: () => {}, + isDropTarget: false, + }), +})) + const users = [ { id: 'd56b54dd-31b0-4d71-8a10-82464be59a61', @@ -47,6 +73,7 @@ const tasks = [ beforeEach(() => { window.localStorage.clear() + dragAndDrop.onTaskDrop = null }) afterEach(() => { @@ -513,6 +540,174 @@ test('statusfel behåller tidigare status och ansvarig och visas på kortet', as expect(within(card).getByText('Ta uppgift')).toBeInTheDocument() }) +test('drag flyttar optimistiskt, låser kortet och använder hela serverresponsen', async () => { + const otherTask = { + ...tasks[0], + id: '00000000-0000-0000-0000-000000000010', + title: 'Putsa fönster', + } + const serverTask = { + ...tasks[0], + status: 'COMPLETED', + assignee: { id: users[1].id, name: users[1].name }, + } + let resolveStatus!: (response: Response) => void + const statusResponse = new Promise((resolve) => { + resolveStatus = 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(statusResponse) + render() + + await screen.findByText('Dammsuga') + act(() => dropTask(tasks[0].id, 'IN_PROGRESS')) + + const inProgress = screen.getByRole('region', { name: 'Pågående' }) + const optimisticCard = (await within(inProgress).findByText('Dammsuga')).closest('article')! + const waiting = screen.getByRole('region', { name: 'Väntande' }) + const otherCard = within(waiting).getByText('Putsa fönster').closest('article')! + + expect(within(optimisticCard).getByText('Urban')).toBeInTheDocument() + expect(optimisticCard).toHaveAttribute('aria-busy', 'true') + expect(optimisticCard).toHaveClass('task-card-pending') + expect(within(optimisticCard).getByRole('button', { name: 'Markera klar' })).toBeDisabled() + expect( + within(optimisticCard).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }), + ).toBeDisabled() + expect(within(otherCard).getByRole('button', { name: 'Påbörja' })).toBeEnabled() + expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/status`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + status: 'IN_PROGRESS', + activeUserId: users[0].id, + }), + }) + + act(() => dropTask(tasks[0].id, 'COMPLETED')) + expect(fetchMock).toHaveBeenCalledTimes(3) + + await act(async () => resolveStatus(jsonResponse(serverTask))) + + const completed = screen.getByRole('region', { name: 'Klart' }) + const confirmedCard = (await within(completed).findByText('Dammsuga')).closest('article')! + expect(within(confirmedCard).getByText('Anna')).toBeInTheDocument() + expect(confirmedCard).not.toHaveAttribute('aria-busy') +}) + +test('dragfel återställer hela uppgiften och visar lokalt fel', async () => { + let resolveStatus!: (response: Response) => void + const statusResponse = new Promise((resolve) => { + resolveStatus = resolve + }) + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(jsonResponse(users)) + fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]])) + fetchMock.mockReturnValueOnce(statusResponse) + render() + + await screen.findByText('Dammsuga') + act(() => dropTask(tasks[0].id, 'IN_PROGRESS')) + + const inProgress = screen.getByRole('region', { name: 'Pågående' }) + expect(await within(inProgress).findByText('Urban')).toBeInTheDocument() + + await act(async () => + resolveStatus( + jsonResponse( + { + code: 'TASK_REQUIRES_ASSIGNEE', + message: 'En pågående uppgift måste ha en ansvarig.', + }, + 409, + ), + ), + ) + + const waiting = screen.getByRole('region', { name: 'Väntande' }) + const restoredCard = (await within(waiting).findByText('Dammsuga')).closest('article')! + expect(within(restoredCard).getByText('Ta uppgift')).toBeInTheDocument() + expect(within(restoredCard).queryByText('Urban')).not.toBeInTheDocument() + expect(await within(restoredCard).findByRole('alert')).toHaveTextContent( + 'En pågående uppgift måste ha en ansvarig.', + ) + expect(restoredCard).not.toHaveAttribute('aria-busy') +}) + +test('drag till Pågående behåller en befintlig ansvarig optimistiskt', async () => { + const assignedTask = { + ...tasks[0], + assignee: { id: users[1].id, name: users[1].name }, + } + let resolveStatus!: (response: Response) => void + const statusResponse = new Promise((resolve) => { + resolveStatus = resolve + }) + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(jsonResponse(users)) + fetchMock.mockResolvedValueOnce(jsonResponse([assignedTask])) + fetchMock.mockReturnValueOnce(statusResponse) + render() + + await screen.findByText('Dammsuga') + act(() => dropTask(assignedTask.id, 'IN_PROGRESS')) + + const inProgress = screen.getByRole('region', { name: 'Pågående' }) + expect(await within(inProgress).findByText('Anna')).toBeInTheDocument() + expect(within(inProgress).queryByText('Urban')).not.toBeInTheDocument() + + await act(async () => + resolveStatus(jsonResponse({ ...assignedTask, status: 'IN_PROGRESS' })), + ) +}) + +test('drop i samma kolumn är no-op', async () => { + window.localStorage.setItem('hemhub.activeUserId', users[0].id) + const fetchMock = mockUsersAndTasks(users, [tasks[0]]) + render() + + await screen.findByText('Dammsuga') + act(() => dropTask(tasks[0].id, 'WAITING')) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(screen.getByText('Dammsuga').closest('article')).not.toHaveAttribute('aria-busy') +}) + +test('olika kort kan ha samtidiga optimistiska statusanrop', async () => { + const otherTask = { + ...tasks[0], + id: '00000000-0000-0000-0000-000000000010', + title: 'Putsa fönster', + } + const firstResponse = new Promise(() => {}) + const secondResponse = new Promise(() => {}) + 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(firstResponse) + fetchMock.mockReturnValueOnce(secondResponse) + render() + + await screen.findByText('Dammsuga') + act(() => { + dropTask(tasks[0].id, 'IN_PROGRESS') + dropTask(otherTask.id, 'COMPLETED') + }) + + expect(screen.getByText('Dammsuga').closest('article')).toHaveAttribute('aria-busy', 'true') + expect(screen.getByText('Putsa fönster').closest('article')).toHaveAttribute( + 'aria-busy', + 'true', + ) + expect(fetchMock).toHaveBeenCalledTimes(4) +}) + test('ansvarig kan bytas i Pågående och tas bort i Klart', async () => { const changedInProgress = { ...tasks[1], assignee: { id: users[0].id, name: users[0].name } } const unassignedCompleted = { ...tasks[2], assignee: null } @@ -690,6 +885,14 @@ function mockUsersAndTasks(userResponse: unknown, taskResponse: unknown) { return fetchMock } +function dropTask(taskId: string, status: string) { + if (!dragAndDrop.onTaskDrop) { + throw new Error('Drag-and-drop-providern är inte monterad') + } + + dragAndDrop.onTaskDrop(taskId, status) +} + function mockJsonResponse(body: unknown, status = 200) { return vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(body, status)) } diff --git a/frontend/src/TaskBoard.tsx b/frontend/src/TaskBoard.tsx index 841a3e0..c018762 100644 --- a/frontend/src/TaskBoard.tsx +++ b/frontend/src/TaskBoard.tsx @@ -1,6 +1,10 @@ import { FormEvent, MouseEvent, useEffect, useRef, useState } from 'react' - -type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED' +import { + TaskDragDropProvider, + TaskStatus, + useTaskColumnDropTarget, + useTaskDraggable, +} from './TaskDragAndDrop' type UserSummary = { id: string @@ -122,11 +126,27 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP } } - const updateStatus = async (task: Task, status: TaskStatus) => { + const updateStatus = async ( + task: Task, + status: TaskStatus, + presentation: 'server-confirmed' | 'optimistic', + ) => { if (!beginTaskRequest(task.id)) { return } + const previousTask = task + if (presentation === 'optimistic') { + replaceTask({ + ...task, + status, + assignee: + status === 'IN_PROGRESS' && !task.assignee + ? { id: activeUserId, name: activeUserName } + : task.assignee, + }) + } + try { const response = await fetch(`/api/tasks/${task.id}/status`, { method: 'PUT', @@ -139,6 +159,9 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP if (!response.ok) { const apiError = (await response.json().catch(() => ({}))) as ApiError + if (presentation === 'optimistic') { + replaceTask(previousTask) + } setTaskErrors((current) => ({ ...current, [task.id]: apiError.message ?? 'Det gick inte att ändra status. Försök igen.', @@ -149,6 +172,9 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP replaceTask((await response.json()) as Task) setEditingAssigneeTaskId(null) } catch { + if (presentation === 'optimistic') { + replaceTask(previousTask) + } setTaskErrors((current) => ({ ...current, [task.id]: 'Det gick inte att ändra status. Försök igen.', @@ -158,6 +184,16 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP } } + const dropTask = (taskId: string, status: TaskStatus) => { + const task = tasks.find((candidate) => candidate.id === taskId) + + if (!task || task.status === status) { + return + } + + void updateStatus(task, status, 'optimistic') + } + return (
@@ -190,48 +226,25 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP -
- {columns.map((column) => ( -
-

{column.title}

-
- {tasks - .filter((task) => task.status === column.status) - .map((task) => { - const pending = pendingTaskIds.has(task.id) - - return ( -
-
-

{task.title}

- {task.points} p -
- {task.description &&

{task.description}

} - setEditingAssigneeTaskId(task.id)} - onChange={(assigneeId) => void updateAssignee(task, assigneeId)} - /> - void updateStatus(task, status)} - /> - {taskErrors[task.id] && ( -

- {taskErrors[task.id]} -

- )} -
- ) - })} -
-
- ))} -
+ +
+ {columns.map((column) => ( + task.status === column.status)} + users={users} + editingAssigneeTaskId={editingAssigneeTaskId} + pendingTaskIds={pendingTaskIds} + taskErrors={taskErrors} + onEditAssignee={setEditingAssigneeTaskId} + onChangeAssignee={(task, assigneeId) => void updateAssignee(task, assigneeId)} + onChangeStatus={(task, status) => + void updateStatus(task, status, 'server-confirmed') + } + /> + ))} +
+
{showCreateTask && ( + taskErrors: Record + onEditAssignee: (taskId: string) => void + onChangeAssignee: (task: Task, assigneeId: string) => void + onChangeStatus: (task: Task, status: TaskStatus) => void +} + +function TaskColumn({ + column, + tasks, + users, + editingAssigneeTaskId, + pendingTaskIds, + taskErrors, + onEditAssignee, + onChangeAssignee, + onChangeStatus, +}: TaskColumnProps) { + const { ref, isDropTarget } = useTaskColumnDropTarget(column.status) + + return ( +
+

{column.title}

+
+ {tasks.map((task) => ( + onEditAssignee(task.id)} + onChangeAssignee={(assigneeId) => onChangeAssignee(task, assigneeId)} + onChangeStatus={(status) => onChangeStatus(task, status)} + /> + ))} +
+
+ ) +} + +type TaskCardProps = { + task: Task + users: UserSummary[] + editingAssignee: boolean + pending: boolean + error?: string + onEditAssignee: () => void + onChangeAssignee: (assigneeId: string) => void + onChangeStatus: (status: TaskStatus) => void +} + +function TaskCard({ + task, + users, + editingAssignee, + pending, + error, + onEditAssignee, + onChangeAssignee, + onChangeStatus, +}: TaskCardProps) { + const { ref, isDragging } = useTaskDraggable(task.id, pending) + + return ( +
+
+

{task.title}

+ {task.points} p +
+ {task.description &&

{task.description}

} + + + {error && ( +

+ {error} +

+ )} +
+ ) +} + type AssigneeControlProps = { task: Task users: UserSummary[] diff --git a/frontend/src/TaskDragAndDrop.test.ts b/frontend/src/TaskDragAndDrop.test.ts new file mode 100644 index 0000000..5c07b90 --- /dev/null +++ b/frontend/src/TaskDragAndDrop.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from 'vitest' +import { resolveTaskDrop } from './TaskDragAndDrop' + +test.each(['WAITING', 'IN_PROGRESS', 'COMPLETED'] as const)( + 'mappar målkolumnen %s till motsvarande status', + (status) => { + expect(resolveTaskDrop('task-1', status, false)).toEqual({ + taskId: 'task-1', + targetStatus: status, + }) + }, +) + +test('avbruten dragning och ogiltig målkolumn är no-op', () => { + expect(resolveTaskDrop('task-1', 'WAITING', true)).toBeNull() + expect(resolveTaskDrop('task-1', undefined, false)).toBeNull() + expect(resolveTaskDrop('task-1', 'UNKNOWN', false)).toBeNull() +}) diff --git a/frontend/src/TaskDragAndDrop.tsx b/frontend/src/TaskDragAndDrop.tsx new file mode 100644 index 0000000..5be6228 --- /dev/null +++ b/frontend/src/TaskDragAndDrop.tsx @@ -0,0 +1,90 @@ +import { ReactNode } from 'react' +import { DragDropProvider, useDraggable, useDroppable } from '@dnd-kit/react' +import { PointerActivationConstraints, PointerSensor } from '@dnd-kit/dom' + +export type TaskStatus = 'WAITING' | 'IN_PROGRESS' | 'COMPLETED' + +type TaskDragDropProviderProps = { + children: ReactNode + onTaskDrop: (taskId: string, targetStatus: TaskStatus) => void +} + +const taskStatuses = new Set(['WAITING', 'IN_PROGRESS', 'COMPLETED']) + +const pointerSensor = PointerSensor.configure({ + activationConstraints(event) { + if (event.pointerType === 'touch') { + return [new PointerActivationConstraints.Delay({ value: 250, tolerance: 8 })] + } + + return [new PointerActivationConstraints.Distance({ value: 6 })] + }, +}) + +export function resolveTaskDrop( + sourceId: string | number | undefined, + targetId: string | number | undefined, + canceled: boolean, +) { + if ( + canceled || + sourceId === undefined || + typeof targetId !== 'string' || + !taskStatuses.has(targetId as TaskStatus) + ) { + return null + } + + return { + taskId: String(sourceId), + targetStatus: targetId as TaskStatus, + } +} + +export function TaskDragDropProvider({ + children, + onTaskDrop, +}: TaskDragDropProviderProps) { + return ( + [ + ...defaults.filter((sensor) => sensor !== PointerSensor), + pointerSensor, + ]} + onDragEnd={(event) => { + const drop = resolveTaskDrop( + event.operation.source?.id, + event.operation.target?.id, + event.canceled, + ) + + if (!drop) { + return + } + + onTaskDrop(drop.taskId, drop.targetStatus) + }} + > + {children} + + ) +} + +export function useTaskDraggable(taskId: string, disabled: boolean) { + const { ref, isDragging } = useDraggable({ + id: taskId, + type: 'task', + disabled, + }) + + return { ref, isDragging } +} + +export function useTaskColumnDropTarget(status: TaskStatus) { + const { ref, isDropTarget } = useDroppable({ + id: status, + accept: 'task', + }) + + return { ref, isDropTarget } +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6be03df..4d9cf80 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -158,8 +158,15 @@ textarea { .board-column { min-height: 20rem; padding: 1rem; + border: 1px solid transparent; border-radius: 0.75rem; background: #e5e7eb; + transition: border-color 120ms ease, background-color 120ms ease; +} + +.board-column-drop-target { + border-color: #93c5fd; + background: #e0e7ff; } .board-column h2 { @@ -180,6 +187,14 @@ textarea { box-shadow: 0 0.125rem 0.4rem rgb(0 0 0 / 8%); } +.task-card-pending { + opacity: 0.65; +} + +.task-card-dragging { + cursor: grabbing; +} + .task-card h3, .task-card p { margin: 0; diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 98b4738..1f927e0 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1,5 +1,15 @@ import '@testing-library/jest-dom/vitest' +class ResizeObserverStub implements ResizeObserver { + observe() {} + + unobserve() {} + + disconnect() {} +} + +globalThis.ResizeObserver = ResizeObserverStub + const storedValues = new Map() Object.defineProperty(window, 'localStorage', {