Files
kandev/apps/web/components/task/task-switcher-context-menu.tsx
T
João Salavisa aa6023e421 feat(web): per-task color indicator in sidebar (#835)
* feat(web): per-task color indicator in sidebar (localStorage)

Adds a color marker to tasks in the left task list for better visual
identification. Right-click a task → Color submenu picks one of seven
colors (or None to clear). Color renders as a 3px bar on the left edge
of the row and persists in localStorage.

* fix(web): address review feedback on task color indicator

- Filter storage events to TASK_COLORS_STORAGE_KEY so cross-tab writes
  to unrelated keys no longer wake every TaskItem subscriber.
- Cache the parsed color map at module level, invalidated on writes and
  cross-tab storage events. Avoids re-parsing localStorage on every
  getSnapshot call (N tasks × N renders).
- Drop redundant typeof string check on Object.entries keys.
- Dim the colored bar to opacity-60 when not selected so colored rows
  remain distinguishable from colored-and-selected rows at the left edge.

* refactor(web): remove test-only export from task-colors

Drop the __resetTaskColorsCacheForTests escape hatch from the
production module. Tests now invalidate the cache via a synthesized
cross-tab `storage` event, exercising the same listener path the
production code uses for real cross-tab updates.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-06 19:05:19 +01:00

224 lines
6.5 KiB
TypeScript

"use client";
import { cloneElement, isValidElement, useState } from "react";
import {
IconArchive,
IconCheck,
IconCopy,
IconLoader,
IconPalette,
IconPencil,
IconPin,
IconPinFilled,
IconTrash,
} from "@tabler/icons-react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@kandev/ui/context-menu";
import {
TaskMoveContextMenuItems,
type TaskMoveWorkflow,
} from "@/components/task/task-move-context-menu";
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
import { useSetTaskColor, useTaskColor } from "@/hooks/use-task-color";
import {
TASK_COLORS,
TASK_COLOR_BAR_CLASS,
TASK_COLOR_LABEL,
type TaskColor,
} from "@/lib/task-colors";
import { cn } from "@/lib/utils";
import type { TaskSwitcherItem } from "./task-switcher";
export type StepDef = {
id: string;
title: string;
color?: string;
events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }> };
};
type ContextMenuProps = {
task: TaskSwitcherItem;
workflows?: TaskMoveWorkflow[];
stepsByWorkflowId?: Record<string, StepDef[]>;
steps?: StepDef[];
children: React.ReactElement<{ menuOpen?: boolean }>;
onRenameTask?: (taskId: string, currentTitle: string) => void;
onArchiveTask?: (taskId: string) => void;
onDeleteTask?: (taskId: string) => void;
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
onTogglePin?: (taskId: string) => void;
isPinned?: boolean;
isDeleting?: boolean;
};
export function TaskItemWithContextMenu({
task,
workflows,
stepsByWorkflowId,
steps,
children,
onRenameTask,
onArchiveTask,
onDeleteTask,
onMoveToStep,
onTogglePin,
isPinned,
isDeleting,
}: ContextMenuProps) {
const [contextOpen, setContextOpen] = useState(false);
const [menuKey, setMenuKey] = useState(0);
const moveTasks = useTaskWorkflowMove();
const menuOpen = contextOpen || isDeleting === true;
const closeMenu = () => {
setContextOpen(false);
setMenuKey((k) => k + 1);
};
return (
<ContextMenu key={menuKey} onOpenChange={setContextOpen}>
<ContextMenuTrigger asChild>
<div>{cloneWithMenuOpen(children, menuOpen)}</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{onTogglePin && (
<ContextMenuItem disabled={isDeleting} onSelect={() => onTogglePin(task.id)}>
{isPinned ? (
<IconPinFilled className="mr-2 h-4 w-4" />
) : (
<IconPin className="mr-2 h-4 w-4" />
)}
{isPinned ? "Unpin" : "Pin"}
</ContextMenuItem>
)}
{onRenameTask && (
<ContextMenuItem disabled={isDeleting} onSelect={() => onRenameTask(task.id, task.title)}>
<IconPencil className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
)}
<ContextMenuItem disabled>
<IconCopy className="mr-2 h-4 w-4" />
Duplicate
</ContextMenuItem>
{onArchiveTask && (
<ContextMenuItem disabled={isDeleting} onSelect={() => onArchiveTask(task.id)}>
<IconArchive className="mr-2 h-4 w-4" />
Archive
</ContextMenuItem>
)}
<TaskColorMenu taskId={task.id} disabled={isDeleting} />
{task.workflowId && (
<TaskMoveContextMenuItems
currentWorkflowId={task.workflowId}
currentStepId={task.workflowStepId}
workflows={workflows ?? []}
stepsByWorkflowId={stepsByWorkflowId ?? (steps ? { [task.workflowId]: steps } : {})}
disabled={isDeleting || task.isArchived}
onMoveToStep={
onMoveToStep
? (stepId) => {
closeMenu();
onMoveToStep(task.id, task.workflowId!, stepId);
}
: undefined
}
onSendToWorkflow={(workflowId, stepId) => {
closeMenu();
void moveTasks([task.id], workflowId, stepId).catch(() => {
// useTaskWorkflowMove already shows the failure toast.
});
}}
/>
)}
{onDeleteTask && (
<>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={isDeleting}
onSelect={() => onDeleteTask(task.id)}
>
{isDeleting ? (
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
) : (
<IconTrash className="mr-2 h-4 w-4" />
)}
Delete
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
);
}
function cloneWithMenuOpen(
children: React.ReactElement<{ menuOpen?: boolean }>,
menuOpen: boolean,
): React.ReactNode {
if (isValidElement(children)) return cloneElement(children, { menuOpen });
return children;
}
function TaskColorMenu({ taskId, disabled }: { taskId: string; disabled?: boolean }) {
const currentColor = useTaskColor(taskId);
const setColor = useSetTaskColor();
return (
<ContextMenuSub>
<ContextMenuSubTrigger disabled={disabled}>
<IconPalette className="mr-2 h-4 w-4" />
Color
{currentColor && (
<span
className={cn(
"ml-2 inline-block h-2 w-2 rounded-full",
TASK_COLOR_BAR_CLASS[currentColor],
)}
/>
)}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-40">
{TASK_COLORS.map((color) => (
<TaskColorMenuItem
key={color}
color={color}
selected={currentColor === color}
onSelect={() => setColor(taskId, color)}
/>
))}
<ContextMenuSeparator />
<ContextMenuItem disabled={!currentColor} onSelect={() => setColor(taskId, null)}>
<span className="mr-2 inline-block h-2 w-2 rounded-full border border-muted-foreground/40" />
None
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
);
}
function TaskColorMenuItem({
color,
selected,
onSelect,
}: {
color: TaskColor;
selected: boolean;
onSelect: () => void;
}) {
return (
<ContextMenuItem onSelect={onSelect}>
<span className={cn("mr-2 inline-block h-2 w-2 rounded-full", TASK_COLOR_BAR_CLASS[color])} />
{TASK_COLOR_LABEL[color]}
{selected && <IconCheck className="ml-auto h-3.5 w-3.5" />}
</ContextMenuItem>
);
}