153 lines
5.5 KiB
TypeScript
153 lines
5.5 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo, useState } from 'react';
|
|
import { IconChevronRight } from '@tabler/icons-react';
|
|
import type { TaskState } from '@/lib/types/http';
|
|
import {
|
|
Item,
|
|
ItemActions,
|
|
ItemContent,
|
|
ItemDescription,
|
|
ItemGroup,
|
|
ItemTitle,
|
|
} from '@kandev/ui/item';
|
|
import { truncateRepoPath } from '@/lib/utils';
|
|
import { TaskStateActions } from './task-state-actions';
|
|
|
|
type TaskSwitcherItem = {
|
|
id: string;
|
|
title: string;
|
|
state?: TaskState;
|
|
description?: string;
|
|
columnId?: string;
|
|
repositoryPath?: string;
|
|
};
|
|
|
|
type TaskSwitcherProps = {
|
|
tasks: TaskSwitcherItem[];
|
|
columns: Array<{ id: string; title: string }>;
|
|
activeTaskId: string | null;
|
|
selectedTaskId: string | null;
|
|
onSelectTask: (taskId: string) => void;
|
|
};
|
|
|
|
export function TaskSwitcher({
|
|
tasks,
|
|
columns,
|
|
activeTaskId,
|
|
selectedTaskId,
|
|
onSelectTask,
|
|
}: TaskSwitcherProps) {
|
|
const [collapsedColumnIds, setCollapsedColumnIds] = useState<Set<string>>(() => new Set());
|
|
|
|
const sortedTasks = useMemo(() => {
|
|
return [...tasks].sort((a, b) => a.title.localeCompare(b.title));
|
|
}, [tasks]);
|
|
|
|
const tasksByColumn = useMemo(() => {
|
|
const grouped: Record<string, TaskSwitcherItem[]> = {};
|
|
for (const task of sortedTasks) {
|
|
const key = task.columnId ?? 'unknown';
|
|
if (!grouped[key]) grouped[key] = [];
|
|
grouped[key].push(task);
|
|
}
|
|
return grouped;
|
|
}, [sortedTasks]);
|
|
|
|
const columnsWithFallback = useMemo(() => {
|
|
const seen = new Set(columns.map((column) => column.id));
|
|
const extra: Array<{ id: string; title: string }> = [];
|
|
if (tasksByColumn.unknown?.length) {
|
|
extra.push({ id: 'unknown', title: 'Other' });
|
|
}
|
|
return [...columns, ...extra].filter((column) => {
|
|
if (seen.has(column.id)) return true;
|
|
seen.add(column.id);
|
|
return true;
|
|
});
|
|
}, [columns, tasksByColumn]);
|
|
|
|
const handleToggleColumn = (columnId: string) => {
|
|
setCollapsedColumnIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(columnId)) next.delete(columnId);
|
|
else next.add(columnId);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<span className="text-xs uppercase tracking-wide text-muted-foreground">Tasks</span>
|
|
{sortedTasks.length === 0 ? (
|
|
<div className="text-xs text-muted-foreground">No tasks on this board.</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{columnsWithFallback.map((column) => {
|
|
const columnTasks = tasksByColumn[column.id] ?? [];
|
|
if (columnTasks.length === 0) return null;
|
|
const isColumnOpen = !collapsedColumnIds.has(column.id);
|
|
return (
|
|
<div key={column.id} className="space-y-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleToggleColumn(column.id)}
|
|
className="group flex w-full items-center justify-between cursor-pointer"
|
|
aria-label="Toggle column"
|
|
>
|
|
<span className="truncate text-sm font-medium text-muted-foreground">{column.title}</span>
|
|
<IconChevronRight className={`h-4 w-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity ${isColumnOpen ? 'rotate-90' : ''}`} />
|
|
</button>
|
|
{isColumnOpen && (
|
|
<div className="flex w-full max-w-md flex-col gap-6">
|
|
<ItemGroup className="gap-2">
|
|
{columnTasks.map((task) => {
|
|
const isActive = task.id === activeTaskId;
|
|
const isSelected = task.id === selectedTaskId || isActive;
|
|
const repoLabel = task.repositoryPath ? truncateRepoPath(task.repositoryPath) : 'No repository';
|
|
return (
|
|
<Item
|
|
key={task.id}
|
|
role="listitem"
|
|
variant="outline"
|
|
asChild
|
|
size="xs"
|
|
className={isSelected ? 'bg-primary/10 border-primary/30 hover:bg-transparent' : ''}
|
|
>
|
|
<a
|
|
href="#"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
onSelectTask(task.id);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
onSelectTask(task.id);
|
|
}
|
|
}}
|
|
className="flex w-full items-center gap-2"
|
|
>
|
|
<ItemContent className="min-w-0">
|
|
<ItemTitle className="text-sm text-foreground">{task.title}</ItemTitle>
|
|
<ItemDescription className="truncate">{repoLabel}</ItemDescription>
|
|
</ItemContent>
|
|
<ItemActions>
|
|
<TaskStateActions state={task.state} />
|
|
</ItemActions>
|
|
</a>
|
|
</Item>
|
|
);
|
|
})}
|
|
</ItemGroup>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|