Files
probo/apps/console/src/components/tasks/TasksCard.tsx
Sacha Al Himdani 324f4ce793 Add task priority enum and rename priority to rank
The existing integer priority field represents positional ordering
within a state, not semantic importance. Rename it to rank and
introduce a new priority field with enum values URGENT, HIGH,
MEDIUM and LOW across the entire stack.

Rank is now scoped to (state, priority) so tasks are ordered
within each priority group. A generated priority_rank column
combines both fields into a single sortable integer for cursor
pagination.

Dragging a task across priority groups updates its priority
automatically based on the drop position neighbors. The backend
first moves the task to the new group then repositions it at the
target rank.

The migration defaults existing rows to MEDIUM priority and
backfills ranks per (state, priority) group.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
2026-04-02 13:35:39 +02:00

596 lines
19 KiB
TypeScript

// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatDate, formatDuration, formatError, promisifyMutation } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Card,
DropdownItem,
IconArrowCornerDownLeft,
IconPencil,
IconTrashCan,
PriorityLevel,
Spinner,
TabBadge,
TabItem,
Tabs,
TaskStateIcon,
useConfirm,
useDialogRef,
useToast,
} from "@probo/ui";
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 { TaskFormDialogFragment$key } from "#/__generated__/core/TaskFormDialogFragment.graphql";
import type {
TaskFormDialogUpdateMutation,
TaskPriority,
} 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 {
TasksCardOrganizationFragment$data,
TasksCardOrganizationFragment$key,
} from "#/__generated__/core/TasksCardOrganizationFragment.graphql";
import type { TasksCardOrganizationQuery } from "#/__generated__/core/TasksCardOrganizationQuery.graphql";
import TaskFormDialog, {
taskPriorities,
taskUpdateMutation,
} from "#/components/tasks/TaskFormDialog";
import { updateStoreCounter } from "#/hooks/useMutationWithIncrement";
import { useOrganizationId } from "#/hooks/useOrganizationId";
function resolveDropPriority(
dragged: TaskPriority,
above?: TaskPriority,
below?: TaskPriority,
): TaskPriority | undefined {
// If any neighbor shares the dragged priority, keep it.
if (above === dragged || below === dragged) return undefined;
// At edges, take the single neighbor's priority.
if (!above && below) return below !== dragged ? below : undefined;
if (!below && above) return above !== dragged ? above : undefined;
// Both neighbors differ — pick the one closest to dragged.
if (above && below) {
const di = taskPriorities.indexOf(dragged);
const dAbove = Math.abs(taskPriorities.indexOf(above) - di);
const dBelow = Math.abs(taskPriorities.indexOf(below) - di);
return dAbove <= dBelow ? above : below;
}
return undefined;
}
type Props = {
tasks: TasksCardOrganizationFragment$data["tasks"]["edges"];
connectionId: string;
canReorder?: boolean;
refetch?: (vars: Record<string, never>, options?: { fetchPolicy?: "store-and-network" | "network-only" }) => void;
};
const taskInlineFragment = graphql`
fragment TasksCard_task on Task @inline {
id
state
priority
rank
}
`;
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_RANK, 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; refetch: () => void }) => ReactNode;
};
export function OrganizationTasksCard({ organizationRef, header }: OrganizationTasksCardProps) {
const [data, refetch] = useRefetchableFragment<
TasksCardOrganizationQuery,
TasksCardOrganizationFragment$key
>(organizationTasksFragment, organizationRef);
const handleRefetch = () => {
refetch({}, { fetchPolicy: "store-and-network" });
};
return (
<>
{header?.({ connectionId: data.tasks.__id, canCreateTask: data.canCreateTask, refetch: handleRefetch })}
<TasksCard
tasks={data.tasks.edges}
connectionId={data.tasks.__id}
canReorder={data.canUpdateTask}
refetch={refetch}
/>
</>
);
}
const updateRankMutation = graphql`
mutation TasksCardUpdateRankMutation($input: UpdateTaskInput!) {
updateTask(input: $input) {
task {
id
priority
rank
}
}
}
`;
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 [updateRank] = useMutation<TaskFormDialogUpdateMutation>(updateRankMutation);
const handleStateChange = () => {
if (refetch) {
startTransition(() => {
refetch({}, { fetchPolicy: "store-and-network" });
});
}
};
const hashes = [
{ hash: "", label: __("To do"), state: "TODO" },
{ hash: "done", label: __("Done"), state: "DONE" },
{ hash: "all", label: __("All"), state: null },
] as const;
const tasksPerHash = new Map([
["", 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";
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);
if (originalIdx === -1) {
setDraggedId(null);
setPreviewOrder(null);
return;
}
let targetOriginalIdx = newIdx;
if (targetOriginalIdx >= originalIdx) targetOriginalIdx++;
if (targetOriginalIdx >= filteredTasks.length) targetOriginalIdx = filteredTasks.length - 1;
const targetTask = readTask(filteredTasks[targetOriginalIdx].node);
const draggedTask = readTask(filteredTasks[originalIdx].node);
// Determine target priority from neighbors at the drop position.
const aboveId = newIdx > 0 ? previewOrder[newIdx - 1] : null;
const belowId = newIdx < previewOrder.length - 1 ? previewOrder[newIdx + 1] : null;
const aboveTask = aboveId ? readTask(filteredTasks[originalIds.indexOf(aboveId)].node) : null;
const belowTask = belowId ? readTask(filteredTasks[originalIds.indexOf(belowId)].node) : null;
const targetPriority = resolveDropPriority(draggedTask.priority, aboveTask?.priority, belowTask?.priority);
setDraggedId(null);
updateRank({
variables: {
input: {
taskId: draggedId,
rank: targetTask.rank,
...(targetPriority && { 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">
{tasks.length === 0
? (
<p className="text-center py-6 text-txt-secondary">{__("No tasks")}</p>
)
: (
<Card>
<Tabs className="px-6">
{hashes.map(h => (
<TabItem asChild active={hash === h.hash} key={h.hash}>
<Link to={`#${h.hash}`}>
{h.label}
<TabBadge>{tasksPerHash.get(h.hash)?.length}</TabBadge>
</Link>
</TabItem>
))}
</Tabs>
<div className="divide-y divide-border-solid">
{hash === "all"
// All tabs group the todo using the state
? hashes
.slice(0, 2)
.filter(h => tasksPerHash.get(h.hash)?.length)
.map(h => (
<Fragment key={h.label}>
<h2 className="px-6 py-3 text-sm font-medium flex items-center gap-2 bg-subtle">
<TaskStateIcon state={h.state!} />
{h.label}
</h2>
{tasksPerHash.get(h.hash)?.map(({ node }) => (
<TaskRow
key={readTask(node).id}
fKey={node}
connectionId={connectionId}
onStateChange={handleStateChange}
/>
))}
</Fragment>
))
// Todo and Done tab simply list todos
: 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>
);
}
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`
fragment TasksCard_TaskRowFragment on Task {
id
name
state
priority
description
timeEstimate
deadline
canUpdate: permission(action: "core:task:update")
canDelete: permission(action: "core:task:delete")
assignedTo {
id
fullName
}
measure {
id
name
}
}
`;
const deleteMutation = graphql`
mutation TasksCardDeleteMutation(
$input: DeleteTaskInput!
$connections: [ID!]!
) {
deleteTask(input: $input) {
deletedTaskId @deleteEdge(connections: $connections)
}
}
`;
function TaskRow(props: TaskRowProps) {
const organizationId = useOrganizationId();
const dialogRef = useDialogRef();
const { __ } = useTranslate();
const confirm = useConfirm();
const [deleteTask] = useMutation<TasksCardDeleteMutation>(deleteMutation);
const params = useParams<{ measureId?: string }>();
const relayEnv = useRelayEnvironment();
const { canUpdate, canDelete, ...task }
= useFragment<TasksCard_TaskRowFragment$key>(
fragment,
props.fKey as TasksCard_TaskRowFragment$key,
);
const [updateTask, isUpdating] = useMutation<TaskFormDialogUpdateMutation>(taskUpdateMutation);
const [isMouseDown, setIsMouseDown] = useState(false);
const onToggle = async () => {
await promisifyMutation(updateTask)({
variables: {
input: {
taskId: task.id,
state: task.state === "TODO" ? "DONE" : "TODO",
},
},
});
props.onStateChange?.();
};
const onDelete = () => {
confirm(
() =>
promisifyMutation(deleteTask)({
variables: {
input: { taskId: task.id },
connections: [props.connectionId],
},
onCompleted: (_response, errors) => {
if (!errors && params.measureId) {
updateStoreCounter(
relayEnv,
params.measureId,
"tasks(first:0)",
-1,
);
}
},
}),
{
message: "Are you sure you want to delete this task?",
},
);
};
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}
onCompleted={props.onStateChange}
/>
<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={task.priority} />
<button
onClick={() => void onToggle()}
className="cursor-pointer -m-1 p-1 disabled:opacity-60"
disabled={isUpdating}
>
<TaskStateIcon state={task.state} />
</button>
</div>
<div className="text-sm space-y-1 flex-1">
<h2 className="font-medium">{task.name}</h2>
{task.description && (
<p className="text-txt-secondary whitespace-pre-wrap wrap-break-word">
{task.description}
</p>
)}
<div className="flex flex-wrap items-center gap-3 text-txt-secondary text-xs">
{task.measure && (
<span className="flex items-center gap-1">
<IconArrowCornerDownLeft className="scale-x-[-1]" size={14} />
<Link
className="hover:underline"
to={`/organizations/${organizationId}/measures/${task.measure?.id}`}
>
{task.measure?.name}
</Link>
</span>
)}
{task.timeEstimate && (
<span>{formatDuration(task.timeEstimate, __)}</span>
)}
{task.deadline && (
<time dateTime={task.deadline}>
{formatDate(task.deadline)}
</time>
)}
</div>
</div>
</div>
{task.assignedTo?.fullName && (
<div className="text-sm text-txt-secondary ml-auto mr-8">
<Link
className="hover:underline"
to={`/organizations/${organizationId}/people/${task.assignedTo.id}`}
>
{task.assignedTo.fullName}
</Link>
</div>
)}
<div className="flex gap-2 items-center">
{isUpdating && <Spinner size={16} />}
{(canUpdate || canDelete) && (
<ActionDropdown>
{canUpdate && (
<DropdownItem
icon={IconPencil}
onClick={() => dialogRef.current?.open()}
>
{__("Edit")}
</DropdownItem>
)}
{canDelete && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
)}
</div>
</div>
</>
);
}