Files
kandev/apps/web/components/task-create-dialog-dependencies.tsx
2026-09-04 02:35:15 +00:00

452 lines
15 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
IconCheck,
IconChevronDown,
IconInfoCircle,
IconListCheck,
IconLock,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@kandev/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr";
import { useWorkspaceMRs } from "@/hooks/domains/gitlab/use-task-mr";
import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container";
import type { TaskDependencyCandidate } from "@/lib/types/task-dependencies";
import type { KanbanState } from "@/lib/state/slices/kanban/types";
import type { TaskMR } from "@/lib/types/gitlab";
import type { TaskPR } from "@/lib/types/github";
import { cn } from "@/lib/utils";
import {
changeRequestNumbers,
compareDependencyCandidates,
dependencyOptionValue,
} from "./task-create-dialog-dependency-search";
// Keep stable empty fallbacks here. Creating a fresh array/object in a store
// selector makes Object.is report a change on every read and can cause a
// render loop, which previously blanked the dependency chip.
const NO_TASKS: KanbanState["tasks"] = [];
const NO_SNAPSHOTS: Record<string, { tasks?: KanbanState["tasks"] }> = {};
const NO_MRS: TaskMR[] = [];
const NO_MRS_BY_TASK_ID: Record<string, TaskMR[]> = {};
const NO_PRS: TaskPR[] = [];
const NO_PRS_BY_TASK_ID: Record<string, TaskPR[]> = {};
export type TaskCreateDependenciesProps = {
/** Selected predecessor task IDs. */
value: string[];
onChange: (next: string[]) => void;
disabled?: boolean;
/** Optional server-backed candidates for the edit dialog. */
candidates?: TaskDependencyCandidate[];
/** Titles for selected tasks that are outside the current search page. */
selectedTitles?: Record<string, string>;
/** True while the server-backed candidate search is running. */
candidatesLoading?: boolean;
/** Controlled search value for the server-backed candidate search. */
searchValue?: string;
onSearchValueChange?: (value: string) => void;
/** The edited task is never a valid predecessor of itself. */
excludeTaskId?: string;
};
function useBoardTasks(): KanbanState["tasks"] {
const boardTasks = useAppStore((state) => state.kanban?.tasks ?? NO_TASKS);
const snapshots = useAppStore((state) => state.kanbanMulti?.snapshots ?? NO_SNAPSHOTS);
// The home route hydrates its swimlanes into kanbanMulti.snapshots and can
// leave kanban.tasks empty, so both slices must supply picker candidates.
return useMemo(() => {
const byId = new Map<string, KanbanState["tasks"][number]>();
for (const task of boardTasks) byId.set(task.id, task);
for (const snapshot of Object.values(snapshots)) {
for (const task of snapshot.tasks ?? []) {
if (!byId.has(task.id)) byId.set(task.id, task);
}
}
return [...byId.values()];
}, [boardTasks, snapshots]);
}
function useMRsByTaskId(): Record<string, TaskMR[]> {
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
const byWorkspaceId = useAppStore((state) => state.taskMRs.byWorkspaceId);
return (activeWorkspaceId && byWorkspaceId[activeWorkspaceId]) || NO_MRS_BY_TASK_ID;
}
function usePRsByTaskId(): Record<string, TaskPR[]> {
return useAppStore((state) => {
const activeWorkspaceId = state.workspaces.activeId;
if (
!activeWorkspaceId ||
state.taskPRs.workspaceId !== activeWorkspaceId ||
state.taskPRs.workspaceContextGeneration !== state.workspaceContextGeneration
) {
return NO_PRS_BY_TASK_ID;
}
return state.taskPRs.byTaskId;
});
}
function DependencyTriggerLabel({
value,
selectedTitle,
selectedNumber,
t,
}: {
value: string[];
selectedTitle?: string;
selectedNumber?: number;
t: (key: string, options?: { count?: number; number?: number }) => string;
}) {
if (value.length === 0) return t("task:noDependency");
if (value.length === 1 && selectedTitle) {
if (selectedNumber === undefined) return selectedTitle;
return `${t("task:dependencyChangeRequestLabel", { number: selectedNumber })} · ${selectedTitle}`;
}
return t("task:dependencyCount", { count: value.length });
}
function DependencyOption({
task,
numbers,
selected,
onSelect,
}: {
task: TaskDependencyCandidate;
numbers: number[];
selected: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
return (
<CommandItem
value={dependencyOptionValue(task, numbers)}
onSelect={onSelect}
className="min-h-12 cursor-pointer gap-2 md:min-h-11"
data-testid={`task-create-dependency-option-${task.id}`}
aria-selected={selected}
>
<IconListCheck
className="h-4 w-4 shrink-0 text-muted-foreground"
data-testid="task-create-dependency-task-icon"
aria-hidden="true"
/>
<span className="min-w-0 flex-1 line-clamp-2">{task.title}</span>
{numbers.map((number) => (
<span
key={number}
className="shrink-0 text-xs text-muted-foreground"
aria-label={t("task:dependencyChangeRequestAria", { number })}
>
{t("task:dependencyChangeRequestLabel", { number })}
</span>
))}
<IconCheck
className={cn("h-4 w-4 shrink-0", selected ? "opacity-100" : "opacity-0")}
aria-hidden="true"
/>
</CommandItem>
);
}
type DependencyPickerContentProps = {
candidates: TaskDependencyCandidate[];
numbersByTaskId: ReadonlyMap<string, number[]>;
value: string[];
onChange: (next: string[]) => void;
setOpen: (open: boolean) => void;
portalContainer: HTMLElement | null;
candidatesLoading: boolean;
searchValue?: string;
onSearchValueChange?: (value: string) => void;
};
function DependencyPickerContent({
candidates,
numbersByTaskId,
value,
onChange,
setOpen,
portalContainer,
candidatesLoading,
searchValue,
onSearchValueChange,
}: DependencyPickerContentProps) {
const { t } = useTranslation();
const commandInputProps =
searchValue === undefined
? {}
: { value: searchValue, onValueChange: onSearchValueChange ?? (() => undefined) };
return (
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-[min(420px,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] p-0"
data-testid="task-create-dependencies-popover"
portalContainer={portalContainer}
onWheel={(event) => event.stopPropagation()}
>
<Command>
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1">
<CommandInput
{...commandInputProps}
placeholder={t(
searchValue === undefined ? "task:searchTasksOrChangeRequest" : "task:searchTasks",
)}
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="mr-1 h-12 w-12 min-h-12 min-w-12 cursor-pointer md:h-9 md:w-9 md:min-h-9 md:min-w-9"
aria-label={t("task:dependencyInfoLabel")}
data-testid="task-create-dependency-info"
>
<IconInfoCircle className="h-4 w-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="z-[60] max-w-xs">
{t("task:dependencyInfo")}
</TooltipContent>
</Tooltip>
</div>
<CommandList>
<CommandEmpty>
{candidatesLoading ? t("tasks:loadingTasks") : t("task:noTasksFound")}
</CommandEmpty>
<CommandGroup>
<CommandItem
value="__no_dependency__"
forceMount
onSelect={() => {
onChange([]);
setOpen(false);
}}
className="min-h-12 cursor-pointer gap-2 md:min-h-11"
data-testid="task-create-no-dependency"
aria-selected={value.length === 0}
>
<IconLock className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="flex-1">{t("task:noDependency")}</span>
<IconCheck
className={cn("h-4 w-4 shrink-0", value.length === 0 ? "opacity-100" : "opacity-0")}
aria-hidden="true"
/>
</CommandItem>
{candidates.map((task) => (
<DependencyOption
key={task.id}
task={task}
numbers={numbersByTaskId.get(task.id) ?? []}
selected={value.includes(task.id)}
onSelect={() => {
onChange(
value.includes(task.id)
? value.filter((id) => id !== task.id)
: [...value, task.id],
);
}}
/>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
);
}
type DependencyPickerDataArgs = {
boardTasks: KanbanState["tasks"];
providedCandidates?: TaskDependencyCandidate[];
selectedTitles?: Record<string, string>;
value: string[];
excludeTaskId?: string;
mrsByTaskId: Record<string, TaskMR[]>;
prsByTaskId: Record<string, TaskPR[]>;
};
type DependencyPickerData = {
candidates: TaskDependencyCandidate[];
numbersByTaskId: ReadonlyMap<string, number[]>;
selectedTitle?: string;
selectedNumber?: number;
};
function useDependencyPickerData({
boardTasks,
providedCandidates,
selectedTitles,
value,
excludeTaskId,
mrsByTaskId,
prsByTaskId,
}: DependencyPickerDataArgs): DependencyPickerData {
const tasks = useMemo(() => {
const byID = new Map<string, TaskDependencyCandidate>();
for (const task of providedCandidates ?? boardTasks) byID.set(task.id, task);
for (const id of value) {
const title = selectedTitles?.[id];
if (title !== undefined && !byID.has(id)) {
byID.set(id, { id, title, isArchived: true });
}
}
return [...byID.values()];
}, [boardTasks, providedCandidates, selectedTitles, value]);
const boardTasksById = useMemo(
() => new Map(boardTasks.map((task) => [task.id, task] as const)),
[boardTasks],
);
const numbersByTaskId = useMemo(
() =>
new Map(
tasks.map((task) => {
const boardTask = boardTasksById.get(task.id);
return [
task.id,
boardTask
? changeRequestNumbers(
boardTask,
mrsByTaskId[task.id] ?? NO_MRS,
prsByTaskId[task.id] ?? NO_PRS,
)
: [],
] as const;
}),
),
[boardTasksById, mrsByTaskId, prsByTaskId, tasks],
);
const selectedTask = useMemo(() => {
if (value.length !== 1) return undefined;
return tasks.find((task) => task.id === value[0]);
}, [tasks, value]);
const selectedTitle = useMemo(() => {
if (!selectedTask) return undefined;
return selectedTitles?.[selectedTask.id] ?? selectedTask.title;
}, [selectedTask, selectedTitles]);
const selectedNumber = useMemo(
() => (selectedTask ? numbersByTaskId.get(selectedTask.id)?.[0] : undefined),
[numbersByTaskId, selectedTask],
);
const candidates = useMemo(
() =>
tasks
.filter(
(task) => task.id !== excludeTaskId && (!task.isArchived || value.includes(task.id)),
)
.sort((left, right) => {
const leftTask = boardTasksById.get(left.id) ?? left;
const rightTask = boardTasksById.get(right.id) ?? right;
return compareDependencyCandidates(leftTask, rightTask);
}),
[boardTasksById, excludeTaskId, tasks, value],
);
return { candidates, numbersByTaskId, selectedTitle, selectedNumber };
}
export function TaskCreateDependencies({
value,
onChange,
disabled,
candidates: providedCandidates,
selectedTitles,
candidatesLoading = false,
searchValue,
onSearchValueChange,
excludeTaskId,
}: TaskCreateDependenciesProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const portalContainer = useTaskCreateDialogPopoverContainer();
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
// The global new-task dialog is available outside the Kanban and Tasks
// routes, which normally hydrate these association stores.
useWorkspacePRs(activeWorkspaceId);
useWorkspaceMRs(activeWorkspaceId);
const boardTasks = useBoardTasks();
const mrsByTaskId = useMRsByTaskId();
const prsByTaskId = usePRsByTaskId();
const { candidates, numbersByTaskId, selectedTitle, selectedNumber } = useDependencyPickerData({
boardTasks,
providedCandidates,
selectedTitles,
value,
excludeTaskId,
mrsByTaskId,
prsByTaskId,
});
const triggerLabel = (
<DependencyTriggerLabel
value={value}
selectedTitle={selectedTitle}
selectedNumber={selectedNumber}
t={t}
/>
);
return (
<div className="min-w-0" data-testid="task-create-dependencies">
<Popover
open={open}
onOpenChange={(next) => {
if (!disabled) setOpen(next);
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
aria-label={t("task:dependsOn")}
aria-expanded={open}
className={cn(
"h-12 min-h-12 w-full min-w-0 justify-between md:h-7 md:min-h-7",
!disabled && "cursor-pointer",
)}
disabled={disabled}
data-testid="task-create-dependencies-trigger"
>
<span className="flex min-w-0 flex-1 items-center gap-2 text-left">
<IconLock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className={cn("truncate", value.length === 0 && "text-muted-foreground")}>
{triggerLabel}
</span>
</span>
<IconChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden="true" />
</Button>
</PopoverTrigger>
<DependencyPickerContent
candidates={candidates}
numbersByTaskId={numbersByTaskId}
value={value}
onChange={onChange}
setOpen={setOpen}
portalContainer={portalContainer}
candidatesLoading={candidatesLoading}
searchValue={searchValue}
onSearchValueChange={onSearchValueChange}
/>
</Popover>
</div>
);
}