Files
kandev/apps/web/components/azure-devops/azure-devops-board.tsx
T
2026-09-10 18:14:05 +01:00

434 lines
15 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
import { IconChevronLeft, IconChevronRight, IconGripVertical, IconList } from "@tabler/icons-react";
import { Alert, AlertDescription } from "@kandev/ui/alert";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { useAzureDevOpsBoard } from "@/hooks/domains/azure-devops/use-azure-devops-board";
import type { AzureDevOpsBoardPreference } from "@/hooks/domains/azure-devops/use-azure-devops-preferences";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type {
AzureDevOpsActionPreset,
AzureDevOpsBoard,
AzureDevOpsBoardWorkItem,
AzureDevOpsProject,
AzureDevOpsWorkItem,
} from "@/lib/types/azure-devops";
import { groupAzureDevOpsBoardItems } from "./azure-devops-board-view";
import { AzureDevOpsWorkItemDetail } from "./azure-devops-work-item-detail";
import { useTranslation } from "react-i18next";
type Props = {
workspaceId?: string;
projectId: string;
projects: AzureDevOpsProject[];
onProjectChange: (projectId: string) => void;
initialPreference?: AzureDevOpsBoardPreference;
onPreferenceChange: (preference: AzureDevOpsBoardPreference) => void;
quickActions?: AzureDevOpsActionPreset[];
onStartTask?: (item: AzureDevOpsWorkItem, action?: AzureDevOpsActionPreset) => void;
};
function BoardCard({
item,
onOpen,
onDragStart,
}: {
item: AzureDevOpsBoardWorkItem;
onOpen: () => void;
onDragStart: () => void;
}) {
const { t } = useTranslation();
const pointerStart = useRef<{ x: number; y: number } | null>(null);
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
pointerStart.current = { x: event.clientX, y: event.clientY };
};
const handlePointerUp = (event: PointerEvent<HTMLDivElement>) => {
const start = pointerStart.current;
pointerStart.current = null;
if (!start || Math.hypot(event.clientX - start.x, event.clientY - start.y) < 6) onOpen();
};
return (
<Card
draggable
role="button"
tabIndex={0}
onDragStart={onDragStart}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen();
}
}}
className="cursor-pointer gap-2 py-3 transition hover:border-primary/50"
data-testid={`azure-board-card-${item.id}`}
>
<CardHeader className="px-3 py-0">
<CardTitle className="flex items-start gap-2 text-sm font-medium">
<IconGripVertical className="mt-0.5 hidden h-4 w-4 shrink-0 text-muted-foreground md:block" />
<span className="line-clamp-2">{item.title || t("azuredevops:untitledWorkItem")}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 px-3 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="outline">#{item.id}</Badge>
{item.type && <Badge variant="secondary">{item.type}</Badge>}
{item.assignedTo && <span className="truncate">{item.assignedTo}</span>}
</div>
{!!item.tags?.length && <div className="truncate">{item.tags.join(" · ")}</div>}
</CardContent>
</Card>
);
}
function MobileColumnNavigator({
columns,
selectedColumn,
counts,
onChange,
}: {
columns: AzureDevOpsBoard["columns"];
selectedColumn: string;
counts: Map<string, AzureDevOpsBoardWorkItem[]>;
onChange: (columnId: string) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const index = Math.max(
0,
columns.findIndex((column) => column.id === selectedColumn),
);
const column = columns[index];
const select = (columnId: string) => {
onChange(columnId);
setOpen(false);
};
return (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
className="cursor-pointer"
aria-label={t("azuredevops:previousBoardColumn")}
disabled={index === 0}
onClick={() => onChange(columns[index - 1].id)}
>
<IconChevronLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
className="flex-1 cursor-pointer justify-between"
data-testid="azure-board-column-picker"
onClick={() => setOpen(true)}
>
<span>{column?.name ?? t("azuredevops:boardColumn")}</span>
<span>{counts.get(column?.id ?? "")?.length ?? 0}</span>
<IconList className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="cursor-pointer"
aria-label={t("azuredevops:nextBoardColumn")}
disabled={index >= columns.length - 1}
onClick={() => onChange(columns[index + 1].id)}
>
<IconChevronRight className="h-4 w-4" />
</Button>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="max-h-[min(32rem,calc(100dvh-16px-env(safe-area-inset-bottom,0px)))]">
<DrawerHeader>
<DrawerTitle>{t("azuredevops:boardColumns")}</DrawerTitle>
</DrawerHeader>
<div className="min-h-0 overflow-y-auto p-4 pb-[env(safe-area-inset-bottom,0px)]">
{columns.map((candidate) => (
<Button
key={candidate.id}
type="button"
variant={candidate.id === selectedColumn ? "default" : "outline"}
className="mb-2 min-h-11 w-full cursor-pointer justify-between"
data-testid={`azure-board-column-option-${candidate.id}`}
onClick={() => select(candidate.id)}
>
<span>{candidate.name}</span>
<Badge variant="secondary">{counts.get(candidate.id)?.length ?? 0}</Badge>
</Button>
))}
</div>
</DrawerContent>
</Drawer>
</div>
);
}
function BoardSelectors({
projectId,
projects,
onProjectChange,
teamId,
teams,
onTeamChange,
boardId,
boards,
onBoardChange,
}: {
projectId: string;
projects: AzureDevOpsProject[];
onProjectChange: (value: string) => void;
teamId: string;
teams: Array<{ id: string; name: string }>;
onTeamChange: (value: string) => void;
boardId: string;
boards: Array<{ id: string; name: string }>;
onBoardChange: (value: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-2">
<Select value={projectId} onValueChange={onProjectChange}>
<SelectTrigger className="w-52" data-testid="azure-board-project-select">
<SelectValue placeholder={t("azuredevops:project")} />
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={teamId} onValueChange={onTeamChange} disabled={!teams.length}>
<SelectTrigger className="w-52" data-testid="azure-board-team-select">
<SelectValue placeholder={t("azuredevops:team")} />
</SelectTrigger>
<SelectContent>
{teams.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={boardId} onValueChange={onBoardChange} disabled={!boards.length}>
<SelectTrigger className="w-52" data-testid="azure-board-select">
<SelectValue placeholder={t("azuredevops:board")} />
</SelectTrigger>
<SelectContent>
{boards.map((board) => (
<SelectItem key={board.id} value={board.id}>
{board.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function preferredColumn(
columns: AzureDevOpsBoard["columns"],
selectedColumn: string,
initialPreference: AzureDevOpsBoardPreference | undefined,
): string {
if (columns.some((column) => column.id === selectedColumn)) return selectedColumn;
if (columns.some((column) => column.id === initialPreference?.focusedColumnId)) {
return initialPreference?.focusedColumnId ?? "";
}
return columns[0]?.id ?? "";
}
function useBoardPreference(
board: ReturnType<typeof useAzureDevOpsBoard>,
columns: AzureDevOpsBoard["columns"],
initialPreference: AzureDevOpsBoardPreference | undefined,
onPreferenceChange: (preference: AzureDevOpsBoardPreference) => void,
) {
const [selectedColumn, setSelectedColumn] = useState("");
const effectiveColumn = preferredColumn(columns, selectedColumn, initialPreference);
useEffect(() => {
if (effectiveColumn !== selectedColumn) setSelectedColumn(effectiveColumn);
}, [effectiveColumn, selectedColumn]);
useEffect(() => {
if (!board.teamId || !board.boardId || !effectiveColumn) return;
onPreferenceChange({
teamId: board.teamId,
boardId: board.boardId,
focusedColumnId: effectiveColumn,
});
}, [board.boardId, board.teamId, effectiveColumn, onPreferenceChange]);
return { effectiveColumn, setSelectedColumn };
}
function useBoardGroups(snapshot: ReturnType<typeof useAzureDevOpsBoard>["snapshot"]) {
return useMemo(
() =>
snapshot
? groupAzureDevOpsBoardItems(snapshot.board, snapshot.items)
: new Map<string, AzureDevOpsBoardWorkItem[]>(),
[snapshot],
);
}
function useBoardPresentation(
board: ReturnType<typeof useAzureDevOpsBoard>,
initialPreference: AzureDevOpsBoardPreference | undefined,
onPreferenceChange: (preference: AzureDevOpsBoardPreference) => void,
) {
const groups = useBoardGroups(board.snapshot);
const columns = board.snapshot?.board.columns ?? [];
const columnPreference = useBoardPreference(
board,
columns,
initialPreference,
onPreferenceChange,
);
return { groups, columns, columnPreference };
}
function BoardEmptyState() {
const { t } = useTranslation();
return (
<div className="p-6 text-sm text-muted-foreground">
{t("azuredevops:selectAProjectToViewIts")}
</div>
);
}
// eslint-disable-next-line max-lines-per-function -- board rendering coordinates drag/drop, responsive columns, and detail state.
export function AzureDevOpsBoard({
workspaceId,
projectId,
projects,
onProjectChange,
initialPreference,
onPreferenceChange,
quickActions = [],
onStartTask,
}: Props) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const board = useAzureDevOpsBoard(workspaceId, projectId, initialPreference);
const [editing, setEditing] = useState<AzureDevOpsBoardWorkItem | null>(null);
const [dragged, setDragged] = useState<AzureDevOpsBoardWorkItem | null>(null);
const presentation = useBoardPresentation(board, initialPreference, onPreferenceChange);
if (!projectId) return <BoardEmptyState />;
return (
<section
className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden p-4"
data-testid="azure-devops-board"
>
<BoardSelectors
projectId={projectId}
projects={projects}
onProjectChange={onProjectChange}
teamId={board.teamId}
teams={board.teams}
onTeamChange={board.setTeamId}
boardId={board.boardId}
boards={board.boards}
onBoardChange={board.setBoardId}
/>
{board.error && (
<Alert variant="destructive">
<AlertDescription>{board.error}</AlertDescription>
</Alert>
)}
{board.loading && (
<div className="p-6 text-sm text-muted-foreground">{t("azuredevops:loadingBoard")}</div>
)}
{!board.loading && board.snapshot && (
<>
{isMobile && (
<MobileColumnNavigator
columns={presentation.columns}
selectedColumn={presentation.columnPreference.effectiveColumn}
counts={presentation.groups}
onChange={presentation.columnPreference.setSelectedColumn}
/>
)}
<div
className={
isMobile
? "min-h-0 flex-1 overflow-y-auto"
: "grid min-h-0 flex-1 auto-cols-fr grid-flow-col gap-3 overflow-x-auto"
}
>
{presentation.columns
.filter(
(column) =>
!isMobile || column.id === presentation.columnPreference.effectiveColumn,
)
.map((column) => (
<div
key={column.id}
data-testid={`azure-board-column-${column.id}`}
className="flex min-w-72 flex-col gap-2 rounded-lg bg-muted/40 p-2"
onDragOver={(event) => event.preventDefault()}
onDrop={() => {
if (dragged) void board.moveItem(dragged, column.id);
setDragged(null);
}}
>
<div className="flex items-center justify-between px-1 text-sm font-semibold">
<span>{column.name}</span>
<Badge variant="secondary">
{presentation.groups.get(column.id)?.length ?? 0}
</Badge>
</div>
<div className="space-y-2">
{(presentation.groups.get(column.id) ?? []).map((item) => (
<BoardCard
key={item.id}
item={item}
onOpen={() => setEditing(item)}
onDragStart={() => setDragged(item)}
/>
))}
</div>
</div>
))}
</div>
<AzureDevOpsWorkItemDetail
open={!!editing}
workspaceId={workspaceId}
projectId={projectId}
initialItem={editing}
onOpenChange={(open) => !open && setEditing(null)}
boardContext={
editing
? {
board: board.snapshot.board,
item: editing,
onMove: board.moveItem,
onItemUpdated: (item) => {
board.mergeItem(item);
setEditing(item);
if (isMobile) {
presentation.columnPreference.setSelectedColumn(item.columnId);
}
},
}
: undefined
}
quickActions={quickActions}
onStartTask={onStartTask}
/>
</>
)}
</section>
);
}