Skip to content

Commit ad0370b

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 ad0370b

File tree

4 files changed

+197
-30
lines changed

4 files changed

+197
-30
lines changed

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,6 @@ const taskFragment = graphql`
3333
id
3434
description
3535
name
36-
# eslint-disable-next-line relay/unused-fields
37-
state
3836
timeEstimate
3937
deadline
4038
assignedTo {

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

Lines changed: 185 additions & 16 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,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";
2122
import {
2223
graphql,
24+
readInlineData,
2325
useFragment,
2426
useMutation,
2527
useRelayEnvironment,
@@ -29,6 +31,7 @@ import { Link, useLocation, useParams } from "react-router";
2931
import type { MeasureTasksTabQuery$data } from "#/__generated__/core/MeasureTasksTabQuery.graphql";
3032
import type { TaskFormDialogFragment$key } from "#/__generated__/core/TaskFormDialogFragment.graphql";
3133
import type { TaskFormDialogUpdateMutation } from "#/__generated__/core/TaskFormDialogUpdateMutation.graphql";
34+
import type { TasksCard_task$key } from "#/__generated__/core/TasksCard_task.graphql";
3235
import type { TasksCard_TaskRowFragment$key } from "#/__generated__/core/TasksCard_TaskRowFragment.graphql";
3336
import type { TasksCardDeleteMutation } from "#/__generated__/core/TasksCardDeleteMutation.graphql";
3437
import 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

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

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

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,13 @@ 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
2222
edges @required(action: THROW) {
2323
node {
24-
id
25-
# eslint-disable-next-line relay/unused-fields
26-
state
24+
...TasksCard_task
2725
...TaskFormDialogFragment
2826
...TasksCard_TaskRowFragment
2927
}

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

Lines changed: 10 additions & 8 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
@@ -35,12 +36,8 @@ const tasksFragment = graphql`
3536
) @connection(key: "TasksPageFragment_tasks") @required(action: THROW) {
3637
__id
3738
edges @required(action: THROW) {
38-
# eslint-disable-next-line relay/unused-fields
3939
node {
40-
# eslint-disable-next-line relay/unused-fields
41-
id
42-
# eslint-disable-next-line relay/unused-fields
43-
state
40+
...TasksCard_task
4441
...TaskFormDialogFragment
4542
...TasksCard_TaskRowFragment
4643
}
@@ -56,7 +53,7 @@ interface Props {
5653
export default function TasksPage({ queryRef }: Props) {
5754
const { __ } = useTranslate();
5855
const query = usePreloadedQuery(tasksQuery, queryRef);
59-
const [data] = useRefetchableFragment<TasksPageFragment_query, TasksPageFragment$key>(
56+
const [data, refetch] = useRefetchableFragment<TasksPageFragment_query, TasksPageFragment$key>(
6057
tasksFragment,
6158
query.organization as TasksPageFragment$key,
6259
);
@@ -77,7 +74,12 @@ export default function TasksPage({ queryRef }: Props) {
7774
</TaskFormDialog>
7875
)}
7976
</PageHeader>
80-
<TasksCard connectionId={connectionId} tasks={data.tasks.edges} />
77+
<TasksCard
78+
connectionId={connectionId}
79+
tasks={data.tasks.edges}
80+
canReorder={data.canUpdateTask}
81+
refetch={refetch}
82+
/>
8183
</div>
8284
);
8385
}

0 commit comments

Comments
 (0)