TaskGroupOrderPopover.tsx8.5 KBView on GitHub 'use client';
/**
* TaskGroupOrderPopover — the ordered, draggable list of task groups, styled after the CRM
* sorting popover (see conversation-canvas/FilterSortConfigurationRow): 1-indexed rows, a grip
* handle, and the group as a rounded pill.
*
* This is the one ordering the grouped surfaces read: it is the section order in /tasks/list when
* grouping by task group, and the column order (left to right) on /tasks/kanban. It writes through
* `taskGroups.reorderGroups`, which rewrites each group's `position` to its index.
*
* Misc is virtual (no row exists for it) and is pinned last by the server, so it renders as a
* static trailing row rather than a draggable one.
*/
import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, HelpCircle } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import { taskGroupIcon } from '@/modules/userTasks/utils/task-group-icons';
import { reorderCachedGroups, listGroupsInput } from '@/modules/userTasks/utils/group-cache';
import { useTRPC } from '@/providers/query-provider';
import { cn } from '@/lib/utils';
interface OrderableGroup {
id: string;
name: string;
color: string | null;
icon: string | null;
openTaskCount: number;
}
/** The group pill — the sort-variant badge from the CRM sorting list, with the group's own accent. */
function GroupBadge({
group,
isDragging,
}: {
group: OrderableGroup;
isDragging?: boolean;
}) {
const Icon = taskGroupIcon(group.icon);
return (
<Badge
variant="secondary"
className={cn(
'bg-sunken flex h-8 w-full min-w-0 cursor-grab items-center gap-1.5 rounded-full px-2 text-xs font-normal transition-all active:cursor-grabbing',
isDragging && 'ring-primary/20 ring-2',
)}
>
<GripVertical className="h-3 w-3 shrink-0 opacity-50" />
<Icon
className="h-3 w-3 shrink-0"
style={group.color ? { color: group.color } : undefined}
/>
<span className="min-w-0 flex-1 truncate text-left">{group.name}</span>
{group.openTaskCount > 0 && (
<span className="bg-muted/50 shrink-0 rounded px-1 py-1 text-xs leading-none tabular-nums">
{group.openTaskCount}
</span>
)}
</Badge>
);
}
function GroupRow({ group, index }: { group: OrderableGroup; index: number }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: group.id,
animateLayoutChanges: () => true,
});
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition: transition || 'transform 200ms ease',
zIndex: isDragging ? 1 : undefined,
}}
className="flex min-w-0 items-center gap-1.5"
>
<span className="text-muted-foreground w-4 shrink-0 text-right text-xs tabular-nums">
{index + 1}.
</span>
<div className="min-w-0 flex-1" {...attributes} {...listeners}>
<GroupBadge group={group} isDragging={isDragging} />
</div>
</div>
);
}
function GroupOrderHelpPopover() {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="How group order works"
className="text-muted-foreground hover:text-foreground flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-72 p-3 text-xs leading-relaxed" align="start">
<p className="text-foreground mb-1.5 font-medium">How group order works</p>
<p className="text-muted-foreground">
Groups run top to bottom: this is the order of the sections on the list and of the columns
on the board, left to right. Drag a group to move it.
</p>
<p className="text-muted-foreground mt-1.5">
Misc always comes last — it isn't a real group, just wherever a task hasn't been
filed yet.
</p>
</PopoverContent>
</Popover>
);
}
/** The sortable list — also usable on its own if a surface wants it inline. */
export function TaskGroupOrderList() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const { data } = useQuery(trpc.taskGroups.listGroups.queryOptions(listGroupsInput()));
const reorderGroups = useMutation(trpc.taskGroups.reorderGroups.mutationOptions());
const groupsKey=[redacted];
// Rendered straight from the query cache — the same cache the List and Board read. A drag
// rewrites that cache, so all three move together and there's no local copy to fall out of sync
// with (or to be reset by a background refetch mid-drag).
const order = useMemo<OrderableGroup[]>(
() =>
(data?.groups ?? [])
.filter((g): g is typeof g & { id: string } => !g.isMisc && !!g.id)
.map((g) => ({
id: g.id,
name: g.name,
color: g.color,
icon: g.icon ?? null,
openTaskCount: g.openTaskCount,
})),
[data],
);
const hasMisc = (data?.groups ?? []).some((g) => g.isMisc);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = order.findIndex((g) => g.id === active.id);
const newIndex = order.findIndex((g) => g.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const orderedGroupIds = arrayMove(order, oldIndex, newIndex).map((g) => g.id);
// Write the new order into the cache before the round trip. Without this the drag only lands
// after a refetch — and the refetch can lose a race with an in-flight `listGroups` request that
// was issued before the write, putting the old order straight back (see canvas-query-cache for
// the same failure mode).
queryClient.setQueriesData({ queryKey=[redacted] }, (cached: unknown) =>
reorderCachedGroups(cached, orderedGroupIds),
);
reorderGroups.mutate(
{ orderedGroupIds },
{ onSettled: () => void queryClient.invalidateQueries({ queryKey=[redacted] }) },
);
};
return (
<div className="flex w-full min-w-[260px] flex-col gap-2">
<div className="flex items-center gap-1">
<span className="text-foreground text-xs font-medium">Group order</span>
<GroupOrderHelpPopover />
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={order.map((g) => g.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-1.5">
{order.map((group, index) => (
<GroupRow key=[redacted] group={group} index={index} />
))}
{order.length === 0 && (
<span className="text-muted-foreground py-1 text-xs">No task groups yet</span>
)}
</div>
</SortableContext>
</DndContext>
{hasMisc && (
<div className="flex min-w-0 items-center gap-1.5 opacity-60">
<span className="text-muted-foreground w-4 shrink-0 text-right text-xs tabular-nums">
{order.length + 1}.
</span>
<Badge
variant="secondary"
className="bg-sunken flex h-8 w-full min-w-0 items-center gap-1.5 rounded-full px-2 text-xs font-normal"
>
<span className="bg-muted-foreground/40 ml-1 size-2 shrink-0 rounded-full" />
<span className="min-w-0 flex-1 truncate text-left">Misc</span>
<span className="text-muted-foreground shrink-0 text-xs">always last</span>
</Badge>
</div>
)}
</div>
);
}
/** The list behind a trigger, for the Display popover's "Group order" row. */
export function TaskGroupOrderPopover({ trigger }: { trigger: React.ReactNode }) {
return (
<Popover>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent side="left" align="start" sideOffset={8} className="w-auto p-3">
<TaskGroupOrderList />
</PopoverContent>
</Popover>
);
}