Files
kandev/apps/web/components/kanban-card-menu-items.tsx
T
2026-09-23 11:00:23 +01:00

671 lines
19 KiB
TypeScript

"use client";
import { type ReactNode } from "react";
import {
IconArchive,
IconArrowRight,
IconFlag,
IconLoader,
IconLogicBuffer,
IconTrash,
IconUnlink,
} from "@tabler/icons-react";
import {
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
} from "@kandev/ui/context-menu";
import {
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "@kandev/ui/dropdown-menu";
import {
stepHasAutoStart,
type TaskMoveStep,
type TaskMoveWorkflow,
} from "@/components/task/task-move-context-menu";
import {
isTaskPriority,
TASK_PRIORITY_LABEL_KEYS,
TASK_PRIORITY_TOKENS,
} from "@/lib/tasks/task-priority";
import type { TaskPriority } from "@/lib/types/http";
import { cn } from "@/lib/utils";
import { buildLinkSubmenu } from "./kanban-card-link-submenu";
import type { PluginIcon, PluginTaskMenuContext } from "@/lib/plugins/types";
import { buildEditMenuEntry } from "./kanban-card-edit-submenu";
import { buildPrimaryPluginEntries } from "./plugins/task-menu-actions";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
export { useKanbanCardMoveTargets } from "@/hooks/use-kanban-card-move-targets";
type ItemEntry = {
kind: "item";
key: string;
label: ReactNode;
icon?: ReactNode;
leading?: ReactNode;
trailing?: ReactNode;
disabled?: boolean;
destructive?: boolean;
testId?: string;
onSelect?: () => void;
};
type SeparatorEntry = { kind: "separator"; key: string };
type SubmenuEntry = {
kind: "submenu";
key: string;
label: ReactNode;
icon?: ReactNode;
disabled?: boolean;
testId?: string;
className?: string;
children: KanbanCardMenuEntry[];
};
export type KanbanCardMenuEntry = ItemEntry | SeparatorEntry | SubmenuEntry;
export type KanbanCardMenuEntryGroup = {
key: string;
entries: readonly KanbanCardMenuEntry[];
};
/** Inserts one separator between each nonempty top-level menu group. */
export function buildGroupedMenuEntries(
groups: readonly KanbanCardMenuEntryGroup[],
): KanbanCardMenuEntry[] {
return groups
.filter((group) => group.entries.length > 0)
.flatMap((group, index) => {
const separator: SeparatorEntry = {
kind: "separator",
key: `${group.key}-separator`,
};
return index === 0 ? [...group.entries] : [separator, ...group.entries];
});
}
export type KanbanCardMoveTargets = {
currentWorkflowId: string | null;
workflowItems: TaskMoveWorkflow[];
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
};
/** Registry action already bound to this card's immutable task context. */
export type KanbanPluginLinkAction = {
id: string;
label: string;
icon?: PluginIcon;
disabled?: boolean;
onSelect: () => void;
};
export type BuildKanbanCardMenuEntriesArgs = {
currentWorkflowId?: string | null;
currentStepId?: string | null;
workflows: TaskMoveWorkflow[];
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
/** Disables only move and send-to-workflow entries while a row-local move runs. */
moveDisabled?: boolean;
disabled?: boolean;
isDeleting?: boolean;
isArchiving?: boolean;
isDetaching?: boolean;
parentTaskId?: string | null;
/** The task's currently held priority; may be absent, empty or unrecognized. */
currentPriority?: string | null;
onSelectPriority?: (priority: TaskPriority) => void;
onEdit?: () => void;
onArchive?: () => void;
onDelete?: () => void;
onDetach?: () => void;
onLinkPullRequest?: () => void;
onLinkIssue?: () => void;
onLinkMergeRequest?: () => void;
onLinkJiraTicket?: () => void;
onLinkLinearIssue?: () => void;
onLinkSentryIssue?: () => void;
pluginLinkActions?: KanbanPluginLinkAction[];
onMoveToStep?: (stepId: string) => void;
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
/** Defaults to an empty-id context (no visible plugin actions match it in practice). */
pluginMenuContext?: PluginTaskMenuContext;
/**
* Plugin contributions already built from this call's `PluginEntryInputs`.
* Callers must build them with the same inputs they pass here: the entries
* carry the disabled state, the edit handler and the menu context, so reusing
* a set across an input mismatch silently renders one call's plugin entries
* with another's behaviour. Building them in place is always correct.
*/
pluginEntries?: CardPluginEntries;
/**
* Forces the flat Edit item regardless of registered plugin `edit`-group
* actions. Group `edit` is a card-only plugin contract; surfaces outside
* the card set this so they never present the submenu form.
*/
forceFlatEdit?: boolean;
};
const EMPTY_PLUGIN_MENU_CONTEXT: PluginTaskMenuContext = {
workspaceId: "",
taskId: "",
taskTitle: "",
workflowStepId: null,
presentation: "desktop",
};
export function resolvePluginMenuContext(context?: PluginTaskMenuContext): PluginTaskMenuContext {
return context ?? EMPTY_PLUGIN_MENU_CONTEXT;
}
/** The two plugin-derived pieces of a card menu: group "primary", and `Edit`. */
export type CardPluginEntries = { primary: KanbanCardMenuEntry[]; edit: KanbanCardMenuEntry };
/** The card-menu inputs that decide those two entries. */
export type PluginEntryInputs = Pick<
BuildKanbanCardMenuEntriesArgs,
| "disabled"
| "isDeleting"
| "isArchiving"
| "isDetaching"
| "onEdit"
| "forceFlatEdit"
| "pluginMenuContext"
>;
/**
* Builds both plugin-derived card menu entries. `useKanbanCardMenus` builds one
* card's dropdown and context variants in a single render and they share every
* input here, so it calls this once and passes the result to both -- otherwise
* each plugin action's `items()` runs twice per render for the same children.
*/
export function buildCardPluginEntries(args: PluginEntryInputs): CardPluginEntries {
const isProcessing = Boolean(
args.disabled || args.isDeleting || args.isArchiving || args.isDetaching,
);
const context = resolvePluginMenuContext(args.pluginMenuContext);
return {
primary: buildPrimaryPluginEntries({ disabled: isProcessing, context }),
edit: buildEditMenuEntry({
onEdit: args.onEdit,
disabled: isProcessing,
context,
forceFlat: args.forceFlatEdit,
}),
};
}
function StepBadges({ step, isCurrent }: { step: TaskMoveStep; isCurrent: boolean }) {
const { t } = useTranslation();
const hasAutoStart = stepHasAutoStart(step);
if (!isCurrent && !hasAutoStart) return null;
return (
<span className="ml-auto flex items-center gap-1 text-[10px] text-muted-foreground">
{isCurrent && (
<span data-testid={`task-context-step-current-${step.id}`}>{t("kanban:current")}</span>
)}
{hasAutoStart && (
<span data-testid={`task-context-step-autostart-${step.id}`}>{t("kanban:autoStart")}</span>
)}
</span>
);
}
function buildStepEntry(
step: TaskMoveStep,
currentStepId: string | null | undefined,
onSelect: (stepId: string) => void,
): KanbanCardMenuEntry {
const isCurrent = step.id === currentStepId;
return {
kind: "item",
key: `step-${step.id}`,
testId: `task-context-step-${step.id}`,
disabled: isCurrent,
leading: <span className={cn("block h-2 w-2 rounded-full shrink-0", step.color ?? "")} />,
label: <span className="flex-1 truncate">{step.title}</span>,
trailing: <StepBadges step={step} isCurrent={isCurrent} />,
onSelect: () => {
if (!isCurrent) onSelect(step.id);
},
};
}
function buildMoveToCurrentWorkflowSubmenu({
steps,
currentStepId,
disabled,
onMoveToStep,
}: {
steps: TaskMoveStep[];
currentStepId?: string | null;
disabled?: boolean;
onMoveToStep?: (stepId: string) => void;
}): KanbanCardMenuEntry | null {
if (!onMoveToStep || steps.length <= 1) return null;
return {
kind: "submenu",
key: "move-to",
testId: "task-context-move-to",
icon: <IconArrowRight className="mr-2 h-4 w-4" />,
label: t("kanban:moveTo"),
disabled,
className: "w-48",
children: steps.map((step) => buildStepEntry(step, currentStepId, onMoveToStep)),
};
}
/**
* Reselecting the task's current priority stays enabled and completes
* idempotently, unlike `buildStepEntry`, which disables the current step for
* a move action.
*/
function buildPriorityItemEntry(
priority: TaskPriority,
currentPriority: string | null | undefined,
onSelect: (priority: TaskPriority) => void,
): KanbanCardMenuEntry {
const isCurrent = isTaskPriority(currentPriority) && currentPriority === priority;
return {
kind: "item",
key: `priority-${priority}`,
testId: `task-context-priority-${priority}`,
label: <span className="flex-1 truncate">{t(TASK_PRIORITY_LABEL_KEYS[priority])}</span>,
trailing: isCurrent ? (
<span
data-testid={`task-context-priority-current-${priority}`}
className="ml-auto text-[10px] text-muted-foreground"
>
{t("kanban:current")}
</span>
) : undefined,
onSelect: () => onSelect(priority),
};
}
function buildPriorityMenuEntry({
currentPriority,
disabled,
onSelectPriority,
}: {
currentPriority?: string | null;
disabled?: boolean;
onSelectPriority?: (priority: TaskPriority) => void;
}): KanbanCardMenuEntry | null {
if (!onSelectPriority) return null;
return {
kind: "submenu",
key: "priority",
testId: "task-context-priority",
icon: <IconFlag className="mr-2 h-4 w-4" />,
label: t("kanban:priority"),
disabled,
className: "w-40",
children: TASK_PRIORITY_TOKENS.map((priority) =>
buildPriorityItemEntry(priority, currentPriority, onSelectPriority),
),
};
}
function buildWorkflowTargetEntry({
workflow,
steps,
disabled,
onSendToWorkflow,
}: {
workflow: TaskMoveWorkflow;
steps: TaskMoveStep[];
disabled?: boolean;
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
}): KanbanCardMenuEntry {
if (steps.length === 0 || !onSendToWorkflow) {
return {
kind: "item",
key: `workflow-${workflow.id}`,
testId: `task-context-workflow-${workflow.id}`,
disabled: true,
label: <span className="flex-1 truncate">{workflow.name}</span>,
trailing: (
<span data-testid="task-context-disabled-reason" className="ml-2 text-[10px]">
{t("kanban:noSteps")}
</span>
),
};
}
return {
kind: "submenu",
key: `workflow-${workflow.id}`,
testId: `task-context-workflow-${workflow.id}`,
label: <span className="truncate">{workflow.name}</span>,
disabled,
className: "w-48",
children: steps.map((step) =>
buildStepEntry(step, null, (stepId) => onSendToWorkflow(workflow.id, stepId)),
),
};
}
function buildSendToWorkflowSubmenu({
currentWorkflowId,
workflows,
stepsByWorkflowId,
disabled,
onSendToWorkflow,
}: {
currentWorkflowId?: string | null;
workflows: TaskMoveWorkflow[];
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
disabled?: boolean;
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
}): KanbanCardMenuEntry | null {
const targets = workflows.filter((workflow) => workflow.id !== currentWorkflowId);
if (!onSendToWorkflow || !currentWorkflowId || targets.length === 0) return null;
return {
kind: "submenu",
key: "send-to-workflow",
testId: "task-context-send-to-workflow",
icon: <IconLogicBuffer className="mr-2 h-4 w-4" />,
label: t("kanban:sendToWorkflow"),
disabled,
className: "w-56",
children: targets.map((workflow) =>
buildWorkflowTargetEntry({
workflow,
steps: stepsByWorkflowId[workflow.id] ?? [],
disabled,
onSendToWorkflow,
}),
),
};
}
export function buildKanbanCardMenuEntries({
currentWorkflowId,
currentStepId,
workflows,
stepsByWorkflowId,
moveDisabled,
disabled,
isDeleting,
isArchiving,
isDetaching,
parentTaskId,
currentPriority,
onSelectPriority,
onEdit,
onArchive,
onDelete,
onDetach,
onLinkPullRequest,
onLinkIssue,
onLinkMergeRequest,
onLinkJiraTicket,
onLinkLinearIssue,
onLinkSentryIssue,
pluginLinkActions,
onMoveToStep,
onSendToWorkflow,
pluginMenuContext,
pluginEntries,
forceFlatEdit,
}: BuildKanbanCardMenuEntriesArgs): KanbanCardMenuEntry[] {
const visibleWorkflows = workflows.filter((workflow) => !workflow.hidden);
const currentSteps = currentWorkflowId ? (stepsByWorkflowId[currentWorkflowId] ?? []) : [];
const isProcessing = Boolean(disabled || isDeleting || isArchiving || isDetaching);
const priorityEntry = buildPriorityMenuEntry({
currentPriority,
disabled: isProcessing,
onSelectPriority,
});
const moveToEntry = buildMoveToCurrentWorkflowSubmenu({
steps: currentSteps,
currentStepId,
disabled: moveDisabled || isProcessing,
onMoveToStep,
});
const sendToEntry = buildSendToWorkflowSubmenu({
currentWorkflowId,
workflows: visibleWorkflows,
stepsByWorkflowId,
disabled: moveDisabled || isProcessing,
onSendToWorkflow,
});
// `forceFlatEdit` outranks a prebuilt bundle: those surfaces must never show the submenu form.
const pluginContributions =
pluginEntries && !forceFlatEdit
? pluginEntries
: buildCardPluginEntries({
disabled: isProcessing,
onEdit,
forceFlatEdit,
pluginMenuContext,
});
const linkEntry = buildLinkSubmenu({
disabled: isProcessing,
onLinkPullRequest,
onLinkIssue,
onLinkMergeRequest,
onLinkJiraTicket,
onLinkLinearIssue,
onLinkSentryIssue,
pluginLinkActions,
});
const detachEntry = buildDetachEntry({ parentTaskId, onDetach, isDetaching, isProcessing });
return buildGroupedMenuEntries([
{ key: "priority", entries: priorityEntry ? [priorityEntry] : [] },
{
key: "edit",
entries: [pluginContributions.edit],
},
{
key: "relationships",
entries: [linkEntry, detachEntry].filter(
(entry): entry is KanbanCardMenuEntry => entry !== null,
),
},
{
key: "move",
entries: [moveToEntry, sendToEntry].filter(
(entry): entry is KanbanCardMenuEntry => entry !== null,
),
},
{ key: "plugins", entries: pluginContributions.primary },
{
key: "remove",
entries: [
buildArchiveEntry({ isArchiving, isProcessing, onArchive }),
buildDeleteEntry({ isDeleting, isProcessing, onDelete }),
],
},
]);
}
export function buildArchiveEntry({
isArchiving,
isProcessing,
onArchive,
}: {
isArchiving?: boolean;
isProcessing: boolean;
onArchive?: () => void;
}): KanbanCardMenuEntry {
return {
kind: "item",
key: "archive",
icon: isArchiving ? (
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
) : (
<IconArchive className="mr-2 h-4 w-4" />
),
label: t("kanban:archive"),
disabled: isProcessing || !onArchive,
onSelect: onArchive,
};
}
export function buildDeleteEntry({
isDeleting,
isProcessing,
onDelete,
}: {
isDeleting?: boolean;
isProcessing: boolean;
onDelete?: () => void;
}): KanbanCardMenuEntry {
return {
kind: "item",
key: "delete",
icon: isDeleting ? (
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
) : (
<IconTrash className="mr-2 h-4 w-4" />
),
label: t("kanban:delete"),
destructive: true,
disabled: isProcessing || !onDelete,
onSelect: onDelete,
};
}
function buildDetachEntry({
parentTaskId,
onDetach,
isDetaching,
isProcessing,
}: Pick<BuildKanbanCardMenuEntriesArgs, "parentTaskId" | "onDetach" | "isDetaching"> & {
isProcessing: boolean;
}): KanbanCardMenuEntry | null {
if (!parentTaskId || !onDetach) return null;
return {
kind: "item",
key: "detach",
testId: "task-context-detach",
icon: isDetaching ? (
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
) : (
<IconUnlink className="mr-2 h-4 w-4" />
),
label: t("kanban:detachFromParent"),
disabled: isProcessing,
onSelect: onDetach,
};
}
function ContextEntry({ entry }: { entry: KanbanCardMenuEntry }) {
if (entry.kind === "separator") return <ContextMenuSeparator />;
if (entry.kind === "submenu") {
return (
<ContextMenuSub>
<ContextMenuSubTrigger data-testid={entry.testId} disabled={entry.disabled}>
{entry.icon}
{entry.label}
</ContextMenuSubTrigger>
<ContextMenuSubContent className={entry.className}>
{entry.children.map((child) => (
<ContextEntry key={child.key} entry={child} />
))}
</ContextMenuSubContent>
</ContextMenuSub>
);
}
return (
<ContextMenuItem
data-testid={entry.testId}
disabled={entry.disabled}
className={entry.destructive ? "text-destructive focus:text-destructive" : undefined}
// React events bubble through the React tree even from a portal — stop here so the card's onClick doesn't navigate.
onClick={(event) => event.stopPropagation()}
onSelect={() => {
if (!entry.disabled) entry.onSelect?.();
}}
>
{entry.icon}
{entry.leading}
{entry.label}
{entry.trailing}
</ContextMenuItem>
);
}
function DropdownEntry({ entry }: { entry: KanbanCardMenuEntry }) {
if (entry.kind === "separator") return <DropdownMenuSeparator />;
if (entry.kind === "submenu") {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger
data-testid={entry.testId}
disabled={entry.disabled}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
{entry.icon}
{entry.label}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className={entry.className}>
{entry.children.map((child) => (
<DropdownEntry key={child.key} entry={child} />
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
);
}
return (
<DropdownMenuItem
data-testid={entry.testId}
disabled={entry.disabled}
className={entry.destructive ? "text-destructive focus:text-destructive" : undefined}
// React events bubble through the React tree even from a portal - stop here so click/pointer don't reach the parent Card's onClick or dnd-kit listeners.
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onSelect={(event) => {
event.stopPropagation();
if (!entry.disabled) entry.onSelect?.();
}}
>
{entry.icon}
{entry.leading}
{entry.label}
{entry.trailing}
</DropdownMenuItem>
);
}
export function KanbanCardContextMenuItems({ entries }: { entries: KanbanCardMenuEntry[] }) {
return (
<>
{entries.map((entry) => (
<ContextEntry key={entry.key} entry={entry} />
))}
</>
);
}
export function KanbanCardDropdownMenuItems({ entries }: { entries: KanbanCardMenuEntry[] }) {
return (
<>
{entries.map((entry) => (
<DropdownEntry key={entry.key} entry={entry} />
))}
</>
);
}