From 4a2d308da0822f7b536cc59b80f7ce214e06c366 Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Thu, 26 Mar 2026 19:05:18 +0100 Subject: [PATCH] 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 --- .../src/components/tasks/TaskFormDialog.tsx | 8 +- .../src/components/tasks/TasksCard.tsx | 284 ++++++++++++++++-- apps/console/src/hooks/graph/TaskGraph.ts | 14 - .../measures/tabs/MeasureTasksTab.tsx | 6 +- .../pages/organizations/tasks/TasksPage.tsx | 85 ++---- apps/console/src/routes/taskRoutes.ts | 7 +- 6 files changed, 296 insertions(+), 108 deletions(-) delete mode 100644 apps/console/src/hooks/graph/TaskGraph.ts diff --git a/apps/console/src/components/tasks/TaskFormDialog.tsx b/apps/console/src/components/tasks/TaskFormDialog.tsx index 04094c471..90387068e 100644 --- a/apps/console/src/components/tasks/TaskFormDialog.tsx +++ b/apps/console/src/components/tasks/TaskFormDialog.tsx @@ -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 } } } diff --git a/apps/console/src/components/tasks/TasksCard.tsx b/apps/console/src/components/tasks/TasksCard.tsx index f9d38d382..a8be15e90 100644 --- a/apps/console/src/components/tasks/TasksCard.tsx +++ b/apps/console/src/components/tasks/TasksCard.tsx @@ -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, 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 })} + + + ); +} + +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(null); + const [previewOrder, setPreviewOrder] = useState(null); + const [updatePriority] = useMutation(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 (
@@ -98,26 +278,43 @@ export function TasksCard({ tasks, connectionId }: Props) { {h.label} - {tasksPerHash.get(h.hash)?.map(({ node: task }) => ( + {tasksPerHash.get(h.hash)?.map(({ node }) => ( ))} )) // Todo and Done tab simply list todos - : filteredTasks?.map(({ node: task }) => ( - - ))} + : displayTasks.map(({ node }) => { + const task = readTask(node); + return ( + setDraggedId(task.id)} + onDragOver={e => handleDragOver(e, task.id)} + onDrop={handleDrop} + onDragEnd={() => setDraggedId(null)} + onStateChange={handleStateChange} + /> + ); + })}
)} + {canDrag && filteredTasks.length > 1 && ( +

+ {__("Drag and drop to reorder tasks")} +

+ )} ); } @@ -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(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 ( <> -
+
setIsMouseDown(true) : undefined} + onMouseUp={canDrag ? () => setIsMouseDown(false) : undefined} + onMouseLeave={canDrag ? () => setIsMouseDown(false) : undefined} + >
diff --git a/apps/console/src/hooks/graph/TaskGraph.ts b/apps/console/src/hooks/graph/TaskGraph.ts deleted file mode 100644 index e993e5ee8..000000000 --- a/apps/console/src/hooks/graph/TaskGraph.ts +++ /dev/null @@ -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 - } - } - } -`; diff --git a/apps/console/src/pages/organizations/measures/tabs/MeasureTasksTab.tsx b/apps/console/src/pages/organizations/measures/tabs/MeasureTasksTab.tsx index 051be3a9e..3589d2f38 100644 --- a/apps/console/src/pages/organizations/measures/tabs/MeasureTasksTab.tsx +++ b/apps/console/src/pages/organizations/measures/tabs/MeasureTasksTab.tsx @@ -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 } diff --git a/apps/console/src/pages/organizations/tasks/TasksPage.tsx b/apps/console/src/pages/organizations/tasks/TasksPage.tsx index d66ca3b91..d3ae0c33f 100644 --- a/apps/console/src/pages/organizations/tasks/TasksPage.tsx +++ b/apps/console/src/pages/organizations/tasks/TasksPage.tsx @@ -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; + queryRef: PreloadedQuery; } export default function TasksPage({ queryRef }: Props) { const { __ } = useTranslate(); - const query = usePreloadedQuery(tasksQuery, queryRef); - const [data] = useRefetchableFragment( - tasksFragment, - query.organization as TasksPageFragment$key, - ); - const connectionId = data.tasks.__id; + const query = usePreloadedQuery(tasksPageQuery, queryRef); usePageTitle(__("Tasks")); return (
- ( + + {canCreateTask && ( + + + + )} + )} - > - {data.canCreateTask && ( - - - - )} - - + />
); } diff --git a/apps/console/src/routes/taskRoutes.ts b/apps/console/src/routes/taskRoutes.ts index df492c35e..0a55ab2d2 100644 --- a/apps/console/src/routes/taskRoutes.ts +++ b/apps/console/src/routes/taskRoutes.ts @@ -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(coreEnvironment, tasksQuery, { + loadQuery(coreEnvironment, tasksPageQuery, { organizationId, }), ),