mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
List View Features
This commit is contained in:
@@ -60,7 +60,7 @@ import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
|
|||||||
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
|
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
|
||||||
import { WorktreePanel } from './board-view/worktree-panel';
|
import { WorktreePanel } from './board-view/worktree-panel';
|
||||||
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
|
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
|
||||||
import { COLUMNS } from './board-view/constants';
|
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
|
||||||
import {
|
import {
|
||||||
useBoardFeatures,
|
useBoardFeatures,
|
||||||
useBoardDragDrop,
|
useBoardDragDrop,
|
||||||
@@ -72,8 +72,9 @@ import {
|
|||||||
useBoardPersistence,
|
useBoardPersistence,
|
||||||
useFollowUpState,
|
useFollowUpState,
|
||||||
useSelectionMode,
|
useSelectionMode,
|
||||||
|
useListViewState,
|
||||||
} from './board-view/hooks';
|
} from './board-view/hooks';
|
||||||
import { SelectionActionBar } from './board-view/components';
|
import { SelectionActionBar, ListView } from './board-view/components';
|
||||||
import { MassEditDialog } from './board-view/dialogs';
|
import { MassEditDialog } from './board-view/dialogs';
|
||||||
import { InitScriptIndicator } from './board-view/init-script-indicator';
|
import { InitScriptIndicator } from './board-view/init-script-indicator';
|
||||||
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
|
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
|
||||||
@@ -194,6 +195,15 @@ export function BoardView() {
|
|||||||
} = useSelectionMode();
|
} = useSelectionMode();
|
||||||
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
|
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
|
||||||
|
|
||||||
|
// View mode state (kanban vs list)
|
||||||
|
const {
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
isListView,
|
||||||
|
sortConfig,
|
||||||
|
setSortColumn,
|
||||||
|
} = useListViewState();
|
||||||
|
|
||||||
// Search filter for Kanban cards
|
// Search filter for Kanban cards
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
// Plan approval loading state
|
// Plan approval loading state
|
||||||
@@ -1038,6 +1048,17 @@ export function BoardView() {
|
|||||||
projectPath: currentProject?.path || null,
|
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<string, typeof hookFeatures> = {};
|
||||||
|
for (const column of columns) {
|
||||||
|
map[column.id] = getColumnFeatures(column.id as any);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [pipelineConfig, getColumnFeatures]);
|
||||||
|
|
||||||
// Use background hook
|
// Use background hook
|
||||||
const { backgroundSettings, backgroundImageStyle } = useBoardBackground({
|
const { backgroundSettings, backgroundImageStyle } = useBoardBackground({
|
||||||
currentProject,
|
currentProject,
|
||||||
@@ -1225,6 +1246,8 @@ export function BoardView() {
|
|||||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||||
completedCount={completedFeatures.length}
|
completedCount={completedFeatures.length}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Worktree Panel - conditionally rendered based on visibility setting */}
|
{/* Worktree Panel - conditionally rendered based on visibility setting */}
|
||||||
@@ -1263,7 +1286,41 @@ export function BoardView() {
|
|||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* View Content - Kanban Board */}
|
{/* View Content - Kanban Board or List View */}
|
||||||
|
{isListView ? (
|
||||||
|
<ListView
|
||||||
|
columnFeaturesMap={columnFeaturesMap}
|
||||||
|
allFeatures={hookFeatures}
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSortChange={setSortColumn}
|
||||||
|
actionHandlers={{
|
||||||
|
onEdit: (feature) => 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"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<KanbanBoard
|
<KanbanBoard
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetectionStrategy={collisionDetectionStrategy}
|
collisionDetectionStrategy={collisionDetectionStrategy}
|
||||||
@@ -1294,17 +1351,18 @@ export function BoardView() {
|
|||||||
runningAutoTasks={runningAutoTasks}
|
runningAutoTasks={runningAutoTasks}
|
||||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||||
onAddFeature={() => setShowAddDialog(true)}
|
onAddFeature={() => setShowAddDialog(true)}
|
||||||
pipelineConfig={
|
pipelineConfig={pipelineConfig}
|
||||||
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
|
|
||||||
}
|
|
||||||
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
selectedFeatureIds={selectedFeatureIds}
|
selectedFeatureIds={selectedFeatureIds}
|
||||||
onToggleFeatureSelection={toggleFeatureSelection}
|
onToggleFeatureSelection={toggleFeatureSelection}
|
||||||
onToggleSelectionMode={toggleSelectionMode}
|
onToggleSelectionMode={toggleSelectionMode}
|
||||||
|
viewMode={viewMode}
|
||||||
isDragging={activeFeature !== null}
|
isDragging={activeFeature !== null}
|
||||||
onAiSuggest={() => setShowPlanDialog(true)}
|
onAiSuggest={() => setShowPlanDialog(true)}
|
||||||
|
className="transition-opacity duration-250"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selection Action Bar */}
|
{/* Selection Action Bar */}
|
||||||
@@ -1423,7 +1481,7 @@ export function BoardView() {
|
|||||||
open={showPipelineSettings}
|
open={showPipelineSettings}
|
||||||
onClose={() => setShowPipelineSettings(false)}
|
onClose={() => setShowPipelineSettings(false)}
|
||||||
projectPath={currentProject.path}
|
projectPath={currentProject.path}
|
||||||
pipelineConfig={pipelineConfigByProject[currentProject.path] || null}
|
pipelineConfig={pipelineConfig}
|
||||||
onSave={async (config) => {
|
onSave={async (config) => {
|
||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
const result = await api.pipeline.saveConfig(currentProject.path, config);
|
const result = await api.pipeline.saveConfig(currentProject.path, config);
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import { PlanSettingsDialog } from './dialogs/plan-settings-dialog';
|
|||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { BoardSearchBar } from './board-search-bar';
|
import { BoardSearchBar } from './board-search-bar';
|
||||||
import { BoardControls } from './board-controls';
|
import { BoardControls } from './board-controls';
|
||||||
|
import { ViewToggle, type ViewMode } from './components';
|
||||||
|
|
||||||
|
export type { ViewMode };
|
||||||
|
|
||||||
interface BoardHeaderProps {
|
interface BoardHeaderProps {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
@@ -33,6 +36,9 @@ interface BoardHeaderProps {
|
|||||||
onShowBoardBackground: () => void;
|
onShowBoardBackground: () => void;
|
||||||
onShowCompletedModal: () => void;
|
onShowCompletedModal: () => void;
|
||||||
completedCount: number;
|
completedCount: number;
|
||||||
|
// View toggle props
|
||||||
|
viewMode: ViewMode;
|
||||||
|
onViewModeChange: (mode: ViewMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared styles for header control containers
|
// Shared styles for header control containers
|
||||||
@@ -55,6 +61,8 @@ export function BoardHeader({
|
|||||||
onShowBoardBackground,
|
onShowBoardBackground,
|
||||||
onShowCompletedModal,
|
onShowCompletedModal,
|
||||||
completedCount,
|
completedCount,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
}: BoardHeaderProps) {
|
}: BoardHeaderProps) {
|
||||||
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
||||||
const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
|
const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
|
||||||
@@ -122,6 +130,12 @@ export function BoardHeader({
|
|||||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||||
currentProjectPath={projectPath}
|
currentProjectPath={projectPath}
|
||||||
/>
|
/>
|
||||||
|
{isMounted && (
|
||||||
|
<ViewToggle
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={onViewModeChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<BoardControls
|
<BoardControls
|
||||||
isMounted={isMounted}
|
isMounted={isMounted}
|
||||||
onShowBoardBackground={onShowBoardBackground}
|
onShowBoardBackground={onShowBoardBackground}
|
||||||
|
|||||||
@@ -2,3 +2,33 @@ export { KanbanCard } from './kanban-card/kanban-card';
|
|||||||
export { KanbanColumn } from './kanban-column';
|
export { KanbanColumn } from './kanban-column';
|
||||||
export { SelectionActionBar } from './selection-action-bar';
|
export { SelectionActionBar } from './selection-action-bar';
|
||||||
export { EmptyStateCard } from './empty-state-card';
|
export { EmptyStateCard } from './empty-state-card';
|
||||||
|
export { ViewToggle, type ViewMode } from './view-toggle';
|
||||||
|
|
||||||
|
// List view components
|
||||||
|
export {
|
||||||
|
ListHeader,
|
||||||
|
LIST_COLUMNS,
|
||||||
|
getColumnById,
|
||||||
|
getColumnWidth,
|
||||||
|
getColumnAlign,
|
||||||
|
ListRow,
|
||||||
|
getFeatureSortValue,
|
||||||
|
sortFeatures,
|
||||||
|
ListView,
|
||||||
|
getFlatFeatures,
|
||||||
|
getTotalFeatureCount,
|
||||||
|
RowActions,
|
||||||
|
createRowActionHandlers,
|
||||||
|
StatusBadge,
|
||||||
|
getStatusLabel,
|
||||||
|
getStatusOrder,
|
||||||
|
} from './list-view';
|
||||||
|
export type {
|
||||||
|
ListHeaderProps,
|
||||||
|
ListRowProps,
|
||||||
|
ListViewProps,
|
||||||
|
ListViewActionHandlers,
|
||||||
|
RowActionsProps,
|
||||||
|
RowActionHandlers,
|
||||||
|
StatusBadgeProps,
|
||||||
|
} from './list-view';
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export { ListHeader, LIST_COLUMNS, getColumnById, getColumnWidth, getColumnAlign } from './list-header';
|
||||||
|
export type { ListHeaderProps } from './list-header';
|
||||||
|
|
||||||
|
export { ListRow, getFeatureSortValue, sortFeatures } from './list-row';
|
||||||
|
export type { ListRowProps } from './list-row';
|
||||||
|
|
||||||
|
export { ListView, getFlatFeatures, getTotalFeatureCount } from './list-view';
|
||||||
|
export type { ListViewProps, ListViewActionHandlers } from './list-view';
|
||||||
|
|
||||||
|
export { RowActions, createRowActionHandlers } from './row-actions';
|
||||||
|
export type { RowActionsProps, RowActionHandlers } from './row-actions';
|
||||||
|
|
||||||
|
export { StatusBadge, getStatusLabel, getStatusOrder } from './status-badge';
|
||||||
|
export type { StatusBadgeProps } from './status-badge';
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { memo, useCallback } from 'react';
|
||||||
|
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { SortColumn, SortConfig, SortDirection } from '../../hooks/use-list-view-state';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column definition for the list header
|
||||||
|
*/
|
||||||
|
interface ColumnDef {
|
||||||
|
id: SortColumn;
|
||||||
|
label: string;
|
||||||
|
/** Whether this column is sortable */
|
||||||
|
sortable?: boolean;
|
||||||
|
/** Minimum width for the column */
|
||||||
|
minWidth?: string;
|
||||||
|
/** Width class for the column */
|
||||||
|
width?: string;
|
||||||
|
/** Alignment of the column content */
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
/** Additional className for the column */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default column definitions for the list view
|
||||||
|
*/
|
||||||
|
export const LIST_COLUMNS: ColumnDef[] = [
|
||||||
|
{
|
||||||
|
id: 'title',
|
||||||
|
label: 'Title',
|
||||||
|
sortable: true,
|
||||||
|
width: 'flex-1',
|
||||||
|
minWidth: 'min-w-[200px]',
|
||||||
|
align: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
sortable: true,
|
||||||
|
width: 'w-[140px]',
|
||||||
|
align: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'category',
|
||||||
|
label: 'Category',
|
||||||
|
sortable: true,
|
||||||
|
width: 'w-[120px]',
|
||||||
|
align: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'priority',
|
||||||
|
label: 'Priority',
|
||||||
|
sortable: true,
|
||||||
|
width: 'w-[100px]',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'createdAt',
|
||||||
|
label: 'Created',
|
||||||
|
sortable: true,
|
||||||
|
width: 'w-[110px]',
|
||||||
|
align: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'updatedAt',
|
||||||
|
label: 'Updated',
|
||||||
|
sortable: true,
|
||||||
|
width: 'w-[110px]',
|
||||||
|
align: 'left',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ListHeaderProps {
|
||||||
|
/** Current sort configuration */
|
||||||
|
sortConfig: SortConfig;
|
||||||
|
/** Callback when a sortable column is clicked */
|
||||||
|
onSortChange: (column: SortColumn) => 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 (
|
||||||
|
<ChevronsUpDown className="w-3.5 h-3.5 text-muted-foreground/50 group-hover:text-muted-foreground transition-colors" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currently sorted by this column
|
||||||
|
if (sortConfig.direction === 'asc') {
|
||||||
|
return <ChevronUp className="w-3.5 h-3.5 text-foreground" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ChevronDown className="w-3.5 h-3.5 text-foreground" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div
|
||||||
|
role="columnheader"
|
||||||
|
aria-sort={isSorted ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-1.5 px-3 py-2 text-xs font-medium text-muted-foreground',
|
||||||
|
'cursor-pointer select-none transition-colors duration-200',
|
||||||
|
'hover:text-foreground hover:bg-accent/50 rounded-md',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||||
|
column.width,
|
||||||
|
column.minWidth,
|
||||||
|
column.align === 'center' && 'justify-center',
|
||||||
|
column.align === 'right' && 'justify-end',
|
||||||
|
isSorted && 'text-foreground',
|
||||||
|
column.className
|
||||||
|
)}
|
||||||
|
data-testid={`list-header-${column.id}`}
|
||||||
|
>
|
||||||
|
<span>{column.label}</span>
|
||||||
|
<SortIcon column={column.id} sortConfig={sortConfig} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StaticColumnHeader renders a non-sortable header cell
|
||||||
|
*/
|
||||||
|
const StaticColumnHeader = memo(function StaticColumnHeader({
|
||||||
|
column,
|
||||||
|
}: {
|
||||||
|
column: ColumnDef;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="columnheader"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center px-3 py-2 text-xs font-medium text-muted-foreground',
|
||||||
|
column.width,
|
||||||
|
column.minWidth,
|
||||||
|
column.align === 'center' && 'justify-center',
|
||||||
|
column.align === 'right' && 'justify-end',
|
||||||
|
column.className
|
||||||
|
)}
|
||||||
|
data-testid={`list-header-${column.id}`}
|
||||||
|
>
|
||||||
|
<span>{column.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
*
|
||||||
|
* <ListHeader
|
||||||
|
* sortConfig={sortConfig}
|
||||||
|
* onSortChange={setSortColumn}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // With selection support
|
||||||
|
* <ListHeader
|
||||||
|
* sortConfig={sortConfig}
|
||||||
|
* onSortChange={setSortColumn}
|
||||||
|
* showCheckbox
|
||||||
|
* allSelected={allSelected}
|
||||||
|
* someSelected={someSelected}
|
||||||
|
* onSelectAll={handleSelectAll}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const ListHeader = memo(function ListHeader({
|
||||||
|
sortConfig,
|
||||||
|
onSortChange,
|
||||||
|
showCheckbox = false,
|
||||||
|
allSelected = false,
|
||||||
|
someSelected = false,
|
||||||
|
onSelectAll,
|
||||||
|
columns = LIST_COLUMNS,
|
||||||
|
className,
|
||||||
|
}: ListHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="row"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center w-full border-b border-border bg-muted/30',
|
||||||
|
'sticky top-0 z-10 backdrop-blur-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-testid="list-header"
|
||||||
|
>
|
||||||
|
{/* Checkbox column for selection */}
|
||||||
|
{showCheckbox && (
|
||||||
|
<div
|
||||||
|
role="columnheader"
|
||||||
|
className="flex items-center justify-center w-10 px-2 py-2 shrink-0"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
ref={(el) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Column headers */}
|
||||||
|
{columns.map((column) =>
|
||||||
|
column.sortable !== false ? (
|
||||||
|
<SortableColumnHeader
|
||||||
|
key={column.id}
|
||||||
|
column={column}
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSortChange={onSortChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StaticColumnHeader key={column.id} column={column} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions column (placeholder for row action buttons) */}
|
||||||
|
<div
|
||||||
|
role="columnheader"
|
||||||
|
className="w-[80px] px-3 py-2 text-xs font-medium text-muted-foreground shrink-0"
|
||||||
|
aria-label="Actions"
|
||||||
|
data-testid="list-header-actions"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 '';
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
{badges.map((badge) => (
|
||||||
|
<Tooltip key={badge.key}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center w-5 h-5 rounded border',
|
||||||
|
badge.colorClass,
|
||||||
|
badge.bgClass,
|
||||||
|
badge.borderClass,
|
||||||
|
badge.animate && 'animate-pulse'
|
||||||
|
)}
|
||||||
|
data-testid={`list-row-badge-${badge.key}`}
|
||||||
|
>
|
||||||
|
<badge.icon className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="text-xs max-w-[250px]">
|
||||||
|
<p>{badge.tooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PriorityBadge displays the priority indicator in the table
|
||||||
|
*/
|
||||||
|
const PriorityBadge = memo(function PriorityBadge({
|
||||||
|
priority,
|
||||||
|
}: {
|
||||||
|
priority: number | undefined;
|
||||||
|
}) {
|
||||||
|
const display = getPriorityDisplay(priority);
|
||||||
|
|
||||||
|
if (!display) {
|
||||||
|
return <span className="text-muted-foreground">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full border text-xs font-medium',
|
||||||
|
display.colorClass,
|
||||||
|
display.bgClass,
|
||||||
|
display.borderClass
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{display.shortLabel}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="text-xs">
|
||||||
|
<p>{display.label}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* <ListRow
|
||||||
|
* feature={feature}
|
||||||
|
* handlers={{
|
||||||
|
* onEdit: () => 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 = (
|
||||||
|
<div
|
||||||
|
role="row"
|
||||||
|
tabIndex={onClick ? 0 : undefined}
|
||||||
|
onClick={handleRowClick}
|
||||||
|
onKeyDown={onClick ? handleKeyDown : undefined}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center w-full border-b border-border/50',
|
||||||
|
'transition-colors duration-200',
|
||||||
|
onClick && 'cursor-pointer',
|
||||||
|
'hover:bg-accent/50',
|
||||||
|
isSelected && 'bg-accent/70',
|
||||||
|
hasError && 'bg-[var(--status-error)]/5 hover:bg-[var(--status-error)]/10',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-testid={`list-row-${feature.id}`}
|
||||||
|
>
|
||||||
|
{/* Checkbox column */}
|
||||||
|
{showCheckbox && (
|
||||||
|
<div
|
||||||
|
role="cell"
|
||||||
|
className="flex items-center justify-center w-10 px-2 py-3 shrink-0"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={handleCheckboxChange}
|
||||||
|
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={`Select ${feature.title || feature.description}`}
|
||||||
|
data-testid={`list-row-checkbox-${feature.id}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title column */}
|
||||||
|
<div
|
||||||
|
role="cell"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center px-3 py-3 gap-2',
|
||||||
|
getColumnWidth('title'),
|
||||||
|
getColumnAlign('title')
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-medium truncate',
|
||||||
|
feature.titleGenerating && 'animate-pulse text-muted-foreground'
|
||||||
|
)}
|
||||||
|
title={feature.title || feature.description}
|
||||||
|
>
|
||||||
|
{feature.title || feature.description}
|
||||||
|
</span>
|
||||||
|
<IndicatorBadges
|
||||||
|
feature={feature}
|
||||||
|
blockingDependencies={blockingDependencies}
|
||||||
|
isCurrentAutoTask={isCurrentAutoTask}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Show description as subtitle if title exists and is different */}
|
||||||
|
{feature.title && feature.title !== feature.description && (
|
||||||
|
<p
|
||||||
|
className="text-xs text-muted-foreground truncate mt-0.5"
|
||||||
|
title={feature.description}
|
||||||
|
>
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status column */}
|
||||||
|
<div
|
||||||
|
role="cell"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center px-3 py-3',
|
||||||
|
getColumnWidth('status'),
|
||||||
|
getColumnAlign('status')
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StatusBadge
|
||||||
|
status={feature.status}
|
||||||
|
pipelineConfig={pipelineConfig}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category column */}
|
||||||
|
<div
|
||||||
|
role="cell"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center px-3 py-3 text-sm text-muted-foreground truncate',
|
||||||
|
getColumnWidth('category'),
|
||||||
|
getColumnAlign('category')
|
||||||
|
)}
|
||||||
|
title={feature.category}
|
||||||
|
>
|
||||||
|
{feature.category || '-'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority column */}
|
||||||
|
<div
|
||||||
|
role="cell"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center px-3 py-3',
|
||||||
|
getColumnWidth('priority'),
|
||||||
|
getColumnAlign('priority')
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PriorityBadge priority={feature.priority} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Created At column */}
|
||||||
|
<div
|
||||||
|
role="cell"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center px-3 py-3 text-sm text-muted-foreground',
|
||||||
|
getColumnWidth('createdAt'),
|
||||||
|
getColumnAlign('createdAt')
|
||||||
|
)}
|
||||||
|
title={feature.createdAt ? new Date(feature.createdAt).toLocaleString() : undefined}
|
||||||
|
>
|
||||||
|
{formatRelativeDate(feature.createdAt)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Updated At column */}
|
||||||
|
<div
|
||||||
|
role="cell"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center px-3 py-3 text-sm text-muted-foreground',
|
||||||
|
getColumnWidth('updatedAt'),
|
||||||
|
getColumnAlign('updatedAt')
|
||||||
|
)}
|
||||||
|
title={feature.updatedAt ? new Date(feature.updatedAt).toLocaleString() : undefined}
|
||||||
|
>
|
||||||
|
{formatRelativeDate(feature.updatedAt)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions column */}
|
||||||
|
<div
|
||||||
|
role="cell"
|
||||||
|
className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0"
|
||||||
|
>
|
||||||
|
<RowActions
|
||||||
|
feature={feature}
|
||||||
|
handlers={handlers}
|
||||||
|
isCurrentAutoTask={isCurrentAutoTask}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrap with animated border for currently running auto task
|
||||||
|
if (isCurrentAutoTask) {
|
||||||
|
return (
|
||||||
|
<div className="animated-border-wrapper-row">
|
||||||
|
{rowContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<string, Feature[]>;
|
||||||
|
/** 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<string>;
|
||||||
|
/** 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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 w-full px-3 py-2 text-left',
|
||||||
|
'bg-muted/50 hover:bg-muted/70 transition-colors duration-200',
|
||||||
|
'border-b border-border/50',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset'
|
||||||
|
)}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
data-testid={`list-group-header-${group.id}`}
|
||||||
|
>
|
||||||
|
{/* Collapse indicator */}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Status color indicator */}
|
||||||
|
<span
|
||||||
|
className={cn('w-2.5 h-2.5 rounded-full shrink-0', group.colorClass)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Group title */}
|
||||||
|
<span className="font-medium text-sm">{group.title}</span>
|
||||||
|
|
||||||
|
{/* Feature count */}
|
||||||
|
<span className="text-xs text-muted-foreground">({group.features.length})</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EmptyState displays a message when there are no features
|
||||||
|
*/
|
||||||
|
const EmptyState = memo(function EmptyState({
|
||||||
|
onAddFeature,
|
||||||
|
}: {
|
||||||
|
onAddFeature?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center py-16 px-4',
|
||||||
|
'text-center text-muted-foreground'
|
||||||
|
)}
|
||||||
|
data-testid="list-view-empty"
|
||||||
|
>
|
||||||
|
<p className="text-sm mb-4">No features to display</p>
|
||||||
|
{onAddFeature && (
|
||||||
|
<Button variant="outline" size="sm" onClick={onAddFeature}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Feature
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, ... });
|
||||||
|
*
|
||||||
|
* <ListView
|
||||||
|
* columnFeaturesMap={columnFeaturesMap}
|
||||||
|
* allFeatures={features}
|
||||||
|
* sortConfig={sortConfig}
|
||||||
|
* onSortChange={setSortColumn}
|
||||||
|
* actionHandlers={{
|
||||||
|
* onEdit: handleEdit,
|
||||||
|
* onDelete: handleDelete,
|
||||||
|
* // ...
|
||||||
|
* }}
|
||||||
|
* runningAutoTasks={runningAutoTasks}
|
||||||
|
* pipelineConfig={pipelineConfig}
|
||||||
|
* onAddFeature={handleAddFeature}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
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<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Generate status groups from columnFeaturesMap
|
||||||
|
const statusGroups = useMemo<StatusGroup[]>(() => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col h-full bg-background', className)}
|
||||||
|
data-testid="list-view"
|
||||||
|
>
|
||||||
|
<EmptyState onAddFeature={onAddFeature} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col h-full bg-background', className)}
|
||||||
|
role="table"
|
||||||
|
aria-label="Features list"
|
||||||
|
data-testid="list-view"
|
||||||
|
>
|
||||||
|
{/* Table header */}
|
||||||
|
<ListHeader
|
||||||
|
sortConfig={sortConfig}
|
||||||
|
onSortChange={onSortChange}
|
||||||
|
showCheckbox={isSelectionMode}
|
||||||
|
allSelected={selectionState.allSelected}
|
||||||
|
someSelected={selectionState.someSelected}
|
||||||
|
onSelectAll={handleSelectAll}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Table body with status groups */}
|
||||||
|
<div className="flex-1 overflow-y-auto" role="rowgroup">
|
||||||
|
{statusGroups.map((group) => {
|
||||||
|
const isExpanded = !collapsedGroups.has(group.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.id}
|
||||||
|
className="border-b border-border/30"
|
||||||
|
data-testid={`list-group-${group.id}`}
|
||||||
|
>
|
||||||
|
{/* Group header */}
|
||||||
|
<StatusGroupHeader
|
||||||
|
group={group}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onToggle={() => toggleGroup(group.id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Group rows */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div role="rowgroup">
|
||||||
|
{group.features.map((feature) => (
|
||||||
|
<ListRow
|
||||||
|
key={feature.id}
|
||||||
|
feature={feature}
|
||||||
|
handlers={createHandlers(feature)}
|
||||||
|
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||||
|
pipelineConfig={pipelineConfig}
|
||||||
|
isSelected={selectedFeatureIds.has(feature.id)}
|
||||||
|
showCheckbox={isSelectionMode}
|
||||||
|
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||||
|
onClick={() => onRowClick?.(feature)}
|
||||||
|
blockingDependencies={getBlockingDeps(feature)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer with Add Feature button */}
|
||||||
|
{onAddFeature && (
|
||||||
|
<div className="border-t border-border px-4 py-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onAddFeature}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
data-testid="list-view-add-feature"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Feature
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get all features from the columnFeaturesMap as a flat array
|
||||||
|
*/
|
||||||
|
export function getFlatFeatures(
|
||||||
|
columnFeaturesMap: Record<string, Feature[]>
|
||||||
|
): Feature[] {
|
||||||
|
return Object.values(columnFeaturesMap).flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to count total features across all groups
|
||||||
|
*/
|
||||||
|
export function getTotalFeatureCount(
|
||||||
|
columnFeaturesMap: Record<string, Feature[]>
|
||||||
|
): number {
|
||||||
|
return Object.values(columnFeaturesMap).reduce(
|
||||||
|
(sum, features) => sum + features.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn('gap-2', variantClasses[variant])}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* <RowActions
|
||||||
|
* feature={feature}
|
||||||
|
* handlers={{
|
||||||
|
* onEdit: () => 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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200',
|
||||||
|
'focus-within:opacity-100',
|
||||||
|
open && 'opacity-100',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`row-actions-${feature.id}`}
|
||||||
|
>
|
||||||
|
{/* Primary action quick button */}
|
||||||
|
{primaryAction && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(
|
||||||
|
'h-7 w-7',
|
||||||
|
primaryAction.variant === 'destructive' && 'hover:bg-destructive/10 hover:text-destructive',
|
||||||
|
primaryAction.variant === 'primary' && 'hover:bg-primary/10 hover:text-primary',
|
||||||
|
primaryAction.variant === 'success' && 'hover:bg-[var(--status-success)]/10 hover:text-[var(--status-success)]',
|
||||||
|
primaryAction.variant === 'warning' && 'hover:bg-[var(--status-waiting)]/10 hover:text-[var(--status-waiting)]'
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
primaryAction.onClick();
|
||||||
|
}}
|
||||||
|
title={primaryAction.label}
|
||||||
|
data-testid={`row-action-primary-${feature.id}`}
|
||||||
|
>
|
||||||
|
<primaryAction.icon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dropdown menu */}
|
||||||
|
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="h-7 w-7"
|
||||||
|
data-testid={`row-actions-trigger-${feature.id}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
<span className="sr-only">Open actions menu</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
{/* Running task actions */}
|
||||||
|
{isCurrentAutoTask && (
|
||||||
|
<>
|
||||||
|
{handlers.onViewOutput && (
|
||||||
|
<MenuItem
|
||||||
|
icon={FileText}
|
||||||
|
label="View Logs"
|
||||||
|
onClick={withClose(handlers.onViewOutput)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{feature.planSpec?.status === 'generated' && handlers.onApprovePlan && (
|
||||||
|
<MenuItem
|
||||||
|
icon={FileText}
|
||||||
|
label="Approve Plan"
|
||||||
|
onClick={withClose(handlers.onApprovePlan)}
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.onForceStop && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
icon={StopCircle}
|
||||||
|
label="Force Stop"
|
||||||
|
onClick={withClose(handlers.onForceStop)}
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Backlog actions */}
|
||||||
|
{!isCurrentAutoTask && feature.status === 'backlog' && (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
icon={Edit}
|
||||||
|
label="Edit"
|
||||||
|
onClick={withClose(handlers.onEdit)}
|
||||||
|
/>
|
||||||
|
{feature.planSpec?.content && handlers.onViewPlan && (
|
||||||
|
<MenuItem
|
||||||
|
icon={Eye}
|
||||||
|
label="View Plan"
|
||||||
|
onClick={withClose(handlers.onViewPlan)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.onImplement && (
|
||||||
|
<MenuItem
|
||||||
|
icon={PlayCircle}
|
||||||
|
label="Make"
|
||||||
|
onClick={withClose(handlers.onImplement)}
|
||||||
|
variant="primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label="Delete"
|
||||||
|
onClick={withClose(handlers.onDelete)}
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* In Progress actions */}
|
||||||
|
{!isCurrentAutoTask && feature.status === 'in_progress' && (
|
||||||
|
<>
|
||||||
|
{handlers.onViewOutput && (
|
||||||
|
<MenuItem
|
||||||
|
icon={FileText}
|
||||||
|
label="View Logs"
|
||||||
|
onClick={withClose(handlers.onViewOutput)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{feature.planSpec?.status === 'generated' && handlers.onApprovePlan && (
|
||||||
|
<MenuItem
|
||||||
|
icon={FileText}
|
||||||
|
label="Approve Plan"
|
||||||
|
onClick={withClose(handlers.onApprovePlan)}
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{feature.skipTests && handlers.onManualVerify ? (
|
||||||
|
<MenuItem
|
||||||
|
icon={CheckCircle2}
|
||||||
|
label="Verify"
|
||||||
|
onClick={withClose(handlers.onManualVerify)}
|
||||||
|
variant="success"
|
||||||
|
/>
|
||||||
|
) : handlers.onResume ? (
|
||||||
|
<MenuItem
|
||||||
|
icon={RotateCcw}
|
||||||
|
label="Resume"
|
||||||
|
onClick={withClose(handlers.onResume)}
|
||||||
|
variant="success"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
icon={Edit}
|
||||||
|
label="Edit"
|
||||||
|
onClick={withClose(handlers.onEdit)}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label="Delete"
|
||||||
|
onClick={withClose(handlers.onDelete)}
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Waiting Approval actions */}
|
||||||
|
{!isCurrentAutoTask && feature.status === 'waiting_approval' && (
|
||||||
|
<>
|
||||||
|
{handlers.onViewOutput && (
|
||||||
|
<MenuItem
|
||||||
|
icon={FileText}
|
||||||
|
label="View Logs"
|
||||||
|
onClick={withClose(handlers.onViewOutput)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.onFollowUp && (
|
||||||
|
<MenuItem
|
||||||
|
icon={Wand2}
|
||||||
|
label="Refine"
|
||||||
|
onClick={withClose(handlers.onFollowUp)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{feature.prUrl && (
|
||||||
|
<MenuItem
|
||||||
|
icon={ExternalLink}
|
||||||
|
label="View PR"
|
||||||
|
onClick={withClose(() => window.open(feature.prUrl, '_blank'))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.onManualVerify && (
|
||||||
|
<MenuItem
|
||||||
|
icon={CheckCircle2}
|
||||||
|
label={feature.prUrl ? 'Verify' : 'Mark as Verified'}
|
||||||
|
onClick={withClose(handlers.onManualVerify)}
|
||||||
|
variant="success"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
icon={Edit}
|
||||||
|
label="Edit"
|
||||||
|
onClick={withClose(handlers.onEdit)}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label="Delete"
|
||||||
|
onClick={withClose(handlers.onDelete)}
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Verified actions */}
|
||||||
|
{!isCurrentAutoTask && feature.status === 'verified' && (
|
||||||
|
<>
|
||||||
|
{handlers.onViewOutput && (
|
||||||
|
<MenuItem
|
||||||
|
icon={FileText}
|
||||||
|
label="View Logs"
|
||||||
|
onClick={withClose(handlers.onViewOutput)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{feature.prUrl && (
|
||||||
|
<MenuItem
|
||||||
|
icon={ExternalLink}
|
||||||
|
label="View PR"
|
||||||
|
onClick={withClose(() => window.open(feature.prUrl, '_blank'))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{feature.worktree && (
|
||||||
|
<MenuItem
|
||||||
|
icon={GitBranch}
|
||||||
|
label="View Branch"
|
||||||
|
onClick={withClose(() => {})}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.onComplete && (
|
||||||
|
<MenuItem
|
||||||
|
icon={Archive}
|
||||||
|
label="Complete"
|
||||||
|
onClick={withClose(handlers.onComplete)}
|
||||||
|
variant="primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
icon={Edit}
|
||||||
|
label="Edit"
|
||||||
|
onClick={withClose(handlers.onEdit)}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label="Delete"
|
||||||
|
onClick={withClose(handlers.onDelete)}
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pipeline status actions (generic fallback) */}
|
||||||
|
{!isCurrentAutoTask &&
|
||||||
|
feature.status.startsWith('pipeline_') && (
|
||||||
|
<>
|
||||||
|
{handlers.onViewOutput && (
|
||||||
|
<MenuItem
|
||||||
|
icon={FileText}
|
||||||
|
label="View Logs"
|
||||||
|
onClick={withClose(handlers.onViewOutput)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MenuItem
|
||||||
|
icon={Edit}
|
||||||
|
label="Edit"
|
||||||
|
onClick={withClose(handlers.onEdit)}
|
||||||
|
/>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label="Delete"
|
||||||
|
onClick={withClose(handlers.onDelete)}
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, StatusDisplay> = {
|
||||||
|
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
|
||||||
|
* <StatusBadge status="backlog" />
|
||||||
|
*
|
||||||
|
* // With pipeline configuration
|
||||||
|
* <StatusBadge status="pipeline_review" pipelineConfig={pipelineConfig} />
|
||||||
|
*
|
||||||
|
* // Small size
|
||||||
|
* <StatusBadge status="verified" size="sm" />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
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 (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full border font-medium whitespace-nowrap',
|
||||||
|
'transition-colors duration-200',
|
||||||
|
sizeClasses[size],
|
||||||
|
display.colorClass,
|
||||||
|
display.bgClass,
|
||||||
|
display.borderClass,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-testid={`status-badge-${status}`}
|
||||||
|
>
|
||||||
|
{display.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, number> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-8 items-center rounded-md bg-muted p-[3px] border border-border',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="tablist"
|
||||||
|
aria-label="View mode"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === 'kanban'}
|
||||||
|
aria-label="Kanban view"
|
||||||
|
onClick={() => onViewModeChange('kanban')}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium transition-all duration-200 cursor-pointer',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
viewMode === 'kanban'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-md'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
|
)}
|
||||||
|
data-testid="view-toggle-kanban"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-4 h-4" />
|
||||||
|
<span className="sr-only sm:not-sr-only">Kanban</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === 'list'}
|
||||||
|
aria-label="List view"
|
||||||
|
onClick={() => onViewModeChange('list')}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium transition-all duration-200 cursor-pointer',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-md'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
|
)}
|
||||||
|
data-testid="view-toggle-list"
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
<span className="sr-only sm:not-sr-only">List</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,3 +8,4 @@ export { useBoardBackground } from './use-board-background';
|
|||||||
export { useBoardPersistence } from './use-board-persistence';
|
export { useBoardPersistence } from './use-board-persistence';
|
||||||
export { useFollowUpState } from './use-follow-up-state';
|
export { useFollowUpState } from './use-follow-up-state';
|
||||||
export { useSelectionMode } from './use-selection-mode';
|
export { useSelectionMode } from './use-selection-mode';
|
||||||
|
export { useListViewState } from './use-list-view-state';
|
||||||
|
|||||||
@@ -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<Partial<ListViewPersistedState>>(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
|
||||||
|
* <ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||||
|
*
|
||||||
|
* // Sort by column (clicking same column toggles direction)
|
||||||
|
* <TableHeader onClick={() => setSortColumn('title')}>Title</TableHeader>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useListViewState(): UseListViewStateReturn {
|
||||||
|
// Initialize state from localStorage
|
||||||
|
const [viewMode, setViewModeState] = useState<ViewMode>(() => loadPersistedState().viewMode);
|
||||||
|
const [sortConfig, setSortConfigState] = useState<SortConfig>(() => 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]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-reac
|
|||||||
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
||||||
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
||||||
import type { PipelineConfig } from '@automaker/types';
|
import type { PipelineConfig } from '@automaker/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ViewMode } from './hooks/use-list-view-state';
|
||||||
|
|
||||||
interface KanbanBoardProps {
|
interface KanbanBoardProps {
|
||||||
sensors: any;
|
sensors: any;
|
||||||
@@ -57,6 +59,10 @@ interface KanbanBoardProps {
|
|||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
/** Whether the board is in read-only mode */
|
/** Whether the board is in read-only mode */
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
|
// View mode for transition animation
|
||||||
|
viewMode?: ViewMode;
|
||||||
|
/** Additional className for custom styling (e.g., transition classes) */
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanBoard({
|
export function KanbanBoard({
|
||||||
@@ -95,6 +101,8 @@ export function KanbanBoard({
|
|||||||
onAiSuggest,
|
onAiSuggest,
|
||||||
isDragging = false,
|
isDragging = false,
|
||||||
isReadOnly = false,
|
isReadOnly = false,
|
||||||
|
viewMode,
|
||||||
|
className,
|
||||||
}: KanbanBoardProps) {
|
}: KanbanBoardProps) {
|
||||||
// Generate columns including pipeline steps
|
// Generate columns including pipeline steps
|
||||||
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
||||||
@@ -108,7 +116,14 @@ export function KanbanBoard({
|
|||||||
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-x-auto px-5 pt-4 pb-4 relative" style={backgroundImageStyle}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex-1 overflow-x-auto px-5 pt-4 pb-4 relative',
|
||||||
|
'transition-opacity duration-250',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={backgroundImageStyle}
|
||||||
|
>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={collisionDetectionStrategy}
|
collisionDetection={collisionDetectionStrategy}
|
||||||
|
|||||||
Reference in New Issue
Block a user