1- import { formatDate , formatDuration , promisifyMutation } from "@probo/helpers" ;
1+ import { formatDate , formatDuration , formatError , promisifyMutation } from "@probo/helpers" ;
22import { usePageTitle } from "@probo/hooks" ;
33import { useTranslate } from "@probo/i18n" ;
44import {
@@ -16,10 +16,12 @@ import {
1616 TaskStateIcon ,
1717 useConfirm ,
1818 useDialogRef ,
19+ useToast ,
1920} from "@probo/ui" ;
20- import { Fragment } from "react" ;
21+ import { Fragment , useState , useTransition } from "react" ;
2122import {
2223 graphql ,
24+ readInlineData ,
2325 useFragment ,
2426 useMutation ,
2527 useRelayEnvironment ,
@@ -29,6 +31,7 @@ import { Link, useLocation, useParams } from "react-router";
2931import type { MeasureTasksTabQuery$data } from "#/__generated__/core/MeasureTasksTabQuery.graphql" ;
3032import type { TaskFormDialogFragment$key } from "#/__generated__/core/TaskFormDialogFragment.graphql" ;
3133import type { TaskFormDialogUpdateMutation } from "#/__generated__/core/TaskFormDialogUpdateMutation.graphql" ;
34+ import type { TasksCard_task$key } from "#/__generated__/core/TasksCard_task.graphql" ;
3235import type { TasksCard_TaskRowFragment$key } from "#/__generated__/core/TasksCard_TaskRowFragment.graphql" ;
3336import type { TasksCardDeleteMutation } from "#/__generated__/core/TasksCardDeleteMutation.graphql" ;
3437import type { TasksPageFragment$data } from "#/__generated__/core/TasksPageFragment.graphql" ;
@@ -46,11 +49,42 @@ type Props = {
4649 { __typename : "Measure" }
4750 > [ "tasks" ] [ "edges" ] ;
4851 connectionId : string ;
52+ canReorder ?: boolean ;
53+ refetch ?: ( vars : Record < string , never > , options ?: { fetchPolicy ?: "store-and-network" | "network-only" } ) => void ;
4954} ;
5055
51- export function TasksCard ( { tasks, connectionId } : Props ) {
56+ const taskInlineFragment = graphql `
57+ fragment TasksCard_task on Task @inline {
58+ id
59+ state
60+ priority
61+ }
62+ ` ;
63+
64+ function readTask ( key : TasksCard_task$key ) {
65+ return readInlineData ( taskInlineFragment , key ) ;
66+ }
67+
68+ const updatePriorityMutation = graphql `
69+ mutation TasksCardUpdatePriorityMutation($input: UpdateTaskInput!) {
70+ updateTask(input: $input) {
71+ task {
72+ id
73+ priority
74+ }
75+ }
76+ }
77+ ` ;
78+
79+ export function TasksCard ( { tasks, connectionId, canReorder, refetch } : Props ) {
5280 const { __ } = useTranslate ( ) ;
5381 const hash = useLocation ( ) . hash . replace ( "#" , "" ) ;
82+ const [ , startTransition ] = useTransition ( ) ;
83+
84+ const { toast } = useToast ( ) ;
85+ const [ draggedId , setDraggedId ] = useState < string | null > ( null ) ;
86+ const [ previewOrder , setPreviewOrder ] = useState < string [ ] | null > ( null ) ;
87+ const [ updatePriority ] = useMutation < TaskFormDialogUpdateMutation > ( updatePriorityMutation ) ;
5488
5589 const hashes = [
5690 { hash : "" , label : __ ( "To do" ) , state : "TODO" } ,
@@ -59,12 +93,99 @@ export function TasksCard({ tasks, connectionId }: Props) {
5993 ] as const ;
6094
6195 const tasksPerHash = new Map ( [
62- [ "" , tasks ?. filter ( ( { node } ) => node . state === "TODO" ) ] ,
63- [ "done" , tasks ?. filter ( ( { node } ) => node . state === "DONE" ) ] ,
96+ [ "" , tasks ?. filter ( ( { node } ) => readTask ( node ) . state === "TODO" ) ] ,
97+ [ "done" , tasks ?. filter ( ( { node } ) => readTask ( node ) . state === "DONE" ) ] ,
6498 [ "all" , tasks ] ,
6599 ] ) ;
66100
67101 const filteredTasks = tasksPerHash . get ( hash ) ?? [ ] ;
102+ const canDrag = ! ! canReorder && hash !== "all" ;
103+
104+ const handleDragOver = ( e : React . DragEvent , hoveredId : string ) => {
105+ e . preventDefault ( ) ;
106+ if ( draggedId === null || hoveredId === draggedId ) return ;
107+ const ids = filteredTasks . map ( ( { node } ) => readTask ( node ) . id ) ;
108+ const fromIdx = ids . indexOf ( draggedId ) ;
109+ if ( fromIdx === - 1 ) return ;
110+ const rect = e . currentTarget . getBoundingClientRect ( ) ;
111+ const midY = rect . top + rect . height / 2 ;
112+ const insertBefore = e . clientY < midY ;
113+ const hoverIdx = ids . indexOf ( hoveredId ) ;
114+ let targetIdx = insertBefore ? hoverIdx : hoverIdx + 1 ;
115+ if ( targetIdx > fromIdx ) targetIdx -- ;
116+ if ( targetIdx === fromIdx ) {
117+ setPreviewOrder ( null ) ;
118+ return ;
119+ }
120+ const reordered = [ ...ids ] ;
121+ reordered . splice ( fromIdx , 1 ) ;
122+ reordered . splice ( targetIdx , 0 , draggedId ) ;
123+ setPreviewOrder ( reordered ) ;
124+ } ;
125+
126+ const handleDrop = ( ) => {
127+ if ( draggedId === null || previewOrder === null ) {
128+ setDraggedId ( null ) ;
129+ return ;
130+ }
131+
132+ const newIdx = previewOrder . indexOf ( draggedId ) ;
133+ const originalIds = filteredTasks . map ( ( { node } ) => readTask ( node ) . id ) ;
134+ const originalIdx = originalIds . indexOf ( draggedId ) ;
135+ let targetOriginalIdx = newIdx ;
136+ if ( targetOriginalIdx >= originalIdx ) targetOriginalIdx ++ ;
137+ if ( targetOriginalIdx >= filteredTasks . length ) targetOriginalIdx = filteredTasks . length - 1 ;
138+ const targetPriority = readTask ( filteredTasks [ targetOriginalIdx ] . node ) . priority ;
139+
140+ setDraggedId ( null ) ;
141+
142+ updatePriority ( {
143+ variables : {
144+ input : {
145+ taskId : draggedId ,
146+ priority : targetPriority ,
147+ } ,
148+ } ,
149+ onCompleted : ( _ , errors ) => {
150+ if ( errors ?. length ) {
151+ toast ( {
152+ title : __ ( "Error" ) ,
153+ description : formatError (
154+ __ ( "Failed to reorder task." ) ,
155+ errors ,
156+ ) ,
157+ variant : "destructive" ,
158+ } ) ;
159+ }
160+ if ( refetch ) {
161+ startTransition ( ( ) => {
162+ refetch (
163+ { } ,
164+ { fetchPolicy : errors ?. length ? "network-only" : "store-and-network" } ,
165+ ) ;
166+ } ) ;
167+ }
168+ } ,
169+ onError : ( ) => {
170+ toast ( {
171+ title : __ ( "Error" ) ,
172+ description : __ ( "Failed to reorder task." ) ,
173+ variant : "destructive" ,
174+ } ) ;
175+ } ,
176+ } ) ;
177+ } ;
178+
179+ const displayTasks = ( ( ) => {
180+ if ( ! previewOrder ) return filteredTasks ;
181+ const byId = new Map ( filteredTasks . map ( edge => [ readTask ( edge . node ) . id , edge ] ) ) ;
182+ const currentIdSet = new Set ( byId . keys ( ) ) ;
183+ const previewIdSet = new Set ( previewOrder ) ;
184+ if ( currentIdSet . size !== previewIdSet . size || [ ...currentIdSet ] . some ( id => ! previewIdSet . has ( id ) ) ) {
185+ return filteredTasks ;
186+ }
187+ return previewOrder . map ( id => byId . get ( id ) ! ) ;
188+ } ) ( ) ;
68189
69190 usePageTitle ( __ ( "Tasks" ) ) ;
70191
@@ -98,33 +219,55 @@ export function TasksCard({ tasks, connectionId }: Props) {
98219 < TaskStateIcon state = { h . state ! } />
99220 { h . label }
100221 </ h2 >
101- { tasksPerHash . get ( h . hash ) ?. map ( ( { node : task } ) => (
222+ { tasksPerHash . get ( h . hash ) ?. map ( ( { node } ) => (
102223 < TaskRow
103- key = { task . id }
104- fKey = { task }
224+ key = { readTask ( node ) . id }
225+ fKey = { node }
105226 connectionId = { connectionId }
106227 />
107228 ) ) }
108229 </ Fragment >
109230 ) )
110231 // Todo and Done tab simply list todos
111- : filteredTasks ?. map ( ( { node : task } ) => (
112- < TaskRow
113- key = { task . id }
114- fKey = { task }
115- connectionId = { connectionId }
116- />
117- ) ) }
232+ : displayTasks . map ( ( { node } ) => {
233+ const task = readTask ( node ) ;
234+ return (
235+ < TaskRow
236+ key = { task . id }
237+ fKey = { node }
238+ connectionId = { connectionId }
239+ canDrag = { canDrag }
240+ isDragging = { draggedId === task . id }
241+ isGhost = { previewOrder !== null && draggedId === task . id }
242+ onDragStart = { ( ) => setDraggedId ( task . id ) }
243+ onDragOver = { e => handleDragOver ( e , task . id ) }
244+ onDrop = { handleDrop }
245+ onDragEnd = { ( ) => setDraggedId ( null ) }
246+ />
247+ ) ;
248+ } ) }
118249 </ div >
119250 </ Card >
120251 ) }
252+ { canDrag && filteredTasks . length > 1 && (
253+ < p className = "text-sm text-txt-tertiary" >
254+ { __ ( "Drag and drop to reorder tasks" ) }
255+ </ p >
256+ ) }
121257 </ div >
122258 ) ;
123259}
124260
125261type TaskRowProps = {
126262 fKey : TasksCard_TaskRowFragment$key | TaskFormDialogFragment$key ;
127263 connectionId : string ;
264+ canDrag ?: boolean ;
265+ isDragging ?: boolean ;
266+ isGhost ?: boolean ;
267+ onDragStart ?: ( ) => void ;
268+ onDragOver ?: ( e : React . DragEvent ) => void ;
269+ onDrop ?: ( ) => void ;
270+ onDragEnd ?: ( ) => void ;
128271} ;
129272
130273const fragment = graphql `
@@ -175,6 +318,8 @@ function TaskRow(props: TaskRowProps) {
175318 ) ;
176319 const [ updateTask , isUpdating ] = useMutation < TaskFormDialogUpdateMutation > ( taskUpdateMutation ) ;
177320
321+ const [ isMouseDown , setIsMouseDown ] = useState ( false ) ;
322+
178323 const onToggle = async ( ) => {
179324 await promisifyMutation ( updateTask ) ( {
180325 variables : {
@@ -211,13 +356,37 @@ function TaskRow(props: TaskRowProps) {
211356 ) ;
212357 } ;
213358
359+ const canDrag = props . canDrag ;
360+ const isDragging = props . isDragging ;
361+ const isGhost = props . isGhost ;
362+
363+ const className = [
364+ "transition-all duration-150" ,
365+ canDrag && isDragging && ! isGhost && "opacity-40 cursor-grabbing" ,
366+ canDrag && ! isDragging && ! isMouseDown && "cursor-grab" ,
367+ canDrag && ! isDragging && isMouseDown && "cursor-grabbing" ,
368+ isGhost && "opacity-50 bg-primary-50" ,
369+ ]
370+ . filter ( Boolean )
371+ . join ( " " ) ;
372+
214373 return (
215374 < >
216375 < TaskFormDialog
217376 task = { props . fKey as TaskFormDialogFragment$key }
218377 ref = { dialogRef }
219378 />
220- < div className = "flex items-center justify-between py-3 px-6" >
379+ < div
380+ className = { `flex items-center justify-between py-3 px-6 ${ className } ` }
381+ draggable = { canDrag }
382+ onDragStart = { canDrag ? props . onDragStart : undefined }
383+ onDragOver = { canDrag ? props . onDragOver : undefined }
384+ onDrop = { canDrag ? props . onDrop : undefined }
385+ onDragEnd = { canDrag ? props . onDragEnd : undefined }
386+ onMouseDown = { canDrag ? ( ) => setIsMouseDown ( true ) : undefined }
387+ onMouseUp = { canDrag ? ( ) => setIsMouseDown ( false ) : undefined }
388+ onMouseLeave = { canDrag ? ( ) => setIsMouseDown ( false ) : undefined }
389+ >
221390 < div className = "flex gap-2 items-start" >
222391 < div className = "flex items-center gap-2 pt-[2px]" >
223392 < PriorityLevel level = { 1 } />
0 commit comments