From f721eb7152b0434f599bb3c587fefbfab0533f91 Mon Sep 17 00:00:00 2001 From: anonymous Date: Sun, 11 Jan 2026 19:51:26 -0800 Subject: [PATCH] List View Features --- apps/ui/src/components/views/board-view.tsx | 148 +++-- .../views/board-view/board-header.tsx | 14 + .../views/board-view/components/index.ts | 30 + .../board-view/components/list-view/index.ts | 14 + .../components/list-view/list-header.tsx | 328 ++++++++++ .../components/list-view/list-row.tsx | 559 +++++++++++++++++ .../components/list-view/list-view.tsx | 485 +++++++++++++++ .../components/list-view/row-actions.tsx | 570 ++++++++++++++++++ .../components/list-view/status-badge.tsx | 221 +++++++ .../board-view/components/view-toggle.tsx | 62 ++ .../views/board-view/hooks/index.ts | 1 + .../board-view/hooks/use-list-view-state.ts | 204 +++++++ .../views/board-view/kanban-board.tsx | 17 +- 13 files changed, 2607 insertions(+), 46 deletions(-) create mode 100644 apps/ui/src/components/views/board-view/components/list-view/index.ts create mode 100644 apps/ui/src/components/views/board-view/components/list-view/list-header.tsx create mode 100644 apps/ui/src/components/views/board-view/components/list-view/list-row.tsx create mode 100644 apps/ui/src/components/views/board-view/components/list-view/list-view.tsx create mode 100644 apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx create mode 100644 apps/ui/src/components/views/board-view/components/list-view/status-badge.tsx create mode 100644 apps/ui/src/components/views/board-view/components/view-toggle.tsx create mode 100644 apps/ui/src/components/views/board-view/hooks/use-list-view-state.ts diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 30cd4db3..f523119f 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -60,7 +60,7 @@ import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog'; import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog'; import { WorktreePanel } from './board-view/worktree-panel'; import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types'; -import { COLUMNS } from './board-view/constants'; +import { COLUMNS, getColumnsWithPipeline } from './board-view/constants'; import { useBoardFeatures, useBoardDragDrop, @@ -72,8 +72,9 @@ import { useBoardPersistence, useFollowUpState, useSelectionMode, + useListViewState, } from './board-view/hooks'; -import { SelectionActionBar } from './board-view/components'; +import { SelectionActionBar, ListView } from './board-view/components'; import { MassEditDialog } from './board-view/dialogs'; import { InitScriptIndicator } from './board-view/init-script-indicator'; import { useInitScriptEvents } from '@/hooks/use-init-script-events'; @@ -194,6 +195,15 @@ export function BoardView() { } = useSelectionMode(); const [showMassEditDialog, setShowMassEditDialog] = useState(false); + // View mode state (kanban vs list) + const { + viewMode, + setViewMode, + isListView, + sortConfig, + setSortColumn, + } = useListViewState(); + // Search filter for Kanban cards const [searchQuery, setSearchQuery] = useState(''); // Plan approval loading state @@ -1038,6 +1048,17 @@ export function BoardView() { projectPath: currentProject?.path || null, }); + // Build columnFeaturesMap for ListView + const pipelineConfig = currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null; + const columnFeaturesMap = useMemo(() => { + const columns = getColumnsWithPipeline(pipelineConfig); + const map: Record = {}; + for (const column of columns) { + map[column.id] = getColumnFeatures(column.id as any); + } + return map; + }, [pipelineConfig, getColumnFeatures]); + // Use background hook const { backgroundSettings, backgroundImageStyle } = useBoardBackground({ currentProject, @@ -1225,6 +1246,8 @@ export function BoardView() { onShowBoardBackground={() => setShowBoardBackgroundModal(true)} onShowCompletedModal={() => setShowCompletedModal(true)} completedCount={completedFeatures.length} + viewMode={viewMode} + onViewModeChange={setViewMode} /> {/* Worktree Panel - conditionally rendered based on visibility setting */} @@ -1263,48 +1286,83 @@ export function BoardView() { {/* Main Content Area */}
- {/* View Content - Kanban Board */} - setEditingFeature(feature)} - onDelete={(featureId) => handleDeleteFeature(featureId)} - onViewOutput={handleViewOutput} - onVerify={handleVerifyFeature} - onResume={handleResumeFeature} - onForceStop={handleForceStopFeature} - onManualVerify={handleManualVerify} - onMoveBackToInProgress={handleMoveBackToInProgress} - onFollowUp={handleOpenFollowUp} - onComplete={handleCompleteFeature} - onImplement={handleStartImplementation} - onViewPlan={(feature) => setViewPlanFeature(feature)} - onApprovePlan={handleOpenApprovalDialog} - onSpawnTask={(feature) => { - setSpawnParentFeature(feature); - setShowAddDialog(true); - }} - featuresWithContext={featuresWithContext} - runningAutoTasks={runningAutoTasks} - onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} - onAddFeature={() => setShowAddDialog(true)} - pipelineConfig={ - currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null - } - onOpenPipelineSettings={() => setShowPipelineSettings(true)} - isSelectionMode={isSelectionMode} - selectedFeatureIds={selectedFeatureIds} - onToggleFeatureSelection={toggleFeatureSelection} - onToggleSelectionMode={toggleSelectionMode} - isDragging={activeFeature !== null} - onAiSuggest={() => setShowPlanDialog(true)} - /> + {/* View Content - Kanban Board or List View */} + {isListView ? ( + setEditingFeature(feature), + onDelete: (featureId) => handleDeleteFeature(featureId), + onViewOutput: handleViewOutput, + onVerify: handleVerifyFeature, + onResume: handleResumeFeature, + onForceStop: handleForceStopFeature, + onManualVerify: handleManualVerify, + onFollowUp: handleOpenFollowUp, + onImplement: handleStartImplementation, + onComplete: handleCompleteFeature, + onViewPlan: (feature) => setViewPlanFeature(feature), + onApprovePlan: handleOpenApprovalDialog, + onSpawnTask: (feature) => { + setSpawnParentFeature(feature); + setShowAddDialog(true); + }, + }} + runningAutoTasks={runningAutoTasks} + pipelineConfig={pipelineConfig} + onAddFeature={() => setShowAddDialog(true)} + isSelectionMode={isSelectionMode} + selectedFeatureIds={selectedFeatureIds} + onToggleFeatureSelection={toggleFeatureSelection} + onRowClick={handleViewOutput} + className="transition-opacity duration-250" + /> + ) : ( + setEditingFeature(feature)} + onDelete={(featureId) => handleDeleteFeature(featureId)} + onViewOutput={handleViewOutput} + onVerify={handleVerifyFeature} + onResume={handleResumeFeature} + onForceStop={handleForceStopFeature} + onManualVerify={handleManualVerify} + onMoveBackToInProgress={handleMoveBackToInProgress} + onFollowUp={handleOpenFollowUp} + onComplete={handleCompleteFeature} + onImplement={handleStartImplementation} + onViewPlan={(feature) => setViewPlanFeature(feature)} + onApprovePlan={handleOpenApprovalDialog} + onSpawnTask={(feature) => { + setSpawnParentFeature(feature); + setShowAddDialog(true); + }} + featuresWithContext={featuresWithContext} + runningAutoTasks={runningAutoTasks} + onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} + onAddFeature={() => setShowAddDialog(true)} + pipelineConfig={pipelineConfig} + onOpenPipelineSettings={() => setShowPipelineSettings(true)} + isSelectionMode={isSelectionMode} + selectedFeatureIds={selectedFeatureIds} + onToggleFeatureSelection={toggleFeatureSelection} + onToggleSelectionMode={toggleSelectionMode} + viewMode={viewMode} + isDragging={activeFeature !== null} + onAiSuggest={() => setShowPlanDialog(true)} + className="transition-opacity duration-250" + /> + )}
{/* Selection Action Bar */} @@ -1423,7 +1481,7 @@ export function BoardView() { open={showPipelineSettings} onClose={() => setShowPipelineSettings(false)} projectPath={currentProject.path} - pipelineConfig={pipelineConfigByProject[currentProject.path] || null} + pipelineConfig={pipelineConfig} onSave={async (config) => { const api = getHttpApiClient(); const result = await api.pipeline.saveConfig(currentProject.path, config); diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index cfaa8a27..c9ffd23c 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -14,6 +14,9 @@ import { PlanSettingsDialog } from './dialogs/plan-settings-dialog'; import { getHttpApiClient } from '@/lib/http-api-client'; import { BoardSearchBar } from './board-search-bar'; import { BoardControls } from './board-controls'; +import { ViewToggle, type ViewMode } from './components'; + +export type { ViewMode }; interface BoardHeaderProps { projectPath: string; @@ -33,6 +36,9 @@ interface BoardHeaderProps { onShowBoardBackground: () => void; onShowCompletedModal: () => void; completedCount: number; + // View toggle props + viewMode: ViewMode; + onViewModeChange: (mode: ViewMode) => void; } // Shared styles for header control containers @@ -55,6 +61,8 @@ export function BoardHeader({ onShowBoardBackground, onShowCompletedModal, completedCount, + viewMode, + onViewModeChange, }: BoardHeaderProps) { const [showAutoModeSettings, setShowAutoModeSettings] = useState(false); const [showWorktreeSettings, setShowWorktreeSettings] = useState(false); @@ -122,6 +130,12 @@ export function BoardHeader({ creatingSpecProjectPath={creatingSpecProjectPath} currentProjectPath={projectPath} /> + {isMounted && ( + + )} void; + /** Whether to show a checkbox column for selection */ + showCheckbox?: boolean; + /** Whether all items are selected (for checkbox state) */ + allSelected?: boolean; + /** Whether some but not all items are selected */ + someSelected?: boolean; + /** Callback when the select all checkbox is clicked */ + onSelectAll?: () => void; + /** Custom column definitions (defaults to LIST_COLUMNS) */ + columns?: ColumnDef[]; + /** Additional className for the header */ + className?: string; +} + +/** + * SortIcon displays the current sort state for a column + */ +function SortIcon({ + column, + sortConfig, +}: { + column: SortColumn; + sortConfig: SortConfig; +}) { + if (sortConfig.column !== column) { + // Not sorted by this column - show neutral indicator + return ( + + ); + } + + // Currently sorted by this column + if (sortConfig.direction === 'asc') { + return ; + } + + return ; +} + +/** + * SortableColumnHeader renders a clickable header cell that triggers sorting + */ +const SortableColumnHeader = memo(function SortableColumnHeader({ + column, + sortConfig, + onSortChange, +}: { + column: ColumnDef; + sortConfig: SortConfig; + onSortChange: (column: SortColumn) => void; +}) { + const handleClick = useCallback(() => { + onSortChange(column.id); + }, [column.id, onSortChange]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSortChange(column.id); + } + }, + [column.id, onSortChange] + ); + + const isSorted = sortConfig.column === column.id; + const sortDirection: SortDirection | undefined = isSorted ? sortConfig.direction : undefined; + + return ( +
+ {column.label} + +
+ ); +}); + +/** + * StaticColumnHeader renders a non-sortable header cell + */ +const StaticColumnHeader = memo(function StaticColumnHeader({ + column, +}: { + column: ColumnDef; +}) { + return ( +
+ {column.label} +
+ ); +}); + +/** + * ListHeader displays the header row for the list view table with sortable columns. + * + * Features: + * - Clickable column headers for sorting + * - Visual sort direction indicators (chevron up/down) + * - Keyboard accessible (Tab + Enter/Space to sort) + * - ARIA attributes for screen readers + * - Optional checkbox column for bulk selection + * - Customizable column definitions + * + * @example + * ```tsx + * const { sortConfig, setSortColumn } = useListViewState(); + * + * + * ``` + * + * @example + * ```tsx + * // With selection support + * + * ``` + */ +export const ListHeader = memo(function ListHeader({ + sortConfig, + onSortChange, + showCheckbox = false, + allSelected = false, + someSelected = false, + onSelectAll, + columns = LIST_COLUMNS, + className, +}: ListHeaderProps) { + return ( +
+ {/* Checkbox column for selection */} + {showCheckbox && ( +
+ { + if (el) { + el.indeterminate = someSelected && !allSelected; + } + }} + onChange={onSelectAll} + className={cn( + 'h-4 w-4 rounded border-border text-primary cursor-pointer', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1' + )} + aria-label={allSelected ? 'Deselect all' : 'Select all'} + data-testid="list-header-select-all" + /> +
+ )} + + {/* Column headers */} + {columns.map((column) => + column.sortable !== false ? ( + + ) : ( + + ) + )} + + {/* Actions column (placeholder for row action buttons) */} +
+ Actions +
+
+ ); +}); + +/** + * Helper function to get a column definition by ID + */ +export function getColumnById(columnId: SortColumn): ColumnDef | undefined { + return LIST_COLUMNS.find((col) => col.id === columnId); +} + +/** + * Helper function to get column width class for consistent styling in rows + */ +export function getColumnWidth(columnId: SortColumn): string { + const column = getColumnById(columnId); + return cn(column?.width, column?.minWidth); +} + +/** + * Helper function to get column alignment class + */ +export function getColumnAlign(columnId: SortColumn): string { + const column = getColumnById(columnId); + if (column?.align === 'center') return 'justify-center text-center'; + if (column?.align === 'right') return 'justify-end text-right'; + return ''; +} diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx new file mode 100644 index 00000000..08b1dde2 --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx @@ -0,0 +1,559 @@ +// @ts-nocheck +import { memo, useCallback, useMemo } from 'react'; +import { cn } from '@/lib/utils'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { AlertCircle, Lock, Hand, Sparkles, FileText } from 'lucide-react'; +import type { Feature } from '@/store/app-store'; +import type { PipelineConfig } from '@automaker/types'; +import { StatusBadge } from './status-badge'; +import { RowActions, type RowActionHandlers } from './row-actions'; +import { LIST_COLUMNS, getColumnWidth, getColumnAlign } from './list-header'; + +/** + * Format a date string for display in the table + */ +function formatRelativeDate(dateString: string | undefined): string { + if (!dateString) return '-'; + + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + // Today - show time + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + } + + if (diffDays === 1) { + return 'Yesterday'; + } + + if (diffDays < 7) { + return `${diffDays} days ago`; + } + + // Older - show date + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, + }); +} + +/** + * Get the priority display configuration + */ +function getPriorityDisplay(priority: number | undefined): { + label: string; + shortLabel: string; + colorClass: string; + bgClass: string; + borderClass: string; +} | null { + if (!priority) return null; + + switch (priority) { + case 1: + return { + label: 'High Priority', + shortLabel: 'High', + colorClass: 'text-[var(--status-error)]', + bgClass: 'bg-[var(--status-error)]/15', + borderClass: 'border-[var(--status-error)]/30', + }; + case 2: + return { + label: 'Medium Priority', + shortLabel: 'Medium', + colorClass: 'text-[var(--status-warning)]', + bgClass: 'bg-[var(--status-warning)]/15', + borderClass: 'border-[var(--status-warning)]/30', + }; + case 3: + return { + label: 'Low Priority', + shortLabel: 'Low', + colorClass: 'text-[var(--status-info)]', + bgClass: 'bg-[var(--status-info)]/15', + borderClass: 'border-[var(--status-info)]/30', + }; + default: + return null; + } +} + +export interface ListRowProps { + /** The feature to display */ + feature: Feature; + /** Action handlers for the row */ + handlers: RowActionHandlers; + /** Whether this feature is the current auto task (agent is running) */ + isCurrentAutoTask?: boolean; + /** Pipeline configuration for custom status colors */ + pipelineConfig?: PipelineConfig | null; + /** Whether the row is selected */ + isSelected?: boolean; + /** Whether to show the checkbox for selection */ + showCheckbox?: boolean; + /** Callback when the row selection is toggled */ + onToggleSelect?: () => void; + /** Callback when the row is clicked */ + onClick?: () => void; + /** Blocking dependency feature IDs */ + blockingDependencies?: string[]; + /** Additional className for custom styling */ + className?: string; +} + +/** + * IndicatorBadges shows small indicator icons for special states (error, blocked, manual verification, just finished) + */ +const IndicatorBadges = memo(function IndicatorBadges({ + feature, + blockingDependencies = [], + isCurrentAutoTask, +}: { + feature: Feature; + blockingDependencies?: string[]; + isCurrentAutoTask?: boolean; +}) { + const hasError = feature.error && !isCurrentAutoTask; + const isBlocked = blockingDependencies.length > 0 && !feature.error && feature.status === 'backlog'; + const showManualVerification = feature.skipTests && !feature.error && feature.status === 'backlog'; + const hasPlan = feature.planSpec?.content; + + // Check if just finished (within 2 minutes) + const isJustFinished = useMemo(() => { + if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) { + return false; + } + const finishedTime = new Date(feature.justFinishedAt).getTime(); + const twoMinutes = 2 * 60 * 1000; + return Date.now() - finishedTime < twoMinutes; + }, [feature.justFinishedAt, feature.status, feature.error]); + + const badges: Array<{ + key: string; + icon: typeof AlertCircle; + tooltip: string; + colorClass: string; + bgClass: string; + borderClass: string; + animate?: boolean; + }> = []; + + if (hasError) { + badges.push({ + key: 'error', + icon: AlertCircle, + tooltip: feature.error || 'Error', + colorClass: 'text-[var(--status-error)]', + bgClass: 'bg-[var(--status-error)]/15', + borderClass: 'border-[var(--status-error)]/30', + }); + } + + if (isBlocked) { + badges.push({ + key: 'blocked', + icon: Lock, + tooltip: `Blocked by ${blockingDependencies.length} incomplete ${blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}`, + colorClass: 'text-orange-500', + bgClass: 'bg-orange-500/15', + borderClass: 'border-orange-500/30', + }); + } + + if (showManualVerification) { + badges.push({ + key: 'manual', + icon: Hand, + tooltip: 'Manual verification required', + colorClass: 'text-[var(--status-warning)]', + bgClass: 'bg-[var(--status-warning)]/15', + borderClass: 'border-[var(--status-warning)]/30', + }); + } + + if (hasPlan) { + badges.push({ + key: 'plan', + icon: FileText, + tooltip: 'Has implementation plan', + colorClass: 'text-[var(--status-info)]', + bgClass: 'bg-[var(--status-info)]/15', + borderClass: 'border-[var(--status-info)]/30', + }); + } + + if (isJustFinished) { + badges.push({ + key: 'just-finished', + icon: Sparkles, + tooltip: 'Agent just finished working on this feature', + colorClass: 'text-[var(--status-success)]', + bgClass: 'bg-[var(--status-success)]/15', + borderClass: 'border-[var(--status-success)]/30', + animate: true, + }); + } + + if (badges.length === 0) return null; + + return ( +
+ + {badges.map((badge) => ( + + +
+ +
+
+ +

{badge.tooltip}

+
+
+ ))} +
+
+ ); +}); + +/** + * PriorityBadge displays the priority indicator in the table + */ +const PriorityBadge = memo(function PriorityBadge({ + priority, +}: { + priority: number | undefined; +}) { + const display = getPriorityDisplay(priority); + + if (!display) { + return -; + } + + return ( + + + + + {display.shortLabel} + + + +

{display.label}

+
+
+
+ ); +}); + +/** + * ListRow displays a single feature row in the list view table. + * + * Features: + * - Displays feature data in columns matching ListHeader + * - Hover state with highlight and action buttons + * - Click handler for opening feature details + * - Animated border for currently running auto task + * - Status badge with appropriate colors + * - Priority indicator + * - Indicator badges for errors, blocked state, manual verification, etc. + * - Selection checkbox for bulk operations + * + * @example + * ```tsx + * handleEdit(feature.id), + * onDelete: () => handleDelete(feature.id), + * // ... other handlers + * }} + * onClick={() => handleViewDetails(feature)} + * /> + * ``` + */ +export const ListRow = memo(function ListRow({ + feature, + handlers, + isCurrentAutoTask = false, + pipelineConfig, + isSelected = false, + showCheckbox = false, + onToggleSelect, + onClick, + blockingDependencies = [], + className, +}: ListRowProps) { + const handleRowClick = useCallback( + (e: React.MouseEvent) => { + // Don't trigger row click if clicking on checkbox or actions + if ((e.target as HTMLElement).closest('[data-testid^="row-actions"]')) { + return; + } + if ((e.target as HTMLElement).closest('input[type="checkbox"]')) { + return; + } + onClick?.(); + }, + [onClick] + ); + + const handleCheckboxChange = useCallback(() => { + onToggleSelect?.(); + }, [onToggleSelect]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(); + } + }, + [onClick] + ); + + const hasError = feature.error && !isCurrentAutoTask; + + const rowContent = ( +
+ {/* Checkbox column */} + {showCheckbox && ( +
+ +
+ )} + + {/* Title column */} +
+
+
+ + {feature.title || feature.description} + + +
+ {/* Show description as subtitle if title exists and is different */} + {feature.title && feature.title !== feature.description && ( +

+ {feature.description} +

+ )} +
+
+ + {/* Status column */} +
+ +
+ + {/* Category column */} +
+ {feature.category || '-'} +
+ + {/* Priority column */} +
+ +
+ + {/* Created At column */} +
+ {formatRelativeDate(feature.createdAt)} +
+ + {/* Updated At column */} +
+ {formatRelativeDate(feature.updatedAt)} +
+ + {/* Actions column */} +
+ +
+
+ ); + + // Wrap with animated border for currently running auto task + if (isCurrentAutoTask) { + return ( +
+ {rowContent} +
+ ); + } + + return rowContent; +}); + +/** + * Helper function to get feature sort value for a column + */ +export function getFeatureSortValue( + feature: Feature, + column: 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt' +): string | number | Date { + switch (column) { + case 'title': + return (feature.title || feature.description).toLowerCase(); + case 'status': + return feature.status; + case 'category': + return (feature.category || '').toLowerCase(); + case 'priority': + return feature.priority || 999; // No priority sorts last + case 'createdAt': + return feature.createdAt ? new Date(feature.createdAt) : new Date(0); + case 'updatedAt': + return feature.updatedAt ? new Date(feature.updatedAt) : new Date(0); + default: + return ''; + } +} + +/** + * Helper function to sort features by a column + */ +export function sortFeatures( + features: Feature[], + column: 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt', + direction: 'asc' | 'desc' +): Feature[] { + return [...features].sort((a, b) => { + const aValue = getFeatureSortValue(a, column); + const bValue = getFeatureSortValue(b, column); + + let comparison = 0; + + if (aValue instanceof Date && bValue instanceof Date) { + comparison = aValue.getTime() - bValue.getTime(); + } else if (typeof aValue === 'number' && typeof bValue === 'number') { + comparison = aValue - bValue; + } else { + comparison = String(aValue).localeCompare(String(bValue)); + } + + return direction === 'asc' ? comparison : -comparison; + }); +} diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx new file mode 100644 index 00000000..45ba3532 --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx @@ -0,0 +1,485 @@ +// @ts-nocheck +import { memo, useMemo, useCallback, useState } from 'react'; +import { ChevronDown, ChevronRight, Plus } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { getBlockingDependencies } from '@automaker/dependency-resolver'; +import type { Feature } from '@/store/app-store'; +import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types'; +import { ListHeader } from './list-header'; +import { ListRow, sortFeatures } from './list-row'; +import { createRowActionHandlers, type RowActionHandlers } from './row-actions'; +import { getStatusLabel, getStatusOrder } from './status-badge'; +import { COLUMNS, getColumnsWithPipeline } from '../../constants'; +import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state'; + +/** + * Status group configuration for the list view + */ +interface StatusGroup { + id: FeatureStatusWithPipeline; + title: string; + colorClass: string; + features: Feature[]; +} + +/** + * Props for action handlers passed from the parent board view + */ +export interface ListViewActionHandlers { + onEdit: (feature: Feature) => void; + onDelete: (featureId: string) => void; + onViewOutput?: (feature: Feature) => void; + onVerify?: (feature: Feature) => void; + onResume?: (feature: Feature) => void; + onForceStop?: (feature: Feature) => void; + onManualVerify?: (feature: Feature) => void; + onFollowUp?: (feature: Feature) => void; + onImplement?: (feature: Feature) => void; + onComplete?: (feature: Feature) => void; + onViewPlan?: (feature: Feature) => void; + onApprovePlan?: (feature: Feature) => void; + onSpawnTask?: (feature: Feature) => void; +} + +export interface ListViewProps { + /** Map of column/status ID to features in that column */ + columnFeaturesMap: Record; + /** All features (for dependency checking) */ + allFeatures: Feature[]; + /** Current sort configuration */ + sortConfig: SortConfig; + /** Callback when sort column is changed */ + onSortChange: (column: SortColumn) => void; + /** Action handlers for rows */ + actionHandlers: ListViewActionHandlers; + /** Set of feature IDs that are currently running */ + runningAutoTasks: string[]; + /** Pipeline configuration for custom statuses */ + pipelineConfig?: PipelineConfig | null; + /** Callback to add a new feature */ + onAddFeature?: () => void; + /** Whether selection mode is enabled */ + isSelectionMode?: boolean; + /** Set of selected feature IDs */ + selectedFeatureIds?: Set; + /** Callback when a feature's selection is toggled */ + onToggleFeatureSelection?: (featureId: string) => void; + /** Callback when the row is clicked */ + onRowClick?: (feature: Feature) => void; + /** Additional className for custom styling */ + className?: string; +} + +/** + * StatusGroupHeader displays the header for a status group with collapse toggle + */ +const StatusGroupHeader = memo(function StatusGroupHeader({ + group, + isExpanded, + onToggle, +}: { + group: StatusGroup; + isExpanded: boolean; + onToggle: () => void; +}) { + return ( + + ); +}); + +/** + * EmptyState displays a message when there are no features + */ +const EmptyState = memo(function EmptyState({ + onAddFeature, +}: { + onAddFeature?: () => void; +}) { + return ( +
+

