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&apos;t a real group, just wherever a task hasn&apos;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>
  );
}