165 lines
5.3 KiB
TypeScript
165 lines
5.3 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { IconChecklist } from "@tabler/icons-react";
|
|
import { Badge } from "@kandev/ui/badge";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@kandev/ui/dropdown-menu";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id";
|
|
import { linkToTask } from "@/lib/links";
|
|
import { useRouter } from "@/lib/routing/client-router";
|
|
import type { KanbanState } from "@/lib/state/slices";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type TaskRowLink = {
|
|
id: string;
|
|
taskId: string;
|
|
fallbackTitle: string;
|
|
};
|
|
|
|
export type TaskRowIndicatorProps = {
|
|
tasks: TaskRowLink[] | undefined;
|
|
testIdPrefix: string;
|
|
emptyLabel?: string;
|
|
};
|
|
|
|
function useTaskStepTitle(workflowStepId: string | undefined): string | null {
|
|
return useAppStore((state) => {
|
|
if (!workflowStepId) return null;
|
|
const findIn = (steps: KanbanState["steps"]) =>
|
|
steps.find((step) => step.id === workflowStepId)?.title ?? null;
|
|
const activeTitle = findIn(state.kanban.steps);
|
|
if (activeTitle) return activeTitle;
|
|
for (const snapshot of Object.values(state.kanbanMulti.snapshots)) {
|
|
const title = findIn(snapshot.steps);
|
|
if (title) return title;
|
|
}
|
|
return null;
|
|
});
|
|
}
|
|
|
|
function TaskTitle({ task }: { task: TaskRowLink }) {
|
|
const taskData = useTaskById(task.taskId);
|
|
const title = taskData?.title ?? task.fallbackTitle;
|
|
return (
|
|
<span className="truncate text-foreground/80" title={title}>
|
|
{title}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const buttonClass = cn(
|
|
"inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs",
|
|
"hover:bg-muted/70 transition-colors cursor-pointer w-fit max-w-full",
|
|
"min-h-11 min-w-11 text-left md:min-h-0 md:min-w-0 [@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
);
|
|
const iconClass = "h-3 w-3 shrink-0 text-muted-foreground";
|
|
|
|
function SingleTaskButton({
|
|
task,
|
|
testIdPrefix,
|
|
onNavigate,
|
|
}: {
|
|
task: TaskRowLink;
|
|
testIdPrefix: string;
|
|
onNavigate: (taskId: string) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const taskData = useTaskById(task.taskId);
|
|
const stepTitle = useTaskStepTitle(taskData?.workflowStepId);
|
|
const title = taskData?.title ?? task.fallbackTitle;
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
data-testid={`${testIdPrefix}-single`}
|
|
onClick={() => onNavigate(task.taskId)}
|
|
className={buttonClass}
|
|
>
|
|
<IconChecklist className={iconClass} />
|
|
<span className="min-w-0">
|
|
<span className="block wrap-anywhere text-foreground/80 md:truncate [@media(pointer:coarse)]:whitespace-normal">
|
|
{title}
|
|
</span>
|
|
{stepTitle && (
|
|
<span
|
|
data-testid="task-row-step-title"
|
|
className="block wrap-anywhere text-muted-foreground md:hidden [@media(pointer:coarse)]:block"
|
|
>
|
|
{t("github:stepLabelled", { stepTitle })}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<div className="flex flex-col gap-0.5 text-xs">
|
|
<span>{t("github:taskLabelled", { title })}</span>
|
|
{stepTitle ? (
|
|
<span className="text-muted-foreground">{t("github:stepLabelled", { stepTitle })}</span>
|
|
) : null}
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
export function TaskRowIndicator({ tasks, testIdPrefix, emptyLabel }: TaskRowIndicatorProps) {
|
|
const { t } = useTranslation();
|
|
const setActiveTask = useAppStore((state) => state.setActiveTask);
|
|
const router = useRouter();
|
|
const navigate = useCallback(
|
|
(taskId: string) => {
|
|
setActiveTask(taskId);
|
|
router.push(linkToTask(taskId));
|
|
},
|
|
[router, setActiveTask],
|
|
);
|
|
|
|
if (!tasks || tasks.length === 0) {
|
|
return emptyLabel ? (
|
|
<span
|
|
data-testid={`${testIdPrefix}-empty`}
|
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
|
|
>
|
|
{emptyLabel}
|
|
</span>
|
|
) : null;
|
|
}
|
|
if (tasks.length === 1) {
|
|
return <SingleTaskButton task={tasks[0]} testIdPrefix={testIdPrefix} onNavigate={navigate} />;
|
|
}
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button type="button" data-testid={`${testIdPrefix}-multi`} className={buttonClass}>
|
|
<IconChecklist className={iconClass} />
|
|
<span className="text-foreground/80">{t("github:tasks")}</span>
|
|
<Badge variant="outline" className="h-4 shrink-0 px-1 py-0 text-[10px]">
|
|
{tasks.length}
|
|
</Badge>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-56">
|
|
{tasks.map((task) => (
|
|
<DropdownMenuItem
|
|
key={task.id}
|
|
className="cursor-pointer"
|
|
onSelect={() => navigate(task.taskId)}
|
|
>
|
|
<TaskTitle task={task} />
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|