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>
This commit is contained in:
Sacha Al Himdani
2026-03-26 19:05:18 +01:00
parent 257cbcf826
commit 4a2d308da0
6 changed files with 296 additions and 108 deletions

View File

@@ -33,8 +33,6 @@ const taskFragment = graphql`
id
description
name
# eslint-disable-next-line relay/unused-fields
state
timeEstimate
deadline
assignedTo {
@@ -52,9 +50,11 @@ const taskCreateMutation = graphql`
$connections: [ID!]!
) {
createTask(input: $input) {
taskEdge @prependEdge(connections: $connections) {
taskEdge @appendEdge(connections: $connections) {
node {
...TaskFormDialogFragment
...TasksCard_task
...TasksCard_TaskRowFragment
}
}
}
@@ -66,6 +66,8 @@ export const taskUpdateMutation = graphql`
updateTask(input: $input) {
task {
...TaskFormDialogFragment
...TasksCard_task
...TasksCard_TaskRowFragment
}
}
}

View File

@@ -1,5 +1,4 @@
import { formatDate, formatDuration, promisifyMutation } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { formatDate, formatDuration, formatError, promisifyMutation } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
@@ -16,22 +15,29 @@ import {
TaskStateIcon,
useConfirm,
useDialogRef,
useToast,
} from "@probo/ui";
import { Fragment } from "react";
import { Fragment, type ReactNode, useState, useTransition } from "react";
import {
graphql,
readInlineData,
useFragment,
useMutation,
useRefetchableFragment,
useRelayEnvironment,
} from "react-relay";
import { Link, useLocation, useParams } from "react-router";
import type { MeasureTasksTabQuery$data } from "#/__generated__/core/MeasureTasksTabQuery.graphql";
import type { TaskFormDialogFragment$key } from "#/__generated__/core/TaskFormDialogFragment.graphql";
import type { TaskFormDialogUpdateMutation } from "#/__generated__/core/TaskFormDialogUpdateMutation.graphql";
import type { TasksCard_task$key } from "#/__generated__/core/TasksCard_task.graphql";
import type { TasksCard_TaskRowFragment$key } from "#/__generated__/core/TasksCard_TaskRowFragment.graphql";
import type { TasksCardDeleteMutation } from "#/__generated__/core/TasksCardDeleteMutation.graphql";
import type { TasksPageFragment$data } from "#/__generated__/core/TasksPageFragment.graphql";
import type {
TasksCardOrganizationFragment$data,
TasksCardOrganizationFragment$key,
} from "#/__generated__/core/TasksCardOrganizationFragment.graphql";
import type { TasksCardOrganizationQuery } from "#/__generated__/core/TasksCardOrganizationQuery.graphql";
import TaskFormDialog, {
taskUpdateMutation,
} from "#/components/tasks/TaskFormDialog";
@@ -39,18 +45,107 @@ import { updateStoreCounter } from "#/hooks/useMutationWithIncrement";
import { useOrganizationId } from "#/hooks/useOrganizationId";
type Props = {
tasks:
| TasksPageFragment$data["tasks"]["edges"]
| Extract<
MeasureTasksTabQuery$data["node"],
{ __typename: "Measure" }
>["tasks"]["edges"];
tasks: TasksCardOrganizationFragment$data["tasks"]["edges"];
connectionId: string;
canReorder?: boolean;
refetch?: (vars: Record<string, never>, options?: { fetchPolicy?: "store-and-network" | "network-only" }) => void;
};
export function TasksCard({ tasks, connectionId }: Props) {
const taskInlineFragment = graphql`
fragment TasksCard_task on Task @inline {
id
state
priority
}
`;
function readTask(key: TasksCard_task$key) {
return readInlineData(taskInlineFragment, key);
}
const organizationTasksFragment = graphql`
fragment TasksCardOrganizationFragment on Organization
@refetchable(queryName: "TasksCardOrganizationQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 500 }
order: { type: "TaskOrder", defaultValue: { field: PRIORITY, direction: ASC } }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
canCreateTask: permission(action: "core:task:create")
canUpdateTask: permission(action: "core:task:update")
tasks(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "TasksCardOrganization_tasks") @required(action: THROW) {
__id
edges @required(action: THROW) {
node {
...TasksCard_task
...TaskFormDialogFragment
...TasksCard_TaskRowFragment
}
}
}
}
`;
type OrganizationTasksCardProps = {
organizationRef: TasksCardOrganizationFragment$key;
header?: (params: { connectionId: string; canCreateTask: boolean }) => ReactNode;
};
export function OrganizationTasksCard({ organizationRef, header }: OrganizationTasksCardProps) {
const [data, refetch] = useRefetchableFragment<
TasksCardOrganizationQuery,
TasksCardOrganizationFragment$key
>(organizationTasksFragment, organizationRef);
return (
<>
{header?.({ connectionId: data.tasks.__id, canCreateTask: data.canCreateTask })}
<TasksCard
tasks={data.tasks.edges}
connectionId={data.tasks.__id}
canReorder={data.canUpdateTask}
refetch={refetch}
/>
</>
);
}
const updatePriorityMutation = graphql`
mutation TasksCardUpdatePriorityMutation($input: UpdateTaskInput!) {
updateTask(input: $input) {
task {
id
priority
}
}
}
`;
export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) {
const { __ } = useTranslate();
const hash = useLocation().hash.replace("#", "");
const [, startTransition] = useTransition();
const { toast } = useToast();
const [draggedId, setDraggedId] = useState<string | null>(null);
const [previewOrder, setPreviewOrder] = useState<string[] | null>(null);
const [updatePriority] = useMutation<TaskFormDialogUpdateMutation>(updatePriorityMutation);
const handleStateChange = () => {
if (refetch) {
startTransition(() => {
refetch({}, { fetchPolicy: "store-and-network" });
});
}
};
const hashes = [
{ hash: "", label: __("To do"), state: "TODO" },
@@ -59,14 +154,99 @@ export function TasksCard({ tasks, connectionId }: Props) {
] as const;
const tasksPerHash = new Map([
["", tasks?.filter(({ node }) => node.state === "TODO")],
["done", tasks?.filter(({ node }) => node.state === "DONE")],
["", tasks?.filter(({ node }) => readTask(node).state === "TODO")],
["done", tasks?.filter(({ node }) => readTask(node).state === "DONE")],
["all", tasks],
]);
const filteredTasks = tasksPerHash.get(hash) ?? [];
const canDrag = !!canReorder && hash !== "all";
usePageTitle(__("Tasks"));
const handleDragOver = (e: React.DragEvent, hoveredId: string) => {
e.preventDefault();
if (draggedId === null || hoveredId === draggedId) return;
const ids = filteredTasks.map(({ node }) => readTask(node).id);
const fromIdx = ids.indexOf(draggedId);
if (fromIdx === -1) return;
const rect = e.currentTarget.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const insertBefore = e.clientY < midY;
const hoverIdx = ids.indexOf(hoveredId);
let targetIdx = insertBefore ? hoverIdx : hoverIdx + 1;
if (targetIdx > fromIdx) targetIdx--;
if (targetIdx === fromIdx) {
setPreviewOrder(null);
return;
}
const reordered = [...ids];
reordered.splice(fromIdx, 1);
reordered.splice(targetIdx, 0, draggedId);
setPreviewOrder(reordered);
};
const handleDrop = () => {
if (draggedId === null || previewOrder === null) {
setDraggedId(null);
return;
}
const newIdx = previewOrder.indexOf(draggedId);
const originalIds = filteredTasks.map(({ node }) => readTask(node).id);
const originalIdx = originalIds.indexOf(draggedId);
let targetOriginalIdx = newIdx;
if (targetOriginalIdx >= originalIdx) targetOriginalIdx++;
if (targetOriginalIdx >= filteredTasks.length) targetOriginalIdx = filteredTasks.length - 1;
const targetPriority = readTask(filteredTasks[targetOriginalIdx].node).priority;
setDraggedId(null);
updatePriority({
variables: {
input: {
taskId: draggedId,
priority: targetPriority,
},
},
onCompleted: (_, errors) => {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(
__("Failed to reorder task."),
errors,
),
variant: "error",
});
}
if (refetch) {
startTransition(() => {
refetch(
{},
{ fetchPolicy: errors?.length ? "network-only" : "store-and-network" },
);
});
}
},
onError: () => {
toast({
title: __("Error"),
description: __("Failed to reorder task."),
variant: "error",
});
},
});
};
const displayTasks = (() => {
if (!previewOrder) return filteredTasks;
const byId = new Map(filteredTasks.map(edge => [readTask(edge.node).id, edge]));
const currentIdSet = new Set(byId.keys());
const previewIdSet = new Set(previewOrder);
if (currentIdSet.size !== previewIdSet.size || [...currentIdSet].some(id => !previewIdSet.has(id))) {
return filteredTasks;
}
return previewOrder.map(id => byId.get(id)!);
})();
return (
<div className="space-y-6">
@@ -98,26 +278,43 @@ export function TasksCard({ tasks, connectionId }: Props) {
<TaskStateIcon state={h.state!} />
{h.label}
</h2>
{tasksPerHash.get(h.hash)?.map(({ node: task }) => (
{tasksPerHash.get(h.hash)?.map(({ node }) => (
<TaskRow
key={task.id}
fKey={task}
key={readTask(node).id}
fKey={node}
connectionId={connectionId}
onStateChange={handleStateChange}
/>
))}
</Fragment>
))
// Todo and Done tab simply list todos
: filteredTasks?.map(({ node: task }) => (
<TaskRow
key={task.id}
fKey={task}
connectionId={connectionId}
/>
))}
: displayTasks.map(({ node }) => {
const task = readTask(node);
return (
<TaskRow
key={task.id}
fKey={node}
connectionId={connectionId}
canDrag={canDrag}
isDragging={draggedId === task.id}
isGhost={previewOrder !== null && draggedId === task.id}
onDragStart={() => setDraggedId(task.id)}
onDragOver={e => handleDragOver(e, task.id)}
onDrop={handleDrop}
onDragEnd={() => setDraggedId(null)}
onStateChange={handleStateChange}
/>
);
})}
</div>
</Card>
)}
{canDrag && filteredTasks.length > 1 && (
<p className="text-sm text-txt-tertiary">
{__("Drag and drop to reorder tasks")}
</p>
)}
</div>
);
}
@@ -125,6 +322,14 @@ export function TasksCard({ tasks, connectionId }: Props) {
type TaskRowProps = {
fKey: TasksCard_TaskRowFragment$key | TaskFormDialogFragment$key;
connectionId: string;
canDrag?: boolean;
isDragging?: boolean;
isGhost?: boolean;
onDragStart?: () => void;
onDragOver?: (e: React.DragEvent) => void;
onDrop?: () => void;
onDragEnd?: () => void;
onStateChange?: () => void;
};
const fragment = graphql`
@@ -175,6 +380,8 @@ function TaskRow(props: TaskRowProps) {
);
const [updateTask, isUpdating] = useMutation<TaskFormDialogUpdateMutation>(taskUpdateMutation);
const [isMouseDown, setIsMouseDown] = useState(false);
const onToggle = async () => {
await promisifyMutation(updateTask)({
variables: {
@@ -184,6 +391,7 @@ function TaskRow(props: TaskRowProps) {
},
},
});
props.onStateChange?.();
};
const onDelete = () => {
@@ -211,13 +419,37 @@ function TaskRow(props: TaskRowProps) {
);
};
const canDrag = props.canDrag;
const isDragging = props.isDragging;
const isGhost = props.isGhost;
const className = [
"transition-all duration-150",
canDrag && isDragging && !isGhost && "opacity-40 cursor-grabbing",
canDrag && !isDragging && !isMouseDown && "cursor-grab",
canDrag && !isDragging && isMouseDown && "cursor-grabbing",
isGhost && "opacity-50 bg-primary-50",
]
.filter(Boolean)
.join(" ");
return (
<>
<TaskFormDialog
task={props.fKey as TaskFormDialogFragment$key}
ref={dialogRef}
/>
<div className="flex items-center justify-between py-3 px-6">
<div
className={`flex items-center justify-between py-3 px-6 ${className}`}
draggable={canDrag}
onDragStart={canDrag ? props.onDragStart : undefined}
onDragOver={canDrag ? props.onDragOver : undefined}
onDrop={canDrag ? props.onDrop : undefined}
onDragEnd={canDrag ? props.onDragEnd : undefined}
onMouseDown={canDrag ? () => setIsMouseDown(true) : undefined}
onMouseUp={canDrag ? () => setIsMouseDown(false) : undefined}
onMouseLeave={canDrag ? () => setIsMouseDown(false) : undefined}
>
<div className="flex gap-2 items-start">
<div className="flex items-center gap-2 pt-[2px]">
<PriorityLevel level={1} />

View File

@@ -1,14 +0,0 @@
import { graphql } from "relay-runtime";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
export const tasksQuery = graphql`
query TaskGraphQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
id
...TasksPageFragment
}
}
}
`;

View File

@@ -15,15 +15,13 @@ const tasksQuery = graphql`
... on Measure {
id
canCreateTask: permission(action: "core:task:create")
tasks(first: 100)
tasks(first: 100, orderBy: { field: PRIORITY, direction: ASC })
@connection(key: "Measure__tasks")
@required(action: THROW) {
__id
edges @required(action: THROW) {
node {
id
# eslint-disable-next-line relay/unused-fields
state
...TasksCard_task
...TaskFormDialogFragment
...TasksCard_TaskRowFragment
}

View File

@@ -1,83 +1,52 @@
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Button, IconPlusLarge, PageHeader } from "@probo/ui";
import {
type PreloadedQuery,
usePreloadedQuery,
useRefetchableFragment,
} from "react-relay";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { graphql } from "relay-runtime";
import type { TaskGraphQuery } from "#/__generated__/core/TaskGraphQuery.graphql";
import type { TasksPageFragment$key } from "#/__generated__/core/TasksPageFragment.graphql";
import type { TasksPageFragment_query } from "#/__generated__/core/TasksPageFragment_query.graphql";
import type { TasksCardOrganizationFragment$key } from "#/__generated__/core/TasksCardOrganizationFragment.graphql";
import type { TasksPageQuery } from "#/__generated__/core/TasksPageQuery.graphql";
import TaskFormDialog from "#/components/tasks/TaskFormDialog";
import { TasksCard } from "#/components/tasks/TasksCard";
import { tasksQuery } from "#/hooks/graph/TaskGraph";
import { OrganizationTasksCard } from "#/components/tasks/TasksCard";
const tasksFragment = graphql`
fragment TasksPageFragment on Organization
@refetchable(queryName: "TasksPageFragment_query")
@argumentDefinitions(
first: { type: "Int", defaultValue: 500 }
order: { type: "TaskOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
canCreateTask: permission(action: "core:task:create")
tasks(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "TasksPageFragment_tasks") @required(action: THROW) {
__id
edges @required(action: THROW) {
# eslint-disable-next-line relay/unused-fields
node {
# eslint-disable-next-line relay/unused-fields
id
# eslint-disable-next-line relay/unused-fields
state
...TaskFormDialogFragment
...TasksCard_TaskRowFragment
}
export const tasksPageQuery = graphql`
query TasksPageQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
...TasksCardOrganizationFragment
}
}
}
`;
interface Props {
queryRef: PreloadedQuery<TaskGraphQuery>;
queryRef: PreloadedQuery<TasksPageQuery>;
}
export default function TasksPage({ queryRef }: Props) {
const { __ } = useTranslate();
const query = usePreloadedQuery(tasksQuery, queryRef);
const [data] = useRefetchableFragment<TasksPageFragment_query, TasksPageFragment$key>(
tasksFragment,
query.organization as TasksPageFragment$key,
);
const connectionId = data.tasks.__id;
const query = usePreloadedQuery(tasksPageQuery, queryRef);
usePageTitle(__("Tasks"));
return (
<div className="space-y-6">
<PageHeader
title={__("Tasks")}
description={__(
"Track your assigned compliance tasks and keep progress on track.",
<OrganizationTasksCard
organizationRef={query.organization as TasksCardOrganizationFragment$key}
header={({ connectionId, canCreateTask }) => (
<PageHeader
title={__("Tasks")}
description={__(
"Track your assigned compliance tasks and keep progress on track.",
)}
>
{canCreateTask && (
<TaskFormDialog connection={connectionId}>
<Button icon={IconPlusLarge}>{__("New task")}</Button>
</TaskFormDialog>
)}
</PageHeader>
)}
>
{data.canCreateTask && (
<TaskFormDialog connection={connectionId}>
<Button icon={IconPlusLarge}>{__("New task")}</Button>
</TaskFormDialog>
)}
</PageHeader>
<TasksCard connectionId={connectionId} tasks={data.tasks.edges} />
/>
</div>
);
}

View File

@@ -6,16 +6,17 @@ import {
} from "@probo/routes";
import { loadQuery } from "react-relay";
import type { TaskGraphQuery } from "#/__generated__/core/TaskGraphQuery.graphql";
import type { TasksPageQuery } from "#/__generated__/core/TasksPageQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { coreEnvironment } from "#/environments";
import { tasksQuery } from "#/hooks/graph/TaskGraph";
import { tasksPageQuery } from "#/pages/organizations/tasks/TasksPage";
export const taskRoutes = [
{
path: "tasks",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<TaskGraphQuery>(coreEnvironment, tasksQuery, {
loadQuery<TasksPageQuery>(coreEnvironment, tasksPageQuery, {
organizationId,
}),
),