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,8 +16,9 @@ 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 ,
2324 useFragment ,
@@ -46,11 +47,31 @@ type Props = {
4647 { __typename : "Measure" }
4748 > [ "tasks" ] [ "edges" ] ;
4849 connectionId : string ;
50+ canReorder ?: boolean ;
51+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52+ refetch ?: ( ...args : any [ ] ) => void ;
4953} ;
5054
51- export function TasksCard ( { tasks, connectionId } : Props ) {
55+ const updatePriorityMutation = graphql `
56+ mutation TasksCardUpdatePriorityMutation($input: UpdateTaskInput!) {
57+ updateTask(input: $input) {
58+ task {
59+ id
60+ priority
61+ }
62+ }
63+ }
64+ ` ;
65+
66+ export function TasksCard ( { tasks, connectionId, canReorder, refetch } : Props ) {
5267 const { __ } = useTranslate ( ) ;
5368 const hash = useLocation ( ) . hash . replace ( "#" , "" ) ;
69+ const [ , startTransition ] = useTransition ( ) ;
70+
71+ const { toast } = useToast ( ) ;
72+ const [ draggedId , setDraggedId ] = useState < string | null > ( null ) ;
73+ const [ previewOrder , setPreviewOrder ] = useState < string [ ] | null > ( null ) ;
74+ const [ updatePriority ] = useMutation < TaskFormDialogUpdateMutation > ( updatePriorityMutation ) ;
5475
5576 const hashes = [
5677 { hash : "" , label : __ ( "To do" ) , state : "TODO" } ,
@@ -65,6 +86,95 @@ export function TasksCard({ tasks, connectionId }: Props) {
6586 ] ) ;
6687
6788 const filteredTasks = tasksPerHash . get ( hash ) ?? [ ] ;
89+ const canDrag = ! ! canReorder && hash !== "all" ;
90+
91+ const handleDragOver = ( e : React . DragEvent , hoveredId : string ) => {
92+ e . preventDefault ( ) ;
93+ if ( draggedId === null || hoveredId === draggedId ) return ;
94+ const ids = filteredTasks . map ( ( { node } ) => node . id ) ;
95+ const fromIdx = ids . indexOf ( draggedId ) ;
96+ if ( fromIdx === - 1 ) return ;
97+ const rect = e . currentTarget . getBoundingClientRect ( ) ;
98+ const midY = rect . top + rect . height / 2 ;
99+ const insertBefore = e . clientY < midY ;
100+ const hoverIdx = ids . indexOf ( hoveredId ) ;
101+ let targetIdx = insertBefore ? hoverIdx : hoverIdx + 1 ;
102+ if ( targetIdx > fromIdx ) targetIdx -- ;
103+ if ( targetIdx === fromIdx ) {
104+ setPreviewOrder ( null ) ;
105+ return ;
106+ }
107+ const reordered = [ ...ids ] ;
108+ reordered . splice ( fromIdx , 1 ) ;
109+ reordered . splice ( targetIdx , 0 , draggedId ) ;
110+ setPreviewOrder ( reordered ) ;
111+ } ;
112+
113+ const handleDrop = async ( ) => {
114+ if ( draggedId === null || previewOrder === null ) {
115+ setDraggedId ( null ) ;
116+ return ;
117+ }
118+
119+ const newIdx = previewOrder . indexOf ( draggedId ) ;
120+
121+ // Find the priority of the task currently at the position we want
122+ const originalIds = filteredTasks . map ( ( { node } ) => node . id ) ;
123+ const originalIdx = originalIds . indexOf ( draggedId ) ;
124+ let targetOriginalIdx = newIdx ;
125+ if ( targetOriginalIdx >= originalIdx ) targetOriginalIdx ++ ;
126+ if ( targetOriginalIdx >= filteredTasks . length ) targetOriginalIdx = filteredTasks . length - 1 ;
127+ const targetPriority = filteredTasks [ targetOriginalIdx ] . node . priority ;
128+
129+ setDraggedId ( null ) ;
130+
131+ await promisifyMutation ( updatePriority ) ( {
132+ variables : {
133+ input : {
134+ taskId : draggedId ,
135+ priority : targetPriority ,
136+ } ,
137+ } ,
138+ onCompleted : ( _ , errors ) => {
139+ if ( errors ?. length ) {
140+ toast ( {
141+ title : __ ( "Error" ) ,
142+ description : formatError (
143+ __ ( "Failed to reorder task." ) ,
144+ errors ,
145+ ) ,
146+ variant : "destructive" ,
147+ } ) ;
148+ }
149+ if ( refetch ) {
150+ startTransition ( ( ) => {
151+ refetch (
152+ { } ,
153+ { fetchPolicy : errors ?. length ? "network-only" : "store-and-network" } ,
154+ ) ;
155+ } ) ;
156+ }
157+ } ,
158+ onError : ( ) => {
159+ toast ( {
160+ title : __ ( "Error" ) ,
161+ description : __ ( "Failed to reorder task." ) ,
162+ variant : "destructive" ,
163+ } ) ;
164+ } ,
165+ } ) ;
166+ } ;
167+
168+ const displayTasks = ( ( ) => {
169+ if ( ! previewOrder ) return filteredTasks ;
170+ const byId = new Map ( filteredTasks . map ( edge => [ edge . node . id , edge ] ) ) ;
171+ const currentIdSet = new Set ( byId . keys ( ) ) ;
172+ const previewIdSet = new Set ( previewOrder ) ;
173+ if ( currentIdSet . size !== previewIdSet . size || [ ...currentIdSet ] . some ( id => ! previewIdSet . has ( id ) ) ) {
174+ return filteredTasks ;
175+ }
176+ return previewOrder . map ( id => byId . get ( id ) ! ) ;
177+ } ) ( ) ;
68178
69179 usePageTitle ( __ ( "Tasks" ) ) ;
70180
@@ -108,23 +218,42 @@ export function TasksCard({ tasks, connectionId }: Props) {
108218 </ Fragment >
109219 ) )
110220 // Todo and Done tab simply list todos
111- : filteredTasks ? .map ( ( { node : task } ) => (
221+ : displayTasks . map ( ( { node : task } ) => (
112222 < TaskRow
113223 key = { task . id }
114224 fKey = { task }
115225 connectionId = { connectionId }
226+ canDrag = { canDrag }
227+ isDragging = { draggedId === task . id }
228+ isGhost = { previewOrder !== null && draggedId === task . id }
229+ onDragStart = { ( ) => setDraggedId ( task . id ) }
230+ onDragOver = { e => handleDragOver ( e , task . id ) }
231+ onDrop = { ( ) => void handleDrop ( ) }
232+ onDragEnd = { ( ) => setDraggedId ( null ) }
116233 />
117234 ) ) }
118235 </ div >
119236 </ Card >
120237 ) }
238+ { canDrag && filteredTasks . length > 1 && (
239+ < p className = "text-sm text-txt-tertiary" >
240+ { __ ( "Drag and drop to reorder tasks" ) }
241+ </ p >
242+ ) }
121243 </ div >
122244 ) ;
123245}
124246
125247type TaskRowProps = {
126248 fKey : TasksCard_TaskRowFragment$key | TaskFormDialogFragment$key ;
127249 connectionId : string ;
250+ canDrag ?: boolean ;
251+ isDragging ?: boolean ;
252+ isGhost ?: boolean ;
253+ onDragStart ?: ( ) => void ;
254+ onDragOver ?: ( e : React . DragEvent ) => void ;
255+ onDrop ?: ( ) => void ;
256+ onDragEnd ?: ( ) => void ;
128257} ;
129258
130259const fragment = graphql `
@@ -175,6 +304,8 @@ function TaskRow(props: TaskRowProps) {
175304 ) ;
176305 const [ updateTask , isUpdating ] = useMutation < TaskFormDialogUpdateMutation > ( taskUpdateMutation ) ;
177306
307+ const [ isMouseDown , setIsMouseDown ] = useState ( false ) ;
308+
178309 const onToggle = async ( ) => {
179310 await promisifyMutation ( updateTask ) ( {
180311 variables : {
@@ -211,13 +342,37 @@ function TaskRow(props: TaskRowProps) {
211342 ) ;
212343 } ;
213344
345+ const canDrag = props . canDrag ;
346+ const isDragging = props . isDragging ;
347+ const isGhost = props . isGhost ;
348+
349+ const className = [
350+ "transition-all duration-150" ,
351+ canDrag && isDragging && ! isGhost && "opacity-40 cursor-grabbing" ,
352+ canDrag && ! isDragging && ! isMouseDown && "cursor-grab" ,
353+ canDrag && ! isDragging && isMouseDown && "cursor-grabbing" ,
354+ isGhost && "opacity-50 bg-primary-50" ,
355+ ]
356+ . filter ( Boolean )
357+ . join ( " " ) ;
358+
214359 return (
215360 < >
216361 < TaskFormDialog
217362 task = { props . fKey as TaskFormDialogFragment$key }
218363 ref = { dialogRef }
219364 />
220- < div className = "flex items-center justify-between py-3 px-6" >
365+ < div
366+ className = { `flex items-center justify-between py-3 px-6 ${ className } ` }
367+ draggable = { canDrag }
368+ onDragStart = { canDrag ? props . onDragStart : undefined }
369+ onDragOver = { canDrag ? props . onDragOver : undefined }
370+ onDrop = { canDrag ? props . onDrop : undefined }
371+ onDragEnd = { canDrag ? props . onDragEnd : undefined }
372+ onMouseDown = { canDrag ? ( ) => setIsMouseDown ( true ) : undefined }
373+ onMouseUp = { canDrag ? ( ) => setIsMouseDown ( false ) : undefined }
374+ onMouseLeave = { canDrag ? ( ) => setIsMouseDown ( false ) : undefined }
375+ >
221376 < div className = "flex gap-2 items-start" >
222377 < div className = "flex items-center gap-2 pt-[2px]" >
223378 < PriorityLevel level = { 1 } />
0 commit comments