Skip to content

Commit 964f45f

Browse files
committed
Add task drag-and-drop reordering
Order tasks by priority (ASC) by default. Enable drag-and-drop on the TODO and DONE tabs using native HTML5 drag events, following the compliance external URLs pattern. The "All" tab remains read-only since priority is scoped per state. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
1 parent dffd52a commit 964f45f

File tree

3 files changed

+174
-9
lines changed

3 files changed

+174
-9
lines changed

apps/console/src/components/tasks/TasksCard.tsx

Lines changed: 160 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { formatDate, formatDuration, promisifyMutation } from "@probo/helpers";
1+
import { formatDate, formatDuration, formatError, promisifyMutation } from "@probo/helpers";
22
import { usePageTitle } from "@probo/hooks";
33
import { useTranslate } from "@probo/i18n";
44
import {
@@ -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";
2122
import {
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

125247
type 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

130259
const 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} />

apps/console/src/pages/organizations/measures/tabs/MeasureTasksTab.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const tasksQuery = graphql`
1515
... on Measure {
1616
id
1717
canCreateTask: permission(action: "core:task:create")
18-
tasks(first: 100)
18+
tasks(first: 100, orderBy: { field: PRIORITY, direction: ASC })
1919
@connection(key: "Measure__tasks")
2020
@required(action: THROW) {
2121
__id
@@ -24,6 +24,8 @@ const tasksQuery = graphql`
2424
id
2525
# eslint-disable-next-line relay/unused-fields
2626
state
27+
# eslint-disable-next-line relay/unused-fields
28+
priority
2729
...TaskFormDialogFragment
2830
...TasksCard_TaskRowFragment
2931
}

apps/console/src/pages/organizations/tasks/TasksPage.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@ const tasksFragment = graphql`
2020
@refetchable(queryName: "TasksPageFragment_query")
2121
@argumentDefinitions(
2222
first: { type: "Int", defaultValue: 500 }
23-
order: { type: "TaskOrder", defaultValue: null }
23+
order: { type: "TaskOrder", defaultValue: { field: PRIORITY, direction: ASC } }
2424
after: { type: "CursorKey", defaultValue: null }
2525
before: { type: "CursorKey", defaultValue: null }
2626
last: { type: "Int", defaultValue: null }
2727
) {
2828
canCreateTask: permission(action: "core:task:create")
29+
canUpdateTask: permission(action: "core:task:update")
2930
tasks(
3031
first: $first
3132
after: $after
@@ -41,6 +42,8 @@ const tasksFragment = graphql`
4142
id
4243
# eslint-disable-next-line relay/unused-fields
4344
state
45+
# eslint-disable-next-line relay/unused-fields
46+
priority
4447
...TaskFormDialogFragment
4548
...TasksCard_TaskRowFragment
4649
}
@@ -56,7 +59,7 @@ interface Props {
5659
export default function TasksPage({ queryRef }: Props) {
5760
const { __ } = useTranslate();
5861
const query = usePreloadedQuery(tasksQuery, queryRef);
59-
const [data] = useRefetchableFragment<TasksPageFragment_query, TasksPageFragment$key>(
62+
const [data, refetch] = useRefetchableFragment<TasksPageFragment_query, TasksPageFragment$key>(
6063
tasksFragment,
6164
query.organization as TasksPageFragment$key,
6265
);
@@ -77,7 +80,12 @@ export default function TasksPage({ queryRef }: Props) {
7780
</TaskFormDialog>
7881
)}
7982
</PageHeader>
80-
<TasksCard connectionId={connectionId} tasks={data.tasks.edges} />
83+
<TasksCard
84+
connectionId={connectionId}
85+
tasks={data.tasks.edges}
86+
canReorder={data.canUpdateTask}
87+
refetch={refetch}
88+
/>
8189
</div>
8290
);
8391
}

0 commit comments

Comments
 (0)