auto-claude: 153-widen-kanban-columns-and-add-collapse-feature (#1457)
* auto-claude: subtask-1-1 - Create kanban-settings-store.ts with column preferences Create Zustand store for kanban column preferences (width, collapsed, locked) with localStorage persistence following task-store.ts patterns: - ColumnPreferences interface with width, isCollapsed, isLocked fields - KanbanColumnPreferences type mapping each TaskStatusColumn to preferences - Load/save/reset functions with localStorage persistence - Validation of stored data structure before use - Helper functions: getEffectiveColumnWidth, hasAnyCollapsedColumn, etc. - Constants for DEFAULT_COLUMN_WIDTH (320px), MIN/MAX bounds (180-600px) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-2-1 - Add English translations for collapse/expand/resize/lock * auto-claude: subtask-2-2 - Add French translations for collapse/expand/resize Added French translations for kanban column collapse/expand feature: - collapseColumn: "Réduire la colonne" - expandColumn: "Développer la colonne" - resizeColumn: "Redimensionner la colonne" - lockColumn: "Verrouiller la largeur de la colonne" - unlockColumn: "Déverrouiller la largeur de la colonne" - columnLocked: "La largeur de la colonne est verrouillée" - expandAll: "Développer toutes les colonnes" Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-3-1 - Add CSS classes for kanban column resize/collapse features Add comprehensive CSS classes in globals.css for: - .kanban-resize-handle - Drag handle on right edge of columns - .kanban-column-collapsed - Collapsed column styling with 48px width - .kanban-column-collapsed-title - Rotated vertical title text - .kanban-column-collapsed-header - Collapsed column layout - .kanban-column-collapsed-count - Task count badge - .kanban-column-locked - Lock indicator and disabled resize state - .kanban-lock-indicator - Lock icon button styling - .kanban-column-transition - Smooth width transitions - .kanban-column-no-transition - Disable transition during drag - .kanban-resizing - Body state during resize - .kanban-column-expand-btn / .kanban-column-collapse-btn - Toggle buttons - .drop-zone-highlight variant for collapsed columns - Reduced motion support for all transitions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-4-1 - Update DroppableColumn default width from min-w-72 (288px) to min-w-80 (320px) and integrate kanban-settings-store import * auto-claude: subtask-4-2 - Add collapse/expand functionality: collapse button - Added ChevronLeft/ChevronRight icons for collapse/expand buttons - Added isCollapsed and onToggleCollapsed props to DroppableColumnProps - Implemented collapsed state rendering: narrow 48px vertical strip with rotated title and task count - Added collapse button in expanded column header (left side) - Added expand button in collapsed column header (top) - Connected to kanban-settings-store for persisting collapse state - Added handleToggleColumnCollapsed callback that saves preferences after toggle * auto-claude: subtask-4-3 - Add resize functionality: drag handle on right edge - Add resize handle on right edge of each DroppableColumn - Implement mouse drag to resize between 180px-600px (MIN/MAX from store) - Add visual feedback during drag (cursor: col-resize, highlight on handle) - Integrate with kanban-settings-store for width persistence - Support touch events for mobile compatibility - Document-level event listeners for smooth dragging experience * auto-claude: subtask-4-4 - Add lock functionality: lock toggle button in colu * auto-claude: subtask-5-1 - Verify drag-and-drop on collapsed columns and add Expand All - Verified collapsed columns properly accept task drops via useDroppable hook - Verified drop zone highlighting works on collapsed columns (isOver && 'drop-zone-highlight') - Added 'Expand All' button that appears when 3+ columns are collapsed - Added ChevronsRight icon for the Expand All button - Added collapsedColumnCount useMemo to track collapsed columns - Added handleExpandAll callback to expand all columns and persist preferences - Added setColumnCollapsed action to store hooks for explicit collapse control * fix: address PR review findings for kanban column features - Replace magic numbers (180, 600) with MIN/MAX_COLUMN_WIDTH constants - Remove unused onResize prop from DroppableColumn component - Fix stale closure risk by capturing projectId at resize start - Remove unused utility functions from kanban-settings-store - Remove ~250 lines of unused CSS classes (component uses Tailwind) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: capture projectId at function start to prevent race conditions Apply consistent pattern across collapse/expand/lock handlers: - handleToggleColumnCollapsed: capture projectId before setTimeout - handleExpandAll: capture projectId before setTimeout - handleToggleColumnLocked: capture projectId before setTimeout This matches the safer pattern already used in handleResizeStart. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ import {
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw, GitPullRequest, X, Settings, ListPlus } from 'lucide-react';
|
||||
import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw, GitPullRequest, X, Settings, ListPlus, ChevronLeft, ChevronRight, ChevronsRight, Lock, Unlock } from 'lucide-react';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Button } from './ui/button';
|
||||
@@ -31,6 +31,7 @@ import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants'
|
||||
import { cn } from '../lib/utils';
|
||||
import { persistTaskStatus, forceCompleteTask, archiveTasks, useTaskStore } from '../stores/task-store';
|
||||
import { updateProjectSettings, useProjectStore } from '../stores/project-store';
|
||||
import { useKanbanSettingsStore, COLLAPSED_COLUMN_WIDTH, DEFAULT_COLUMN_WIDTH, MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH } from '../stores/kanban-settings-store';
|
||||
import { useToast } from '../hooks/use-toast';
|
||||
import { WorktreeCleanupDialog } from './WorktreeCleanupDialog';
|
||||
import { BulkPRDialog } from './BulkPRDialog';
|
||||
@@ -81,6 +82,17 @@ interface DroppableColumnProps {
|
||||
onSelectAll?: () => void;
|
||||
onDeselectAll?: () => void;
|
||||
onToggleSelect?: (taskId: string) => void;
|
||||
// Collapse props
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapsed?: () => void;
|
||||
// Resize props
|
||||
columnWidth?: number;
|
||||
isResizing?: boolean;
|
||||
onResizeStart?: (startX: number) => void;
|
||||
onResizeEnd?: () => void;
|
||||
// Lock props
|
||||
isLocked?: boolean;
|
||||
onToggleLocked?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,6 +142,14 @@ function droppableColumnPropsAreEqual(
|
||||
if (prevProps.onSelectAll !== nextProps.onSelectAll) return false;
|
||||
if (prevProps.onDeselectAll !== nextProps.onDeselectAll) return false;
|
||||
if (prevProps.onToggleSelect !== nextProps.onToggleSelect) return false;
|
||||
if (prevProps.isCollapsed !== nextProps.isCollapsed) return false;
|
||||
if (prevProps.onToggleCollapsed !== nextProps.onToggleCollapsed) return false;
|
||||
if (prevProps.columnWidth !== nextProps.columnWidth) return false;
|
||||
if (prevProps.isResizing !== nextProps.isResizing) return false;
|
||||
if (prevProps.onResizeStart !== nextProps.onResizeStart) return false;
|
||||
if (prevProps.onResizeEnd !== nextProps.onResizeEnd) return false;
|
||||
if (prevProps.isLocked !== nextProps.isLocked) return false;
|
||||
if (prevProps.onToggleLocked !== nextProps.onToggleLocked) return false;
|
||||
|
||||
// Compare selection props
|
||||
const prevSelected = prevProps.selectedTaskIds;
|
||||
@@ -200,7 +220,7 @@ const getEmptyStateContent = (status: TaskStatus, t: (key: string) => string): {
|
||||
}
|
||||
};
|
||||
|
||||
const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskClick, onStatusChange, isOver, onAddClick, onArchiveAll, onQueueSettings, onQueueAll, maxParallelTasks, archivedCount, showArchived, onToggleArchived, selectedTaskIds, onSelectAll, onDeselectAll, onToggleSelect }: DroppableColumnProps) {
|
||||
const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskClick, onStatusChange, isOver, onAddClick, onArchiveAll, onQueueSettings, onQueueAll, maxParallelTasks, archivedCount, showArchived, onToggleArchived, selectedTaskIds, onSelectAll, onDeselectAll, onToggleSelect, isCollapsed, onToggleCollapsed, columnWidth, isResizing, onResizeStart, onResizeEnd, isLocked, onToggleLocked }: DroppableColumnProps) {
|
||||
const { t } = useTranslation(['tasks', 'common']);
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: status
|
||||
@@ -298,19 +318,94 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli
|
||||
|
||||
const emptyState = getEmptyStateContent(status, t);
|
||||
|
||||
// Collapsed state: show narrow vertical strip with rotated title and task count
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex flex-col rounded-xl border border-white/5 bg-linear-to-b from-secondary/30 to-transparent backdrop-blur-sm transition-all duration-200',
|
||||
getColumnBorderColor(),
|
||||
'border-t-2',
|
||||
isOver && 'drop-zone-highlight'
|
||||
)}
|
||||
style={{ width: COLLAPSED_COLUMN_WIDTH, minWidth: COLLAPSED_COLUMN_WIDTH, maxWidth: COLLAPSED_COLUMN_WIDTH }}
|
||||
>
|
||||
{/* Expand button at top */}
|
||||
<div className="flex justify-center p-2 border-b border-white/5">
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:bg-primary/10 hover:text-primary transition-colors"
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label={t('kanban.expandColumn')}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t('kanban.expandColumn')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Rotated title and task count */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
<div
|
||||
className="flex items-center gap-2 whitespace-nowrap"
|
||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
||||
>
|
||||
<span className="column-count-badge">
|
||||
{tasks.length}
|
||||
</span>
|
||||
<h2 className="font-semibold text-sm text-foreground">
|
||||
{t(TASK_STATUS_LABELS[status])}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex min-w-72 max-w-[30rem] flex-1 flex-col rounded-xl border border-white/5 bg-linear-to-b from-secondary/30 to-transparent backdrop-blur-sm transition-all duration-200',
|
||||
getColumnBorderColor(),
|
||||
'border-t-2',
|
||||
isOver && 'drop-zone-highlight'
|
||||
)}
|
||||
className="relative flex"
|
||||
style={columnWidth ? { width: columnWidth, minWidth: MIN_COLUMN_WIDTH, maxWidth: MAX_COLUMN_WIDTH, flexShrink: 0 } : undefined}
|
||||
>
|
||||
{/* Column header - enhanced styling */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/5">
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex flex-1 flex-col rounded-xl border border-white/5 bg-linear-to-b from-secondary/30 to-transparent backdrop-blur-sm transition-all duration-200',
|
||||
!columnWidth && 'min-w-80 max-w-[30rem]',
|
||||
getColumnBorderColor(),
|
||||
'border-t-2',
|
||||
isOver && 'drop-zone-highlight'
|
||||
)}
|
||||
>
|
||||
{/* Column header - enhanced styling */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Collapse button */}
|
||||
{onToggleCollapsed && (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 hover:bg-muted-foreground/10 hover:text-muted-foreground transition-colors"
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label={t('kanban.collapseColumn')}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('kanban.collapseColumn')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Select All checkbox for human_review column */}
|
||||
{isHumanReview && onSelectAll && onDeselectAll && (
|
||||
<Tooltip delayDuration={200}>
|
||||
@@ -347,6 +442,31 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Lock toggle button - available for all columns */}
|
||||
{onToggleLocked && (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-7 w-7 transition-colors',
|
||||
isLocked
|
||||
? 'text-amber-500 bg-amber-500/10 hover:bg-amber-500/20'
|
||||
: 'hover:bg-muted-foreground/10 hover:text-muted-foreground'
|
||||
)}
|
||||
onClick={onToggleLocked}
|
||||
aria-pressed={isLocked}
|
||||
aria-label={isLocked ? t('kanban.unlockColumn') : t('kanban.lockColumn')}
|
||||
>
|
||||
{isLocked ? <Lock className="h-3.5 w-3.5" /> : <Unlock className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isLocked ? t('kanban.unlockColumn') : t('kanban.lockColumn')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{status === 'backlog' && (
|
||||
<>
|
||||
{onQueueAll && tasks.length > 0 && (
|
||||
@@ -468,6 +588,39 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli
|
||||
</SortableContext>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resize handle on right edge */}
|
||||
{onResizeStart && onResizeEnd && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 bottom-0 w-1 touch-none z-10",
|
||||
"transition-colors duration-150",
|
||||
isLocked
|
||||
? "cursor-not-allowed bg-amber-500/20 hover:bg-amber-500/30"
|
||||
: "cursor-col-resize hover:bg-primary/40",
|
||||
isResizing && !isLocked && "bg-primary/60"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
// Don't start resize if column is locked
|
||||
if (isLocked) return;
|
||||
onResizeStart(e.clientX);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.preventDefault();
|
||||
// Don't start resize if column is locked
|
||||
if (isLocked) return;
|
||||
if (e.touches.length > 0) {
|
||||
onResizeStart(e.touches[0].clientX);
|
||||
}
|
||||
}}
|
||||
title={isLocked ? t('kanban.columnLocked') : undefined}
|
||||
>
|
||||
{/* Wider invisible hit area for easier grabbing */}
|
||||
<div className="absolute inset-y-0 -left-1 -right-1" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, droppableColumnPropsAreEqual);
|
||||
@@ -482,6 +635,22 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
// Project store for queue settings
|
||||
const projects = useProjectStore((state) => state.projects);
|
||||
|
||||
// Kanban settings store for column preferences (collapse state, width, lock state)
|
||||
const columnPreferences = useKanbanSettingsStore((state) => state.columnPreferences);
|
||||
const loadKanbanPreferences = useKanbanSettingsStore((state) => state.loadPreferences);
|
||||
const saveKanbanPreferences = useKanbanSettingsStore((state) => state.savePreferences);
|
||||
const toggleColumnCollapsed = useKanbanSettingsStore((state) => state.toggleColumnCollapsed);
|
||||
const setColumnCollapsed = useKanbanSettingsStore((state) => state.setColumnCollapsed);
|
||||
const setColumnWidth = useKanbanSettingsStore((state) => state.setColumnWidth);
|
||||
const toggleColumnLocked = useKanbanSettingsStore((state) => state.toggleColumnLocked);
|
||||
|
||||
// Column resize state
|
||||
const [resizingColumn, setResizingColumn] = useState<typeof TASK_STATUS_COLUMNS[number] | null>(null);
|
||||
const resizeStartX = useRef<number>(0);
|
||||
const resizeStartWidth = useRef<number>(0);
|
||||
// Capture projectId at resize start to avoid stale closure if project changes during resize
|
||||
const resizeProjectIdRef = useRef<string | null>(null);
|
||||
|
||||
// Get projectId from first task
|
||||
const projectId = tasks[0]?.projectId;
|
||||
const project = projectId ? projects.find((p) => p.id === projectId) : undefined;
|
||||
@@ -524,6 +693,14 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
[tasks]
|
||||
);
|
||||
|
||||
// Calculate collapsed column count for "Expand All" button
|
||||
const collapsedColumnCount = useMemo(() => {
|
||||
if (!columnPreferences) return 0;
|
||||
return TASK_STATUS_COLUMNS.filter(
|
||||
(status) => columnPreferences[status]?.isCollapsed
|
||||
).length;
|
||||
}, [columnPreferences]);
|
||||
|
||||
// Filter tasks based on archive status
|
||||
const filteredTasks = useMemo(() => {
|
||||
if (showArchived) {
|
||||
@@ -932,6 +1109,127 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
}
|
||||
}, [projectId, loadTaskOrder]);
|
||||
|
||||
// Load kanban column preferences on mount and when project changes
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
loadKanbanPreferences(projectId);
|
||||
}
|
||||
}, [projectId, loadKanbanPreferences]);
|
||||
|
||||
// Create a callback to toggle collapsed state and save to storage
|
||||
const handleToggleColumnCollapsed = useCallback((status: typeof TASK_STATUS_COLUMNS[number]) => {
|
||||
// Capture projectId at function start to avoid stale closure in setTimeout
|
||||
const currentProjectId = projectId;
|
||||
toggleColumnCollapsed(status);
|
||||
// Save preferences after toggling
|
||||
if (currentProjectId) {
|
||||
// Use setTimeout to ensure state is updated before saving
|
||||
setTimeout(() => {
|
||||
saveKanbanPreferences(currentProjectId);
|
||||
}, 0);
|
||||
}
|
||||
}, [toggleColumnCollapsed, saveKanbanPreferences, projectId]);
|
||||
|
||||
// Create a callback to expand all collapsed columns and save to storage
|
||||
const handleExpandAll = useCallback(() => {
|
||||
// Capture projectId at function start to avoid stale closure in setTimeout
|
||||
const currentProjectId = projectId;
|
||||
// Expand all collapsed columns
|
||||
for (const status of TASK_STATUS_COLUMNS) {
|
||||
if (columnPreferences?.[status]?.isCollapsed) {
|
||||
setColumnCollapsed(status, false);
|
||||
}
|
||||
}
|
||||
// Save preferences after expanding
|
||||
if (currentProjectId) {
|
||||
setTimeout(() => {
|
||||
saveKanbanPreferences(currentProjectId);
|
||||
}, 0);
|
||||
}
|
||||
}, [columnPreferences, setColumnCollapsed, saveKanbanPreferences, projectId]);
|
||||
|
||||
// Create a callback to toggle locked state and save to storage
|
||||
const handleToggleColumnLocked = useCallback((status: typeof TASK_STATUS_COLUMNS[number]) => {
|
||||
// Capture projectId at function start to avoid stale closure in setTimeout
|
||||
const currentProjectId = projectId;
|
||||
toggleColumnLocked(status);
|
||||
// Save preferences after toggling
|
||||
if (currentProjectId) {
|
||||
// Use setTimeout to ensure state is updated before saving
|
||||
setTimeout(() => {
|
||||
saveKanbanPreferences(currentProjectId);
|
||||
}, 0);
|
||||
}
|
||||
}, [toggleColumnLocked, saveKanbanPreferences, projectId]);
|
||||
|
||||
// Resize handlers for column width adjustment
|
||||
const handleResizeStart = useCallback((status: typeof TASK_STATUS_COLUMNS[number], startX: number) => {
|
||||
const currentWidth = columnPreferences?.[status]?.width ?? DEFAULT_COLUMN_WIDTH;
|
||||
resizeStartX.current = startX;
|
||||
resizeStartWidth.current = currentWidth;
|
||||
// Capture projectId at resize start to ensure we save to the correct project
|
||||
resizeProjectIdRef.current = projectId ?? null;
|
||||
setResizingColumn(status);
|
||||
}, [columnPreferences, projectId]);
|
||||
|
||||
const handleResizeMove = useCallback((clientX: number) => {
|
||||
if (!resizingColumn) return;
|
||||
|
||||
const deltaX = clientX - resizeStartX.current;
|
||||
const newWidth = Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, resizeStartWidth.current + deltaX));
|
||||
setColumnWidth(resizingColumn, newWidth);
|
||||
}, [resizingColumn, setColumnWidth]);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
// Use the projectId captured at resize start to avoid saving to wrong project
|
||||
const savedProjectId = resizeProjectIdRef.current;
|
||||
if (resizingColumn && savedProjectId) {
|
||||
saveKanbanPreferences(savedProjectId);
|
||||
}
|
||||
setResizingColumn(null);
|
||||
resizeProjectIdRef.current = null;
|
||||
}, [resizingColumn, saveKanbanPreferences]);
|
||||
|
||||
// Document-level event listeners for resize dragging
|
||||
useEffect(() => {
|
||||
if (!resizingColumn) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
handleResizeMove(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
handleResizeEnd();
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (e.touches.length === 0) return;
|
||||
handleResizeMove(e.touches[0].clientX);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
handleResizeEnd();
|
||||
};
|
||||
|
||||
// Prevent text selection and set resize cursor during drag
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'col-resize';
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
|
||||
return () => {
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}, [resizingColumn, handleResizeMove, handleResizeEnd]);
|
||||
|
||||
// Clean up stale task IDs from order when tasks change (e.g., after deletion)
|
||||
// This ensures the persisted order doesn't contain IDs for deleted tasks
|
||||
useEffect(() => {
|
||||
@@ -1084,19 +1382,37 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Kanban header with refresh button */}
|
||||
{onRefresh && (
|
||||
<div className="flex items-center justify-end px-6 pt-4 pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
|
||||
{isRefreshing ? t('common:buttons.refreshing') : t('tasks:refreshTasks')}
|
||||
</Button>
|
||||
{/* Kanban header with refresh button and expand all */}
|
||||
{(onRefresh || collapsedColumnCount >= 3) && (
|
||||
<div className="flex items-center justify-between px-6 pt-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Expand All button - appears when 3+ columns are collapsed */}
|
||||
{collapsedColumnCount >= 3 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExpandAll}
|
||||
className="gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
{t('tasks:kanban.expandAll')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
|
||||
{isRefreshing ? t('common:buttons.refreshing') : t('tasks:refreshTasks')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Kanban columns */}
|
||||
@@ -1133,6 +1449,14 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
onSelectAll={status === 'human_review' ? selectAllTasks : undefined}
|
||||
onDeselectAll={status === 'human_review' ? deselectAllTasks : undefined}
|
||||
onToggleSelect={status === 'human_review' ? toggleTaskSelection : undefined}
|
||||
isCollapsed={columnPreferences?.[status]?.isCollapsed}
|
||||
onToggleCollapsed={() => handleToggleColumnCollapsed(status)}
|
||||
columnWidth={columnPreferences?.[status]?.width}
|
||||
isResizing={resizingColumn === status}
|
||||
onResizeStart={(startX) => handleResizeStart(status, startX)}
|
||||
onResizeEnd={handleResizeEnd}
|
||||
isLocked={columnPreferences?.[status]?.isLocked}
|
||||
onToggleLocked={() => handleToggleColumnLocked(status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { create } from 'zustand';
|
||||
import type { TaskStatusColumn } from '../../shared/constants/task';
|
||||
import { TASK_STATUS_COLUMNS } from '../../shared/constants/task';
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Column preferences for a single kanban column
|
||||
*/
|
||||
export interface ColumnPreferences {
|
||||
/** Column width in pixels (180-600px range) */
|
||||
width: number;
|
||||
/** Whether the column is collapsed (narrow vertical strip) */
|
||||
isCollapsed: boolean;
|
||||
/** Whether the column width is locked (prevents resize) */
|
||||
isLocked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* All column preferences keyed by status column
|
||||
*/
|
||||
export type KanbanColumnPreferences = Record<TaskStatusColumn, ColumnPreferences>;
|
||||
|
||||
/**
|
||||
* Kanban settings store state
|
||||
*/
|
||||
interface KanbanSettingsState {
|
||||
/** Column preferences for each status column */
|
||||
columnPreferences: KanbanColumnPreferences | null;
|
||||
|
||||
// Actions
|
||||
/** Initialize column preferences (call on mount) */
|
||||
initializePreferences: () => void;
|
||||
/** Set column width */
|
||||
setColumnWidth: (column: TaskStatusColumn, width: number) => void;
|
||||
/** Toggle column collapsed state */
|
||||
toggleColumnCollapsed: (column: TaskStatusColumn) => void;
|
||||
/** Set column collapsed state explicitly */
|
||||
setColumnCollapsed: (column: TaskStatusColumn, isCollapsed: boolean) => void;
|
||||
/** Toggle column locked state */
|
||||
toggleColumnLocked: (column: TaskStatusColumn) => void;
|
||||
/** Set column locked state explicitly */
|
||||
setColumnLocked: (column: TaskStatusColumn, isLocked: boolean) => void;
|
||||
/** Load preferences from localStorage */
|
||||
loadPreferences: (projectId: string) => void;
|
||||
/** Save preferences to localStorage */
|
||||
savePreferences: (projectId: string) => boolean;
|
||||
/** Reset preferences to defaults */
|
||||
resetPreferences: (projectId: string) => void;
|
||||
/** Get preferences for a single column */
|
||||
getColumnPreferences: (column: TaskStatusColumn) => ColumnPreferences;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Constants
|
||||
// ============================================
|
||||
|
||||
/** localStorage key prefix for kanban settings persistence */
|
||||
const KANBAN_SETTINGS_KEY_PREFIX = 'kanban-column-prefs';
|
||||
|
||||
/** Default column width in pixels */
|
||||
export const DEFAULT_COLUMN_WIDTH = 320;
|
||||
|
||||
/** Minimum column width in pixels */
|
||||
export const MIN_COLUMN_WIDTH = 180;
|
||||
|
||||
/** Maximum column width in pixels */
|
||||
export const MAX_COLUMN_WIDTH = 600;
|
||||
|
||||
/** Collapsed column width in pixels */
|
||||
export const COLLAPSED_COLUMN_WIDTH = 48;
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get the localStorage key for a project's kanban settings
|
||||
*/
|
||||
function getKanbanSettingsKey(projectId: string): string {
|
||||
return `${KANBAN_SETTINGS_KEY_PREFIX}-${projectId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default column preferences for all columns
|
||||
*/
|
||||
function createDefaultPreferences(): KanbanColumnPreferences {
|
||||
const preferences: Partial<KanbanColumnPreferences> = {};
|
||||
|
||||
for (const column of TASK_STATUS_COLUMNS) {
|
||||
preferences[column] = {
|
||||
width: DEFAULT_COLUMN_WIDTH,
|
||||
isCollapsed: false,
|
||||
isLocked: false
|
||||
};
|
||||
}
|
||||
|
||||
return preferences as KanbanColumnPreferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate column preferences structure
|
||||
* Returns true if valid, false if invalid/incomplete
|
||||
*/
|
||||
function validatePreferences(data: unknown): data is KanbanColumnPreferences {
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prefs = data as Record<string, unknown>;
|
||||
|
||||
// Validate each required column exists with correct structure
|
||||
for (const column of TASK_STATUS_COLUMNS) {
|
||||
const columnPrefs = prefs[column];
|
||||
|
||||
if (!columnPrefs || typeof columnPrefs !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cp = columnPrefs as Record<string, unknown>;
|
||||
|
||||
// Validate width is a number within bounds
|
||||
if (typeof cp.width !== 'number' || cp.width < MIN_COLUMN_WIDTH || cp.width > MAX_COLUMN_WIDTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate boolean fields
|
||||
if (typeof cp.isCollapsed !== 'boolean' || typeof cp.isLocked !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a width value to valid bounds
|
||||
*/
|
||||
function clampWidth(width: number): number {
|
||||
return Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, width));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Store
|
||||
// ============================================
|
||||
|
||||
export const useKanbanSettingsStore = create<KanbanSettingsState>((set, get) => ({
|
||||
columnPreferences: null,
|
||||
|
||||
initializePreferences: () => {
|
||||
const state = get();
|
||||
if (!state.columnPreferences) {
|
||||
set({ columnPreferences: createDefaultPreferences() });
|
||||
}
|
||||
},
|
||||
|
||||
setColumnWidth: (column, width) => {
|
||||
set((state) => {
|
||||
if (!state.columnPreferences) return state;
|
||||
|
||||
// Don't allow width changes on locked columns
|
||||
if (state.columnPreferences[column].isLocked) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const clampedWidth = clampWidth(width);
|
||||
|
||||
return {
|
||||
columnPreferences: {
|
||||
...state.columnPreferences,
|
||||
[column]: {
|
||||
...state.columnPreferences[column],
|
||||
width: clampedWidth
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
toggleColumnCollapsed: (column) => {
|
||||
set((state) => {
|
||||
if (!state.columnPreferences) return state;
|
||||
|
||||
return {
|
||||
columnPreferences: {
|
||||
...state.columnPreferences,
|
||||
[column]: {
|
||||
...state.columnPreferences[column],
|
||||
isCollapsed: !state.columnPreferences[column].isCollapsed
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setColumnCollapsed: (column, isCollapsed) => {
|
||||
set((state) => {
|
||||
if (!state.columnPreferences) return state;
|
||||
|
||||
return {
|
||||
columnPreferences: {
|
||||
...state.columnPreferences,
|
||||
[column]: {
|
||||
...state.columnPreferences[column],
|
||||
isCollapsed
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
toggleColumnLocked: (column) => {
|
||||
set((state) => {
|
||||
if (!state.columnPreferences) return state;
|
||||
|
||||
return {
|
||||
columnPreferences: {
|
||||
...state.columnPreferences,
|
||||
[column]: {
|
||||
...state.columnPreferences[column],
|
||||
isLocked: !state.columnPreferences[column].isLocked
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setColumnLocked: (column, isLocked) => {
|
||||
set((state) => {
|
||||
if (!state.columnPreferences) return state;
|
||||
|
||||
return {
|
||||
columnPreferences: {
|
||||
...state.columnPreferences,
|
||||
[column]: {
|
||||
...state.columnPreferences[column],
|
||||
isLocked
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
loadPreferences: (projectId) => {
|
||||
try {
|
||||
const key = getKanbanSettingsKey(projectId);
|
||||
const stored = localStorage.getItem(key);
|
||||
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
|
||||
// Validate structure before using
|
||||
if (validatePreferences(parsed)) {
|
||||
set({ columnPreferences: parsed });
|
||||
return;
|
||||
}
|
||||
|
||||
// Invalid data structure, use defaults
|
||||
console.warn('[KanbanSettingsStore] Invalid preferences in localStorage, using defaults');
|
||||
}
|
||||
|
||||
// No stored preferences or invalid, use defaults
|
||||
set({ columnPreferences: createDefaultPreferences() });
|
||||
} catch (error) {
|
||||
console.error('[KanbanSettingsStore] Failed to load preferences:', error);
|
||||
set({ columnPreferences: createDefaultPreferences() });
|
||||
}
|
||||
},
|
||||
|
||||
savePreferences: (projectId) => {
|
||||
try {
|
||||
const state = get();
|
||||
if (!state.columnPreferences) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const key = getKanbanSettingsKey(projectId);
|
||||
localStorage.setItem(key, JSON.stringify(state.columnPreferences));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[KanbanSettingsStore] Failed to save preferences:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
resetPreferences: (projectId) => {
|
||||
try {
|
||||
const key = getKanbanSettingsKey(projectId);
|
||||
localStorage.removeItem(key);
|
||||
set({ columnPreferences: createDefaultPreferences() });
|
||||
} catch (error) {
|
||||
console.error('[KanbanSettingsStore] Failed to reset preferences:', error);
|
||||
}
|
||||
},
|
||||
|
||||
getColumnPreferences: (column) => {
|
||||
const state = get();
|
||||
|
||||
if (!state.columnPreferences) {
|
||||
return {
|
||||
width: DEFAULT_COLUMN_WIDTH,
|
||||
isCollapsed: false,
|
||||
isLocked: false
|
||||
};
|
||||
}
|
||||
|
||||
return state.columnPreferences[column];
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -1712,3 +1712,4 @@ body {
|
||||
[data-ui-scale="190"] { font-size: 30.4px; }
|
||||
[data-ui-scale="195"] { font-size: 31.2px; }
|
||||
[data-ui-scale="200"] { font-size: 32px; }
|
||||
|
||||
|
||||
@@ -100,7 +100,14 @@
|
||||
"selectedCountOne": "{{count}} task selected",
|
||||
"selectedCountOther": "{{count}} tasks selected",
|
||||
"createPRs": "Create PRs",
|
||||
"clearSelection": "Clear Selection"
|
||||
"clearSelection": "Clear Selection",
|
||||
"collapseColumn": "Collapse column",
|
||||
"expandColumn": "Expand column",
|
||||
"resizeColumn": "Resize column",
|
||||
"lockColumn": "Lock column width",
|
||||
"unlockColumn": "Unlock column width",
|
||||
"columnLocked": "Column width is locked",
|
||||
"expandAll": "Expand all columns"
|
||||
},
|
||||
"queue": {
|
||||
"limitReached": "Parallel task limit reached ({{current}}/{{max}}). Task moved to queue.",
|
||||
|
||||
@@ -99,7 +99,14 @@
|
||||
"selectedCountOne": "{{count}} tâche sélectionnée",
|
||||
"selectedCountOther": "{{count}} tâches sélectionnées",
|
||||
"createPRs": "Créer les PRs",
|
||||
"clearSelection": "Effacer la sélection"
|
||||
"clearSelection": "Effacer la sélection",
|
||||
"collapseColumn": "Réduire la colonne",
|
||||
"expandColumn": "Développer la colonne",
|
||||
"resizeColumn": "Redimensionner la colonne",
|
||||
"lockColumn": "Verrouiller la largeur de la colonne",
|
||||
"unlockColumn": "Déverrouiller la largeur de la colonne",
|
||||
"columnLocked": "La largeur de la colonne est verrouillée",
|
||||
"expandAll": "Développer toutes les colonnes"
|
||||
},
|
||||
"queue": {
|
||||
"limitReached": "Limite de tâches parallèles atteinte ({{current}}/{{max}}). Tâche déplacée vers la file d'attente.",
|
||||
|
||||
Reference in New Issue
Block a user