No features to display

+ {onAddFeature && ( + + )} +
+ ); +}); + +/** + * ListView displays features in a table format grouped by status. + * + * Features: + * - Groups features by status (backlog, in_progress, waiting_approval, verified, pipeline steps) + * - Collapsible status groups + * - Sortable columns (title, status, category, priority, dates) + * - Inline row actions with hover state + * - Selection support for bulk operations + * - Animated border for currently running features + * - Keyboard accessible + * + * The component receives features grouped by status via columnFeaturesMap + * and applies the current sort configuration within each group. + * + * @example + * ```tsx + * const { sortConfig, setSortColumn } = useListViewState(); + * const { columnFeaturesMap } = useBoardColumnFeatures({ features, ... }); + * + * + * ``` + */ +export const ListView = memo(function ListView({ + columnFeaturesMap, + allFeatures, + sortConfig, + onSortChange, + actionHandlers, + runningAutoTasks, + pipelineConfig = null, + onAddFeature, + isSelectionMode = false, + selectedFeatureIds = new Set(), + onToggleFeatureSelection, + onRowClick, + className, +}: ListViewProps) { + // Track collapsed state for each status group + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + + // Generate status groups from columnFeaturesMap + const statusGroups = useMemo(() => { + const columns = getColumnsWithPipeline(pipelineConfig); + const groups: StatusGroup[] = []; + + for (const column of columns) { + const features = columnFeaturesMap[column.id] || []; + if (features.length > 0) { + // Sort features within the group according to current sort config + const sortedFeatures = sortFeatures( + features, + sortConfig.column, + sortConfig.direction + ); + + groups.push({ + id: column.id as FeatureStatusWithPipeline, + title: column.title, + colorClass: column.colorClass, + features: sortedFeatures, + }); + } + } + + // Sort groups by status order + return groups.sort((a, b) => getStatusOrder(a.id) - getStatusOrder(b.id)); + }, [columnFeaturesMap, pipelineConfig, sortConfig]); + + // Calculate total feature count + const totalFeatures = useMemo( + () => statusGroups.reduce((sum, group) => sum + group.features.length, 0), + [statusGroups] + ); + + // Toggle group collapse state + const toggleGroup = useCallback((groupId: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }, []); + + // Create row action handlers for a feature + const createHandlers = useCallback( + (feature: Feature): RowActionHandlers => { + return createRowActionHandlers(feature.id, { + editFeature: (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onEdit(f); + }, + deleteFeature: (id) => actionHandlers.onDelete(id), + viewOutput: actionHandlers.onViewOutput + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onViewOutput?.(f); + } + : undefined, + verifyFeature: actionHandlers.onVerify + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onVerify?.(f); + } + : undefined, + resumeFeature: actionHandlers.onResume + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onResume?.(f); + } + : undefined, + forceStop: actionHandlers.onForceStop + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onForceStop?.(f); + } + : undefined, + manualVerify: actionHandlers.onManualVerify + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onManualVerify?.(f); + } + : undefined, + followUp: actionHandlers.onFollowUp + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onFollowUp?.(f); + } + : undefined, + implement: actionHandlers.onImplement + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onImplement?.(f); + } + : undefined, + complete: actionHandlers.onComplete + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onComplete?.(f); + } + : undefined, + viewPlan: actionHandlers.onViewPlan + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onViewPlan?.(f); + } + : undefined, + approvePlan: actionHandlers.onApprovePlan + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onApprovePlan?.(f); + } + : undefined, + spawnTask: actionHandlers.onSpawnTask + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onSpawnTask?.(f); + } + : undefined, + }); + }, + [actionHandlers, allFeatures] + ); + + // Get blocking dependencies for a feature + const getBlockingDeps = useCallback( + (feature: Feature): string[] => { + return getBlockingDependencies(feature, allFeatures); + }, + [allFeatures] + ); + + // Calculate selection state for header checkbox + const selectionState = useMemo(() => { + if (!isSelectionMode || totalFeatures === 0) { + return { allSelected: false, someSelected: false }; + } + const selectedCount = selectedFeatureIds.size; + return { + allSelected: selectedCount === totalFeatures && selectedCount > 0, + someSelected: selectedCount > 0 && selectedCount < totalFeatures, + }; + }, [isSelectionMode, totalFeatures, selectedFeatureIds]); + + // Handle select all toggle + const handleSelectAll = useCallback(() => { + if (!onToggleFeatureSelection) return; + + // If all selected, deselect all; otherwise select all + if (selectionState.allSelected) { + // Clear all selections + selectedFeatureIds.forEach((id) => onToggleFeatureSelection(id)); + } else { + // Select all features that aren't already selected + for (const group of statusGroups) { + for (const feature of group.features) { + if (!selectedFeatureIds.has(feature.id)) { + onToggleFeatureSelection(feature.id); + } + } + } + } + }, [ + onToggleFeatureSelection, + selectionState.allSelected, + selectedFeatureIds, + statusGroups, + ]); + + // Show empty state if no features + if (totalFeatures === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Table header */} + + + {/* Table body with status groups */} +
+ {statusGroups.map((group) => { + const isExpanded = !collapsedGroups.has(group.id); + + return ( +
+ {/* Group header */} + toggleGroup(group.id)} + /> + + {/* Group rows */} + {isExpanded && ( +
+ {group.features.map((feature) => ( + onToggleFeatureSelection?.(feature.id)} + onClick={() => onRowClick?.(feature)} + blockingDependencies={getBlockingDeps(feature)} + /> + ))} +
+ )} +
+ ); + })} +
+ + {/* Footer with Add Feature button */} + {onAddFeature && ( +
+ +
+ )} +
+ ); +}); + +/** + * Helper to get all features from the columnFeaturesMap as a flat array + */ +export function getFlatFeatures( + columnFeaturesMap: Record +): Feature[] { + return Object.values(columnFeaturesMap).flat(); +} + +/** + * Helper to count total features across all groups + */ +export function getTotalFeatureCount( + columnFeaturesMap: Record +): number { + return Object.values(columnFeaturesMap).reduce( + (sum, features) => sum + features.length, + 0 + ); +} diff --git a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx new file mode 100644 index 00000000..6882c437 --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx @@ -0,0 +1,570 @@ +// @ts-nocheck +import { memo, useCallback, useState } from 'react'; +import { + MoreHorizontal, + Edit, + Trash2, + PlayCircle, + RotateCcw, + StopCircle, + CheckCircle2, + FileText, + Eye, + Wand2, + Archive, + GitBranch, + ExternalLink, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import type { Feature } from '@/store/app-store'; + +/** + * Action handler types for row actions + */ +export interface RowActionHandlers { + onEdit: () => void; + onDelete: () => void; + onViewOutput?: () => void; + onVerify?: () => void; + onResume?: () => void; + onForceStop?: () => void; + onManualVerify?: () => void; + onFollowUp?: () => void; + onImplement?: () => void; + onComplete?: () => void; + onViewPlan?: () => void; + onApprovePlan?: () => void; + onSpawnTask?: () => void; +} + +export interface RowActionsProps { + /** The feature for this row */ + feature: Feature; + /** Action handlers */ + handlers: RowActionHandlers; + /** Whether this feature is the current auto task (agent is running) */ + isCurrentAutoTask?: boolean; + /** Whether the dropdown menu is open */ + isOpen?: boolean; + /** Callback when the dropdown open state changes */ + onOpenChange?: (open: boolean) => void; + /** Additional className for custom styling */ + className?: string; +} + +/** + * MenuItem is a helper component for dropdown menu items with consistent styling + */ +const MenuItem = memo(function MenuItem({ + icon: Icon, + label, + onClick, + variant = 'default', + disabled = false, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + onClick: () => void; + variant?: 'default' | 'destructive' | 'primary' | 'success' | 'warning'; + disabled?: boolean; +}) { + const variantClasses = { + default: '', + destructive: 'text-destructive focus:text-destructive focus:bg-destructive/10', + primary: 'text-primary focus:text-primary focus:bg-primary/10', + success: 'text-[var(--status-success)] focus:text-[var(--status-success)] focus:bg-[var(--status-success)]/10', + warning: 'text-[var(--status-waiting)] focus:text-[var(--status-waiting)] focus:bg-[var(--status-waiting)]/10', + }; + + return ( + { + e.stopPropagation(); + onClick(); + }} + disabled={disabled} + className={cn('gap-2', variantClasses[variant])} + > + + {label} + + ); +}); + +/** + * Get the primary action for quick access button based on feature status + */ +function getPrimaryAction( + feature: Feature, + handlers: RowActionHandlers, + isCurrentAutoTask: boolean +): { + icon: React.ComponentType<{ className?: string }>; + label: string; + onClick: () => void; + variant?: 'default' | 'destructive' | 'primary' | 'success' | 'warning'; +} | null { + // Running task - force stop is primary + if (isCurrentAutoTask) { + if (handlers.onForceStop) { + return { + icon: StopCircle, + label: 'Stop', + onClick: handlers.onForceStop, + variant: 'destructive', + }; + } + return null; + } + + // Backlog - implement is primary + if (feature.status === 'backlog' && handlers.onImplement) { + return { + icon: PlayCircle, + label: 'Make', + onClick: handlers.onImplement, + variant: 'primary', + }; + } + + // In progress with plan approval pending + if ( + feature.status === 'in_progress' && + feature.planSpec?.status === 'generated' && + handlers.onApprovePlan + ) { + return { + icon: FileText, + label: 'Approve', + onClick: handlers.onApprovePlan, + variant: 'warning', + }; + } + + // In progress - resume is primary + if (feature.status === 'in_progress' && handlers.onResume) { + return { + icon: RotateCcw, + label: 'Resume', + onClick: handlers.onResume, + variant: 'success', + }; + } + + // Waiting approval - verify is primary + if (feature.status === 'waiting_approval' && handlers.onManualVerify) { + return { + icon: CheckCircle2, + label: 'Verify', + onClick: handlers.onManualVerify, + variant: 'success', + }; + } + + // Verified - complete is primary + if (feature.status === 'verified' && handlers.onComplete) { + return { + icon: Archive, + label: 'Complete', + onClick: handlers.onComplete, + variant: 'primary', + }; + } + + return null; +} + +/** + * RowActions provides an inline action menu for list view rows. + * + * Features: + * - Quick access button for primary action (Make, Resume, Verify, etc.) + * - Dropdown menu with all available actions + * - Context-aware actions based on feature status + * - Support for running task actions (view logs, force stop) + * - Keyboard accessible (focus, Enter/Space to open) + * + * Actions by status: + * - Backlog: Edit, Delete, Make (implement), View Plan + * - In Progress: View Logs, Resume, Approve Plan, Manual Verify + * - Waiting Approval: Refine, Verify, View Logs + * - Verified: View Logs, Complete + * - Running (auto task): View Logs, Force Stop, Approve Plan + * + * @example + * ```tsx + * handleEdit(feature.id), + * onDelete: () => handleDelete(feature.id), + * onImplement: () => handleImplement(feature.id), + * // ... other handlers + * }} + * /> + * ``` + */ +export const RowActions = memo(function RowActions({ + feature, + handlers, + isCurrentAutoTask = false, + isOpen, + onOpenChange, + className, +}: RowActionsProps) { + const [internalOpen, setInternalOpen] = useState(false); + + // Use controlled or uncontrolled state + const open = isOpen ?? internalOpen; + const setOpen = onOpenChange ?? setInternalOpen; + + const handleOpenChange = useCallback( + (newOpen: boolean) => { + setOpen(newOpen); + }, + [setOpen] + ); + + const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask); + + // Helper to close menu after action + const withClose = useCallback( + (handler: () => void) => () => { + setOpen(false); + handler(); + }, + [setOpen] + ); + + return ( +
e.stopPropagation()} + data-testid={`row-actions-${feature.id}`} + > + {/* Primary action quick button */} + {primaryAction && ( + + )} + + {/* Dropdown menu */} + + + + + + {/* Running task actions */} + {isCurrentAutoTask && ( + <> + {handlers.onViewOutput && ( + + )} + {feature.planSpec?.status === 'generated' && handlers.onApprovePlan && ( + + )} + {handlers.onForceStop && ( + <> + + + + )} + + )} + + {/* Backlog actions */} + {!isCurrentAutoTask && feature.status === 'backlog' && ( + <> + + {feature.planSpec?.content && handlers.onViewPlan && ( + + )} + {handlers.onImplement && ( + + )} + + + + )} + + {/* In Progress actions */} + {!isCurrentAutoTask && feature.status === 'in_progress' && ( + <> + {handlers.onViewOutput && ( + + )} + {feature.planSpec?.status === 'generated' && handlers.onApprovePlan && ( + + )} + {feature.skipTests && handlers.onManualVerify ? ( + + ) : handlers.onResume ? ( + + ) : null} + + + + + )} + + {/* Waiting Approval actions */} + {!isCurrentAutoTask && feature.status === 'waiting_approval' && ( + <> + {handlers.onViewOutput && ( + + )} + {handlers.onFollowUp && ( + + )} + {feature.prUrl && ( + window.open(feature.prUrl, '_blank'))} + /> + )} + {handlers.onManualVerify && ( + + )} + + + + + )} + + {/* Verified actions */} + {!isCurrentAutoTask && feature.status === 'verified' && ( + <> + {handlers.onViewOutput && ( + + )} + {feature.prUrl && ( + window.open(feature.prUrl, '_blank'))} + /> + )} + {feature.worktree && ( + {})} + disabled + /> + )} + {handlers.onComplete && ( + + )} + + + + + )} + + {/* Pipeline status actions (generic fallback) */} + {!isCurrentAutoTask && + feature.status.startsWith('pipeline_') && ( + <> + {handlers.onViewOutput && ( + + )} + + + + + )} + + +
+ ); +}); + +/** + * Helper function to create action handlers from common patterns + */ +export function createRowActionHandlers( + featureId: string, + actions: { + editFeature?: (id: string) => void; + deleteFeature?: (id: string) => void; + viewOutput?: (id: string) => void; + verifyFeature?: (id: string) => void; + resumeFeature?: (id: string) => void; + forceStop?: (id: string) => void; + manualVerify?: (id: string) => void; + followUp?: (id: string) => void; + implement?: (id: string) => void; + complete?: (id: string) => void; + viewPlan?: (id: string) => void; + approvePlan?: (id: string) => void; + spawnTask?: (id: string) => void; + } +): RowActionHandlers { + return { + onEdit: () => actions.editFeature?.(featureId), + onDelete: () => actions.deleteFeature?.(featureId), + onViewOutput: actions.viewOutput ? () => actions.viewOutput!(featureId) : undefined, + onVerify: actions.verifyFeature ? () => actions.verifyFeature!(featureId) : undefined, + onResume: actions.resumeFeature ? () => actions.resumeFeature!(featureId) : undefined, + onForceStop: actions.forceStop ? () => actions.forceStop!(featureId) : undefined, + onManualVerify: actions.manualVerify ? () => actions.manualVerify!(featureId) : undefined, + onFollowUp: actions.followUp ? () => actions.followUp!(featureId) : undefined, + onImplement: actions.implement ? () => actions.implement!(featureId) : undefined, + onComplete: actions.complete ? () => actions.complete!(featureId) : undefined, + onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined, + onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined, + onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined, + }; +} diff --git a/apps/ui/src/components/views/board-view/components/list-view/status-badge.tsx b/apps/ui/src/components/views/board-view/components/list-view/status-badge.tsx new file mode 100644 index 00000000..28c94777 --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/list-view/status-badge.tsx @@ -0,0 +1,221 @@ +import { memo, useMemo } from 'react'; +import { cn } from '@/lib/utils'; +import { COLUMNS, isPipelineStatus } from '../../constants'; +import type { FeatureStatusWithPipeline, PipelineConfig } from '@automaker/types'; + +/** + * Status display configuration + */ +interface StatusDisplay { + label: string; + colorClass: string; + bgClass: string; + borderClass: string; +} + +/** + * Base status display configurations using CSS variables + */ +const BASE_STATUS_DISPLAY: Record = { + backlog: { + label: 'Backlog', + colorClass: 'text-[var(--status-backlog)]', + bgClass: 'bg-[var(--status-backlog)]/15', + borderClass: 'border-[var(--status-backlog)]/30', + }, + in_progress: { + label: 'In Progress', + colorClass: 'text-[var(--status-in-progress)]', + bgClass: 'bg-[var(--status-in-progress)]/15', + borderClass: 'border-[var(--status-in-progress)]/30', + }, + waiting_approval: { + label: 'Waiting Approval', + colorClass: 'text-[var(--status-waiting)]', + bgClass: 'bg-[var(--status-waiting)]/15', + borderClass: 'border-[var(--status-waiting)]/30', + }, + verified: { + label: 'Verified', + colorClass: 'text-[var(--status-success)]', + bgClass: 'bg-[var(--status-success)]/15', + borderClass: 'border-[var(--status-success)]/30', + }, +}; + +/** + * Get display configuration for a pipeline status + */ +function getPipelineStatusDisplay( + status: string, + pipelineConfig: PipelineConfig | null +): StatusDisplay | null { + if (!isPipelineStatus(status) || !pipelineConfig?.steps) { + return null; + } + + const stepId = status.replace('pipeline_', ''); + const step = pipelineConfig.steps.find((s) => s.id === stepId); + + if (!step) { + return null; + } + + // Extract the color variable from the colorClass (e.g., "bg-[var(--status-in-progress)]") + // and use it for the badge styling + const colorVar = step.colorClass?.match(/var\(([^)]+)\)/)?.[1] || '--status-in-progress'; + + return { + label: step.name || 'Pipeline Step', + colorClass: `text-[var(${colorVar})]`, + bgClass: `bg-[var(${colorVar})]/15`, + borderClass: `border-[var(${colorVar})]/30`, + }; +} + +/** + * Get the display configuration for a status + */ +function getStatusDisplay( + status: FeatureStatusWithPipeline, + pipelineConfig: PipelineConfig | null +): StatusDisplay { + // Check for pipeline status first + if (isPipelineStatus(status)) { + const pipelineDisplay = getPipelineStatusDisplay(status, pipelineConfig); + if (pipelineDisplay) { + return pipelineDisplay; + } + // Fallback for unknown pipeline status + return { + label: status.replace('pipeline_', '').replace(/_/g, ' '), + colorClass: 'text-[var(--status-in-progress)]', + bgClass: 'bg-[var(--status-in-progress)]/15', + borderClass: 'border-[var(--status-in-progress)]/30', + }; + } + + // Check base status + const baseDisplay = BASE_STATUS_DISPLAY[status]; + if (baseDisplay) { + return baseDisplay; + } + + // Try to find from COLUMNS constant + const column = COLUMNS.find((c) => c.id === status); + if (column) { + return { + label: column.title, + colorClass: 'text-muted-foreground', + bgClass: 'bg-muted/50', + borderClass: 'border-border/50', + }; + } + + // Fallback for unknown status + return { + label: status.replace(/_/g, ' '), + colorClass: 'text-muted-foreground', + bgClass: 'bg-muted/50', + borderClass: 'border-border/50', + }; +} + +export interface StatusBadgeProps { + /** The status to display */ + status: FeatureStatusWithPipeline; + /** Optional pipeline configuration for custom pipeline steps */ + pipelineConfig?: PipelineConfig | null; + /** Size variant for the badge */ + size?: 'sm' | 'default' | 'lg'; + /** Additional className for custom styling */ + className?: string; +} + +/** + * StatusBadge displays a feature status as a colored chip/badge for use in the list view table. + * + * Features: + * - Displays status with appropriate color based on status type + * - Supports base statuses (backlog, in_progress, waiting_approval, verified) + * - Supports pipeline statuses with custom colors from pipeline configuration + * - Size variants (sm, default, lg) + * - Uses CSS variables for consistent theming + * + * @example + * ```tsx + * // Basic usage + * + * + * // With pipeline configuration + * + * + * // Small size + * + * ``` + */ +export const StatusBadge = memo(function StatusBadge({ + status, + pipelineConfig = null, + size = 'default', + className, +}: StatusBadgeProps) { + const display = useMemo( + () => getStatusDisplay(status, pipelineConfig), + [status, pipelineConfig] + ); + + const sizeClasses = { + sm: 'px-1.5 py-0.5 text-[10px]', + default: 'px-2 py-0.5 text-xs', + lg: 'px-2.5 py-1 text-sm', + }; + + return ( + + {display.label} + + ); +}); + +/** + * Helper function to get the status label without rendering the badge + * Useful for sorting or filtering operations + */ +export function getStatusLabel( + status: FeatureStatusWithPipeline, + pipelineConfig: PipelineConfig | null = null +): string { + return getStatusDisplay(status, pipelineConfig).label; +} + +/** + * Helper function to get the status order for sorting + * Returns a numeric value representing the status position in the workflow + */ +export function getStatusOrder(status: FeatureStatusWithPipeline): number { + const baseOrder: Record = { + backlog: 0, + in_progress: 1, + waiting_approval: 2, + verified: 3, + }; + + if (isPipelineStatus(status)) { + // Pipeline statuses come after in_progress but before waiting_approval + return 1.5; + } + + return baseOrder[status] ?? 0; +} diff --git a/apps/ui/src/components/views/board-view/components/view-toggle.tsx b/apps/ui/src/components/views/board-view/components/view-toggle.tsx new file mode 100644 index 00000000..b4d73cbb --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/view-toggle.tsx @@ -0,0 +1,62 @@ +import { LayoutGrid, List } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export type ViewMode = 'kanban' | 'list'; + +interface ViewToggleProps { + viewMode: ViewMode; + onViewModeChange: (mode: ViewMode) => void; + className?: string; +} + +/** + * A segmented control component for switching between kanban (grid) and list views. + * Uses icons to represent each view mode with clear visual feedback. + */ +export function ViewToggle({ viewMode, onViewModeChange, className }: ViewToggleProps) { + return ( +
+ + +
+ ); +} diff --git a/apps/ui/src/components/views/board-view/hooks/index.ts b/apps/ui/src/components/views/board-view/hooks/index.ts index 272937f4..72e45677 100644 --- a/apps/ui/src/components/views/board-view/hooks/index.ts +++ b/apps/ui/src/components/views/board-view/hooks/index.ts @@ -8,3 +8,4 @@ export { useBoardBackground } from './use-board-background'; export { useBoardPersistence } from './use-board-persistence'; export { useFollowUpState } from './use-follow-up-state'; export { useSelectionMode } from './use-selection-mode'; +export { useListViewState } from './use-list-view-state'; diff --git a/apps/ui/src/components/views/board-view/hooks/use-list-view-state.ts b/apps/ui/src/components/views/board-view/hooks/use-list-view-state.ts new file mode 100644 index 00000000..ce046820 --- /dev/null +++ b/apps/ui/src/components/views/board-view/hooks/use-list-view-state.ts @@ -0,0 +1,204 @@ +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { getJSON, setJSON } from '@/lib/storage'; +import type { ViewMode } from '../components/view-toggle'; + +// Re-export ViewMode for convenience +export type { ViewMode }; + +/** Columns that can be sorted in the list view */ +export type SortColumn = 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt'; + +/** Sort direction */ +export type SortDirection = 'asc' | 'desc'; + +/** Sort configuration */ +export interface SortConfig { + column: SortColumn; + direction: SortDirection; +} + +/** Persisted state for the list view */ +interface ListViewPersistedState { + viewMode: ViewMode; + sortConfig: SortConfig; +} + +/** Storage key for list view preferences */ +const STORAGE_KEY = 'automaker:list-view-state'; + +/** Default sort configuration */ +const DEFAULT_SORT_CONFIG: SortConfig = { + column: 'createdAt', + direction: 'desc', +}; + +/** Default persisted state */ +const DEFAULT_STATE: ListViewPersistedState = { + viewMode: 'kanban', + sortConfig: DEFAULT_SORT_CONFIG, +}; + +/** + * Validates and returns a valid ViewMode, defaulting to 'kanban' if invalid + */ +function validateViewMode(value: unknown): ViewMode { + if (value === 'kanban' || value === 'list') { + return value; + } + return 'kanban'; +} + +/** + * Validates and returns a valid SortColumn, defaulting to 'createdAt' if invalid + */ +function validateSortColumn(value: unknown): SortColumn { + const validColumns: SortColumn[] = ['title', 'status', 'category', 'priority', 'createdAt', 'updatedAt']; + if (typeof value === 'string' && validColumns.includes(value as SortColumn)) { + return value as SortColumn; + } + return 'createdAt'; +} + +/** + * Validates and returns a valid SortDirection, defaulting to 'desc' if invalid + */ +function validateSortDirection(value: unknown): SortDirection { + if (value === 'asc' || value === 'desc') { + return value; + } + return 'desc'; +} + +/** + * Load persisted state from localStorage with validation + */ +function loadPersistedState(): ListViewPersistedState { + const stored = getJSON>(STORAGE_KEY); + + if (!stored) { + return DEFAULT_STATE; + } + + return { + viewMode: validateViewMode(stored.viewMode), + sortConfig: { + column: validateSortColumn(stored.sortConfig?.column), + direction: validateSortDirection(stored.sortConfig?.direction), + }, + }; +} + +/** + * Save state to localStorage + */ +function savePersistedState(state: ListViewPersistedState): void { + setJSON(STORAGE_KEY, state); +} + +export interface UseListViewStateReturn { + /** Current view mode (kanban or list) */ + viewMode: ViewMode; + /** Set the view mode */ + setViewMode: (mode: ViewMode) => void; + /** Toggle between kanban and list views */ + toggleViewMode: () => void; + /** Whether the current view is list mode */ + isListView: boolean; + /** Whether the current view is kanban mode */ + isKanbanView: boolean; + /** Current sort configuration */ + sortConfig: SortConfig; + /** Set the sort column (toggles direction if same column) */ + setSortColumn: (column: SortColumn) => void; + /** Set the full sort configuration */ + setSortConfig: (config: SortConfig) => void; + /** Reset sort to default */ + resetSort: () => void; +} + +/** + * Hook for managing list view state including view mode, sorting, and localStorage persistence. + * + * Features: + * - View mode toggle between kanban and list views + * - Sort configuration with column and direction + * - Automatic persistence to localStorage + * - Validated state restoration on mount + * + * @example + * ```tsx + * const { viewMode, setViewMode, sortConfig, setSortColumn } = useListViewState(); + * + * // Toggle view mode + * + * + * // Sort by column (clicking same column toggles direction) + * setSortColumn('title')}>Title + * ``` + */ +export function useListViewState(): UseListViewStateReturn { + // Initialize state from localStorage + const [viewMode, setViewModeState] = useState(() => loadPersistedState().viewMode); + const [sortConfig, setSortConfigState] = useState(() => loadPersistedState().sortConfig); + + // Derived state + const isListView = viewMode === 'list'; + const isKanbanView = viewMode === 'kanban'; + + // Persist state changes to localStorage + useEffect(() => { + savePersistedState({ viewMode, sortConfig }); + }, [viewMode, sortConfig]); + + // Set view mode + const setViewMode = useCallback((mode: ViewMode) => { + setViewModeState(mode); + }, []); + + // Toggle between kanban and list views + const toggleViewMode = useCallback(() => { + setViewModeState((prev) => (prev === 'kanban' ? 'list' : 'kanban')); + }, []); + + // Set sort column - toggles direction if same column is clicked + const setSortColumn = useCallback((column: SortColumn) => { + setSortConfigState((prev) => { + if (prev.column === column) { + // Toggle direction if same column + return { + column, + direction: prev.direction === 'asc' ? 'desc' : 'asc', + }; + } + // New column - default to descending for dates, ascending for others + const defaultDirection: SortDirection = + column === 'createdAt' || column === 'updatedAt' ? 'desc' : 'asc'; + return { column, direction: defaultDirection }; + }); + }, []); + + // Set full sort configuration + const setSortConfig = useCallback((config: SortConfig) => { + setSortConfigState(config); + }, []); + + // Reset sort to default + const resetSort = useCallback(() => { + setSortConfigState(DEFAULT_SORT_CONFIG); + }, []); + + return useMemo( + () => ({ + viewMode, + setViewMode, + toggleViewMode, + isListView, + isKanbanView, + sortConfig, + setSortColumn, + setSortConfig, + resetSort, + }), + [viewMode, setViewMode, toggleViewMode, isListView, isKanbanView, sortConfig, setSortColumn, setSortConfig, resetSort] + ); +} diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index e6b229ee..9d09e60b 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -8,6 +8,8 @@ import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-reac import { useResponsiveKanban } from '@/hooks/use-responsive-kanban'; import { getColumnsWithPipeline, type ColumnId } from './constants'; import type { PipelineConfig } from '@automaker/types'; +import { cn } from '@/lib/utils'; +import type { ViewMode } from './hooks/use-list-view-state'; interface KanbanBoardProps { sensors: any; @@ -57,6 +59,10 @@ interface KanbanBoardProps { isDragging?: boolean; /** Whether the board is in read-only mode */ isReadOnly?: boolean; + // View mode for transition animation + viewMode?: ViewMode; + /** Additional className for custom styling (e.g., transition classes) */ + className?: string; } export function KanbanBoard({ @@ -95,6 +101,8 @@ export function KanbanBoard({ onAiSuggest, isDragging = false, isReadOnly = false, + viewMode, + className, }: KanbanBoardProps) { // Generate columns including pipeline steps const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]); @@ -108,7 +116,14 @@ export function KanbanBoard({ const { columnWidth, containerStyle } = useResponsiveKanban(columns.length); return ( -
+