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

View File

@@ -1,5 +1,4 @@
import { formatDate, formatDuration, promisifyMutation } from "@probo/helpers"; import { formatDate, formatDuration, formatError, promisifyMutation } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { import {
ActionDropdown, ActionDropdown,
@@ -16,22 +15,29 @@ import {
TaskStateIcon, TaskStateIcon,
useConfirm, useConfirm,
useDialogRef, useDialogRef,
useToast,
} from "@probo/ui"; } from "@probo/ui";
import { Fragment } from "react"; import { Fragment, type ReactNode, useState, useTransition } from "react";
import { import {
graphql, graphql,
readInlineData,
useFragment, useFragment,
useMutation, useMutation,
useRefetchableFragment,
useRelayEnvironment, useRelayEnvironment,
} from "react-relay"; } from "react-relay";
import { Link, useLocation, useParams } from "react-router"; 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 { TaskFormDialogFragment$key } from "#/__generated__/core/TaskFormDialogFragment.graphql";
import type { TaskFormDialogUpdateMutation } from "#/__generated__/core/TaskFormDialogUpdateMutation.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 { TasksCard_TaskRowFragment$key } from "#/__generated__/core/TasksCard_TaskRowFragment.graphql";
import type { TasksCardDeleteMutation } from "#/__generated__/core/TasksCardDeleteMutation.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, { import TaskFormDialog, {
taskUpdateMutation, taskUpdateMutation,
} from "#/components/tasks/TaskFormDialog"; } from "#/components/tasks/TaskFormDialog";
@@ -39,18 +45,107 @@ import { updateStoreCounter } from "#/hooks/useMutationWithIncrement";
import { useOrganizationId } from "#/hooks/useOrganizationId"; import { useOrganizationId } from "#/hooks/useOrganizationId";
type Props = { type Props = {
tasks: tasks: TasksCardOrganizationFragment$data["tasks"]["edges"];
| TasksPageFragment$data["tasks"]["edges"]
| Extract<
MeasureTasksTabQuery$data["node"],
{ __typename: "Measure" }
>["tasks"]["edges"];
connectionId: string; 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 { __ } = useTranslate();
const hash = useLocation().hash.replace("#", ""); 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 = [ const hashes = [
{ hash: "", label: __("To do"), state: "TODO" }, { hash: "", label: __("To do"), state: "TODO" },
@@ -59,14 +154,99 @@ export function TasksCard({ tasks, connectionId }: Props) {
] as const; ] as const;
const tasksPerHash = new Map([ const tasksPerHash = new Map([
["", tasks?.filter(({ node }) => node.state === "TODO")], ["", tasks?.filter(({ node }) => readTask(node).state === "TODO")],
["done", tasks?.filter(({ node }) => node.state === "DONE")], ["done", tasks?.filter(({ node }) => readTask(node).state === "DONE")],
["all", tasks], ["all", tasks],
]); ]);
const filteredTasks = tasksPerHash.get(hash) ?? []; 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -98,26 +278,43 @@ export function TasksCard({ tasks, connectionId }: Props) {
<TaskStateIcon state={h.state!} /> <TaskStateIcon state={h.state!} />
{h.label} {h.label}
</h2> </h2>
{tasksPerHash.get(h.hash)?.map(({ node: task }) => ( {tasksPerHash.get(h.hash)?.map(({ node }) => (
<TaskRow <TaskRow
key={task.id} key={readTask(node).id}
fKey={task} fKey={node}
connectionId={connectionId} connectionId={connectionId}
onStateChange={handleStateChange}
/> />
))} ))}
</Fragment> </Fragment>
)) ))
// Todo and Done tab simply list todos // Todo and Done tab simply list todos
: filteredTasks?.map(({ node: task }) => ( : displayTasks.map(({ node }) => {
<TaskRow const task = readTask(node);
key={task.id} return (
fKey={task} <TaskRow
connectionId={connectionId} 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> </div>
</Card> </Card>
)} )}
{canDrag && filteredTasks.length > 1 && (
<p className="text-sm text-txt-tertiary">
{__("Drag and drop to reorder tasks")}
</p>
)}
</div> </div>
); );
} }
@@ -125,6 +322,14 @@ export function TasksCard({ tasks, connectionId }: Props) {
type TaskRowProps = { type TaskRowProps = {
fKey: TasksCard_TaskRowFragment$key | TaskFormDialogFragment$key; fKey: TasksCard_TaskRowFragment$key | TaskFormDialogFragment$key;
connectionId: string; connectionId: string;
canDrag?: boolean;
isDragging?: boolean;
isGhost?: boolean;
onDragStart?: () => void;
onDragOver?: (e: React.DragEvent) => void;
onDrop?: () => void;
onDragEnd?: () => void;
onStateChange?: () => void;
}; };
const fragment = graphql` const fragment = graphql`
@@ -175,6 +380,8 @@ function TaskRow(props: TaskRowProps) {
); );
const [updateTask, isUpdating] = useMutation<TaskFormDialogUpdateMutation>(taskUpdateMutation); const [updateTask, isUpdating] = useMutation<TaskFormDialogUpdateMutation>(taskUpdateMutation);
const [isMouseDown, setIsMouseDown] = useState(false);
const onToggle = async () => { const onToggle = async () => {
await promisifyMutation(updateTask)({ await promisifyMutation(updateTask)({
variables: { variables: {
@@ -184,6 +391,7 @@ function TaskRow(props: TaskRowProps) {
}, },
}, },
}); });
props.onStateChange?.();
}; };
const onDelete = () => { 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 ( return (
<> <>
<TaskFormDialog <TaskFormDialog
task={props.fKey as TaskFormDialogFragment$key} task={props.fKey as TaskFormDialogFragment$key}
ref={dialogRef} 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 gap-2 items-start">
<div className="flex items-center gap-2 pt-[2px]"> <div className="flex items-center gap-2 pt-[2px]">
<PriorityLevel level={1} /> <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 { ... on Measure {
id id
canCreateTask: permission(action: "core:task:create") canCreateTask: permission(action: "core:task:create")
tasks(first: 100) tasks(first: 100, orderBy: { field: PRIORITY, direction: ASC })
@connection(key: "Measure__tasks") @connection(key: "Measure__tasks")
@required(action: THROW) { @required(action: THROW) {
__id __id
edges @required(action: THROW) { edges @required(action: THROW) {
node { node {
id ...TasksCard_task
# eslint-disable-next-line relay/unused-fields
state
...TaskFormDialogFragment ...TaskFormDialogFragment
...TasksCard_TaskRowFragment ...TasksCard_TaskRowFragment
} }

View File

@@ -1,83 +1,52 @@
import { usePageTitle } from "@probo/hooks"; import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { Button, IconPlusLarge, PageHeader } from "@probo/ui"; import { Button, IconPlusLarge, PageHeader } from "@probo/ui";
import { import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
type PreloadedQuery,
usePreloadedQuery,
useRefetchableFragment,
} from "react-relay";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
import type { TaskGraphQuery } from "#/__generated__/core/TaskGraphQuery.graphql"; import type { TasksCardOrganizationFragment$key } from "#/__generated__/core/TasksCardOrganizationFragment.graphql";
import type { TasksPageFragment$key } from "#/__generated__/core/TasksPageFragment.graphql"; import type { TasksPageQuery } from "#/__generated__/core/TasksPageQuery.graphql";
import type { TasksPageFragment_query } from "#/__generated__/core/TasksPageFragment_query.graphql";
import TaskFormDialog from "#/components/tasks/TaskFormDialog"; import TaskFormDialog from "#/components/tasks/TaskFormDialog";
import { TasksCard } from "#/components/tasks/TasksCard"; import { OrganizationTasksCard } from "#/components/tasks/TasksCard";
import { tasksQuery } from "#/hooks/graph/TaskGraph";
const tasksFragment = graphql` export const tasksPageQuery = graphql`
fragment TasksPageFragment on Organization query TasksPageQuery($organizationId: ID!) {
@refetchable(queryName: "TasksPageFragment_query") organization: node(id: $organizationId) {
@argumentDefinitions( ... on Organization {
first: { type: "Int", defaultValue: 500 } ...TasksCardOrganizationFragment
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
}
} }
} }
} }
`; `;
interface Props { interface Props {
queryRef: PreloadedQuery<TaskGraphQuery>; queryRef: PreloadedQuery<TasksPageQuery>;
} }
export default function TasksPage({ queryRef }: Props) { export default function TasksPage({ queryRef }: Props) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const query = usePreloadedQuery(tasksQuery, queryRef); const query = usePreloadedQuery(tasksPageQuery, queryRef);
const [data] = useRefetchableFragment<TasksPageFragment_query, TasksPageFragment$key>(
tasksFragment,
query.organization as TasksPageFragment$key,
);
const connectionId = data.tasks.__id;
usePageTitle(__("Tasks")); usePageTitle(__("Tasks"));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <OrganizationTasksCard
title={__("Tasks")} organizationRef={query.organization as TasksCardOrganizationFragment$key}
description={__( header={({ connectionId, canCreateTask }) => (
"Track your assigned compliance tasks and keep progress on track.", <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> </div>
); );
} }

View File

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