mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
feat: add empty state card component and integrate AI suggestion functionality
- Introduced the EmptyStateCard component to display contextual guidance when columns are empty. - Enhanced the KanbanBoard and BoardView components to utilize the new EmptyStateCard for improved user experience. - Added AI suggestion functionality to the empty state configuration, allowing users to generate ideas directly from the backlog column. - Updated constants to define empty state configurations for various column types.
This commit is contained in:
@@ -1287,6 +1287,8 @@ export function BoardView() {
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onToggleFeatureSelection={toggleFeatureSelection}
|
||||
onToggleSelectionMode={toggleSelectionMode}
|
||||
isDragging={activeFeature !== null}
|
||||
onAiSuggest={() => setShowPlanDialog(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import { getEmptyStateConfig, type EmptyStateConfig } from '../constants';
|
||||
import {
|
||||
Lightbulb,
|
||||
Play,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
Sparkles,
|
||||
Wand2,
|
||||
X,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
const ICON_MAP = {
|
||||
lightbulb: Lightbulb,
|
||||
play: Play,
|
||||
clock: Clock,
|
||||
check: CheckCircle2,
|
||||
sparkles: Sparkles,
|
||||
} as const;
|
||||
|
||||
interface EmptyStateCardProps {
|
||||
columnId: string;
|
||||
columnTitle?: string;
|
||||
/** Keyboard shortcut for adding features (from settings) */
|
||||
addFeatureShortcut?: string;
|
||||
/** Whether the column is empty due to active filters */
|
||||
isFilteredEmpty?: boolean;
|
||||
/** Whether we're in read-only mode (hide actions) */
|
||||
isReadOnly?: boolean;
|
||||
/** Called when user clicks "Use AI Suggestions" */
|
||||
onAiSuggest?: () => void;
|
||||
/** Card opacity (matches board settings) */
|
||||
opacity?: number;
|
||||
/** Enable glassmorphism effect */
|
||||
glassmorphism?: boolean;
|
||||
/** Custom config override for pipeline steps */
|
||||
customConfig?: Partial<EmptyStateConfig>;
|
||||
}
|
||||
|
||||
export const EmptyStateCard = memo(function EmptyStateCard({
|
||||
columnId,
|
||||
columnTitle,
|
||||
addFeatureShortcut,
|
||||
isFilteredEmpty = false,
|
||||
isReadOnly = false,
|
||||
onAiSuggest,
|
||||
opacity = 100,
|
||||
glassmorphism = true,
|
||||
customConfig,
|
||||
}: EmptyStateCardProps) {
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
|
||||
// Get base config and merge with custom overrides
|
||||
const baseConfig = getEmptyStateConfig(columnId);
|
||||
const config: EmptyStateConfig = { ...baseConfig, ...customConfig };
|
||||
|
||||
// Handle dismissal
|
||||
if (isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const IconComponent = ICON_MAP[config.icon];
|
||||
const showActions = !isReadOnly && !isFilteredEmpty;
|
||||
const showShortcut = columnId === 'backlog' && addFeatureShortcut && showActions;
|
||||
|
||||
// Minimized state - compact indicator
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsMinimized(false)}
|
||||
className={cn(
|
||||
'w-full p-3 rounded-lg',
|
||||
'border-2 border-dashed border-border/40',
|
||||
'bg-card/30 hover:bg-card/50',
|
||||
'transition-all duration-200 ease-out',
|
||||
'flex items-center justify-center gap-2',
|
||||
'text-muted-foreground/60 hover:text-muted-foreground',
|
||||
'cursor-pointer group',
|
||||
'animate-in fade-in duration-300'
|
||||
)}
|
||||
data-testid={`empty-state-minimized-${columnId}`}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5 group-hover:scale-110 transition-transform" />
|
||||
<span className="text-xs font-medium">Show guidance</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Action button handler
|
||||
const handlePrimaryAction = () => {
|
||||
if (!config.primaryAction) return;
|
||||
if (config.primaryAction.actionType === 'ai-suggest') {
|
||||
onAiSuggest?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-xl overflow-hidden',
|
||||
'border-2 border-dashed border-border/50',
|
||||
'transition-all duration-300 ease-out',
|
||||
'animate-in fade-in slide-in-from-top-2 duration-300',
|
||||
'hover:border-border/70'
|
||||
)}
|
||||
data-testid={`empty-state-card-${columnId}`}
|
||||
>
|
||||
{/* Background with opacity */}
|
||||
<div
|
||||
className={cn('absolute inset-0 bg-card/50 -z-10', glassmorphism && 'backdrop-blur-sm')}
|
||||
style={{ opacity: opacity / 100 }}
|
||||
/>
|
||||
|
||||
{/* Dismiss/Minimize controls */}
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1 z-10">
|
||||
<button
|
||||
onClick={() => setIsMinimized(true)}
|
||||
className="p-1 rounded-md text-muted-foreground/40 hover:text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
title="Minimize guidance"
|
||||
>
|
||||
<EyeOff className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsDismissed(true)}
|
||||
className="p-1 rounded-md text-muted-foreground/40 hover:text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Header with icon */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center shrink-0',
|
||||
'bg-primary/10 text-primary/70'
|
||||
)}
|
||||
>
|
||||
<IconComponent className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<h4 className="font-medium text-sm text-foreground/90 mb-1">
|
||||
{isFilteredEmpty ? 'No Matching Items' : config.title}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground/70 leading-relaxed">
|
||||
{isFilteredEmpty
|
||||
? 'No features match your current filters. Try adjusting your filter criteria.'
|
||||
: config.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard shortcut hint */}
|
||||
{showShortcut && (
|
||||
<div className="flex items-center gap-2 py-2 px-3 rounded-lg bg-muted/30 border border-border/30">
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
{config.shortcutHint || 'Press'}
|
||||
</span>
|
||||
<Kbd className="bg-background/80 border border-border/50 px-2 py-0.5 font-semibold">
|
||||
{formatShortcut(addFeatureShortcut, true)}
|
||||
</Kbd>
|
||||
<span className="text-xs text-muted-foreground/70">to add a feature</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Example card preview */}
|
||||
{config.exampleCard && (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 rounded-lg',
|
||||
'border border-dashed border-border/30',
|
||||
'bg-muted/20',
|
||||
'opacity-60'
|
||||
)}
|
||||
>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground/50 mb-1">
|
||||
{config.exampleCard.category}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-muted-foreground/60">
|
||||
{config.exampleCard.title}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{showActions && config.primaryAction && config.primaryAction.actionType !== 'none' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-8 text-xs border-dashed"
|
||||
onClick={handlePrimaryAction}
|
||||
data-testid={`empty-state-primary-action-${columnId}`}
|
||||
>
|
||||
<Wand2 className="w-3.5 h-3.5 mr-1.5" />
|
||||
{config.primaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Filtered empty state hint */}
|
||||
{isFilteredEmpty && (
|
||||
<p className="text-xs text-center text-muted-foreground/50 italic">
|
||||
Clear filters to see all items
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
export { KanbanCard } from './kanban-card/kanban-card';
|
||||
export { KanbanColumn } from './kanban-column';
|
||||
export { SelectionActionBar } from './selection-action-bar';
|
||||
export { EmptyStateCard } from './empty-state-card';
|
||||
|
||||
@@ -3,6 +3,93 @@ import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types
|
||||
|
||||
export type ColumnId = Feature['status'];
|
||||
|
||||
/**
|
||||
* Empty state configuration for each column type
|
||||
*/
|
||||
export interface EmptyStateConfig {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: 'lightbulb' | 'play' | 'clock' | 'check' | 'sparkles';
|
||||
shortcutKey?: string; // Keyboard shortcut label (e.g., 'N', 'A')
|
||||
shortcutHint?: string; // Human-readable shortcut hint
|
||||
primaryAction?: {
|
||||
label: string;
|
||||
actionType: 'ai-suggest' | 'none';
|
||||
};
|
||||
exampleCard?: {
|
||||
title: string;
|
||||
category: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default empty state configurations per column type
|
||||
*/
|
||||
export const EMPTY_STATE_CONFIGS: Record<string, EmptyStateConfig> = {
|
||||
backlog: {
|
||||
title: 'Ready for Ideas',
|
||||
description:
|
||||
'Add your first feature idea to get started using the button below, or let AI help generate ideas.',
|
||||
icon: 'lightbulb',
|
||||
shortcutHint: 'Press',
|
||||
primaryAction: {
|
||||
label: 'Use AI Suggestions',
|
||||
actionType: 'ai-suggest',
|
||||
},
|
||||
exampleCard: {
|
||||
title: 'User Authentication',
|
||||
category: 'Core Feature',
|
||||
},
|
||||
},
|
||||
in_progress: {
|
||||
title: 'Nothing in Progress',
|
||||
description: 'Drag a feature from the backlog here or click implement to start working on it.',
|
||||
icon: 'play',
|
||||
exampleCard: {
|
||||
title: 'Implementing feature...',
|
||||
category: 'In Development',
|
||||
},
|
||||
},
|
||||
waiting_approval: {
|
||||
title: 'No Items Awaiting Approval',
|
||||
description: 'Features will appear here after implementation is complete and need your review.',
|
||||
icon: 'clock',
|
||||
exampleCard: {
|
||||
title: 'Ready for Review',
|
||||
category: 'Completed',
|
||||
},
|
||||
},
|
||||
verified: {
|
||||
title: 'No Verified Features',
|
||||
description: 'Approved features will appear here. They can then be completed and archived.',
|
||||
icon: 'check',
|
||||
exampleCard: {
|
||||
title: 'Approved & Ready',
|
||||
category: 'Verified',
|
||||
},
|
||||
},
|
||||
// Pipeline step default configuration
|
||||
pipeline_default: {
|
||||
title: 'Pipeline Step Empty',
|
||||
description: 'Features will flow through this step during the automated pipeline process.',
|
||||
icon: 'sparkles',
|
||||
exampleCard: {
|
||||
title: 'Processing...',
|
||||
category: 'Pipeline',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get empty state config for a column, with fallback for pipeline columns
|
||||
*/
|
||||
export function getEmptyStateConfig(columnId: string): EmptyStateConfig {
|
||||
if (columnId.startsWith('pipeline_')) {
|
||||
return EMPTY_STATE_CONFIGS.pipeline_default;
|
||||
}
|
||||
return EMPTY_STATE_CONFIGS[columnId] || EMPTY_STATE_CONFIGS.backlog;
|
||||
}
|
||||
|
||||
export interface Column {
|
||||
id: FeatureStatusWithPipeline;
|
||||
title: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import { DndContext, DragOverlay } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { KanbanColumn, KanbanCard } from './components';
|
||||
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
|
||||
import { Feature, useAppStore, formatShortcut } from '@/store/app-store';
|
||||
import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-react';
|
||||
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
||||
@@ -51,6 +51,12 @@ interface KanbanBoardProps {
|
||||
selectedFeatureIds?: Set<string>;
|
||||
onToggleFeatureSelection?: (featureId: string) => void;
|
||||
onToggleSelectionMode?: () => void;
|
||||
// Empty state action props
|
||||
onAiSuggest?: () => void;
|
||||
/** Whether currently dragging (hides empty states during drag) */
|
||||
isDragging?: boolean;
|
||||
/** Whether the board is in read-only mode */
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export function KanbanBoard({
|
||||
@@ -86,6 +92,9 @@ export function KanbanBoard({
|
||||
selectedFeatureIds = new Set(),
|
||||
onToggleFeatureSelection,
|
||||
onToggleSelectionMode,
|
||||
onAiSuggest,
|
||||
isDragging = false,
|
||||
isReadOnly = false,
|
||||
}: KanbanBoardProps) {
|
||||
// Generate columns including pipeline steps
|
||||
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
||||
@@ -211,6 +220,26 @@ export function KanbanBoard({
|
||||
items={columnFeatures.map((f) => f.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{/* Empty state card when column has no features */}
|
||||
{columnFeatures.length === 0 && !isDragging && (
|
||||
<EmptyStateCard
|
||||
columnId={column.id}
|
||||
columnTitle={column.title}
|
||||
addFeatureShortcut={addFeatureShortcut}
|
||||
isReadOnly={isReadOnly}
|
||||
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
|
||||
opacity={backgroundSettings.cardOpacity}
|
||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
||||
customConfig={
|
||||
column.isPipelineStep
|
||||
? {
|
||||
title: `${column.title} Empty`,
|
||||
description: `Features will appear here during the ${column.title.toLowerCase()} phase of the pipeline.`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{columnFeatures.map((feature, index) => {
|
||||
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
||||
let shortcutKey: string | undefined;
|
||||
|
||||
Reference in New Issue
Block a user