feat: implement bulk feature verification and enhance selection mode

- Added functionality for bulk verifying features in the BoardView, allowing users to mark multiple features as verified at once.
- Introduced a selection target mechanism to differentiate between 'backlog' and 'waiting_approval' features during selection mode.
- Updated the KanbanCard and SelectionActionBar components to support the new selection target logic, improving user experience for bulk actions.
- Enhanced the UI to provide appropriate actions based on the current selection target, including verification options for waiting approval features.
This commit is contained in:
webdevcody
2026-01-15 13:14:15 -05:00
parent 241fd0b252
commit 06b047cfcb
10 changed files with 348 additions and 73 deletions

View File

@@ -65,6 +65,7 @@ interface KanbanCardProps {
isSelectionMode?: boolean;
isSelected?: boolean;
onToggleSelect?: () => void;
selectionTarget?: 'backlog' | 'waiting_approval' | null;
}
export const KanbanCard = memo(function KanbanCard({
@@ -96,6 +97,7 @@ export const KanbanCard = memo(function KanbanCard({
isSelectionMode = false,
isSelected = false,
onToggleSelect,
selectionTarget = null,
}: KanbanCardProps) {
const { useWorktrees } = useAppStore();
const [isLifted, setIsLifted] = useState(false);
@@ -125,8 +127,8 @@ export const KanbanCard = memo(function KanbanCard({
const cardStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity);
// Only allow selection for backlog features
const isSelectable = isSelectionMode && feature.status === 'backlog';
// Only allow selection for features matching the selection target
const isSelectable = isSelectionMode && feature.status === selectionTarget;
const wrapperClasses = cn(
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
@@ -180,7 +182,7 @@ export const KanbanCard = memo(function KanbanCard({
{/* Category row with selection checkbox */}
<div className="px-3 pt-3 flex items-center gap-2">
{isSelectionMode && !isOverlay && feature.status === 'backlog' && (
{isSelectable && !isOverlay && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect?.()}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Pencil, X, CheckSquare, Trash2 } from 'lucide-react';
import { Pencil, X, CheckSquare, Trash2, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Dialog,
@@ -11,13 +11,17 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
export type SelectionActionMode = 'backlog' | 'waiting_approval';
interface SelectionActionBarProps {
selectedCount: number;
totalCount: number;
onEdit: () => void;
onDelete: () => void;
onEdit?: () => void;
onDelete?: () => void;
onVerify?: () => void;
onClear: () => void;
onSelectAll: () => void;
mode?: SelectionActionMode;
}
export function SelectionActionBar({
@@ -25,10 +29,13 @@ export function SelectionActionBar({
totalCount,
onEdit,
onDelete,
onVerify,
onClear,
onSelectAll,
mode = 'backlog',
}: SelectionActionBarProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showVerifyDialog, setShowVerifyDialog] = useState(false);
const allSelected = selectedCount === totalCount && totalCount > 0;
@@ -38,7 +45,16 @@ export function SelectionActionBar({
const handleConfirmDelete = () => {
setShowDeleteDialog(false);
onDelete();
onDelete?.();
};
const handleVerifyClick = () => {
setShowVerifyDialog(true);
};
const handleConfirmVerify = () => {
setShowVerifyDialog(false);
onVerify?.();
};
return (
@@ -54,36 +70,56 @@ export function SelectionActionBar({
>
<span className="text-sm font-medium text-foreground">
{selectedCount === 0
? 'Select features to edit'
? mode === 'waiting_approval'
? 'Select features to verify'
: 'Select features to edit'
: `${selectedCount} feature${selectedCount !== 1 ? 's' : ''} selected`}
</span>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={onEdit}
disabled={selectedCount === 0}
className="h-8 bg-brand-500 hover:bg-brand-600 disabled:opacity-50"
data-testid="selection-edit-button"
>
<Pencil className="w-4 h-4 mr-1.5" />
Edit Selected
</Button>
{mode === 'backlog' && (
<>
<Button
variant="default"
size="sm"
onClick={onEdit}
disabled={selectedCount === 0}
className="h-8 bg-brand-500 hover:bg-brand-600 disabled:opacity-50"
data-testid="selection-edit-button"
>
<Pencil className="w-4 h-4 mr-1.5" />
Edit Selected
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDeleteClick}
disabled={selectedCount === 0}
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10 disabled:opacity-50"
data-testid="selection-delete-button"
>
<Trash2 className="w-4 h-4 mr-1.5" />
Delete
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDeleteClick}
disabled={selectedCount === 0}
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10 disabled:opacity-50"
data-testid="selection-delete-button"
>
<Trash2 className="w-4 h-4 mr-1.5" />
Delete
</Button>
</>
)}
{mode === 'waiting_approval' && (
<Button
variant="default"
size="sm"
onClick={handleVerifyClick}
disabled={selectedCount === 0}
className="h-8 bg-green-600 hover:bg-green-700 disabled:opacity-50"
data-testid="selection-verify-button"
>
<CheckCircle2 className="w-4 h-4 mr-1.5" />
Verify Selected
</Button>
)}
{!allSelected && (
<Button
@@ -146,6 +182,42 @@ export function SelectionActionBar({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Verify Confirmation Dialog */}
<Dialog open={showVerifyDialog} onOpenChange={setShowVerifyDialog}>
<DialogContent data-testid="bulk-verify-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="w-5 h-5" />
Verify Selected Features?
</DialogTitle>
<DialogDescription>
Are you sure you want to mark {selectedCount} feature
{selectedCount !== 1 ? 's' : ''} as verified?
<span className="block mt-2 text-muted-foreground">
This will move them to the Verified column.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setShowVerifyDialog(false)}
data-testid="cancel-bulk-verify-button"
>
Cancel
</Button>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={handleConfirmVerify}
data-testid="confirm-bulk-verify-button"
>
<CheckCircle2 className="w-4 h-4 mr-2" />
Verify
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -7,5 +7,5 @@ export { useBoardEffects } from './use-board-effects';
export { useBoardBackground } from './use-board-background';
export { useBoardPersistence } from './use-board-persistence';
export { useFollowUpState } from './use-follow-up-state';
export { useSelectionMode } from './use-selection-mode';
export { useSelectionMode, type SelectionTarget } from './use-selection-mode';
export { useListViewState } from './use-list-view-state';

View File

@@ -1,10 +1,13 @@
import { useState, useCallback, useEffect } from 'react';
export type SelectionTarget = 'backlog' | 'waiting_approval' | null;
interface UseSelectionModeReturn {
isSelectionMode: boolean;
selectionTarget: SelectionTarget;
selectedFeatureIds: Set<string>;
selectedCount: number;
toggleSelectionMode: () => void;
toggleSelectionMode: (target?: SelectionTarget) => void;
toggleFeatureSelection: (featureId: string) => void;
selectAll: (featureIds: string[]) => void;
clearSelection: () => void;
@@ -13,21 +16,26 @@ interface UseSelectionModeReturn {
}
export function useSelectionMode(): UseSelectionModeReturn {
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectionTarget, setSelectionTarget] = useState<SelectionTarget>(null);
const [selectedFeatureIds, setSelectedFeatureIds] = useState<Set<string>>(new Set());
const toggleSelectionMode = useCallback(() => {
setIsSelectionMode((prev) => {
if (prev) {
const isSelectionMode = selectionTarget !== null;
const toggleSelectionMode = useCallback((target: SelectionTarget = 'backlog') => {
setSelectionTarget((prev) => {
if (prev === target) {
// Exiting selection mode - clear selection
setSelectedFeatureIds(new Set());
return null;
}
return !prev;
// Switching to a different target or entering selection mode
setSelectedFeatureIds(new Set());
return target;
});
}, []);
const exitSelectionMode = useCallback(() => {
setIsSelectionMode(false);
setSelectionTarget(null);
setSelectedFeatureIds(new Set());
}, []);
@@ -70,6 +78,7 @@ export function useSelectionMode(): UseSelectionModeReturn {
return {
isSelectionMode,
selectionTarget,
selectedFeatureIds,
selectedCount: selectedFeatureIds.size,
toggleSelectionMode,

View File

@@ -50,9 +50,10 @@ interface KanbanBoardProps {
onOpenPipelineSettings?: () => void;
// Selection mode props
isSelectionMode?: boolean;
selectionTarget?: 'backlog' | 'waiting_approval' | null;
selectedFeatureIds?: Set<string>;
onToggleFeatureSelection?: (featureId: string) => void;
onToggleSelectionMode?: () => void;
onToggleSelectionMode?: (target?: 'backlog' | 'waiting_approval') => void;
// Empty state action props
onAiSuggest?: () => void;
/** Whether currently dragging (hides empty states during drag) */
@@ -95,6 +96,7 @@ export function KanbanBoard({
pipelineConfig,
onOpenPipelineSettings,
isSelectionMode = false,
selectionTarget = null,
selectedFeatureIds = new Set(),
onToggleFeatureSelection,
onToggleSelectionMode,
@@ -189,12 +191,14 @@ export function KanbanBoard({
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${isSelectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={onToggleSelectionMode}
title={isSelectionMode ? 'Switch to Drag Mode' : 'Select Multiple'}
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('backlog')}
title={
selectionTarget === 'backlog' ? 'Switch to Drag Mode' : 'Select Multiple'
}
data-testid="selection-mode-button"
>
{isSelectionMode ? (
{selectionTarget === 'backlog' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
@@ -207,6 +211,31 @@ export function KanbanBoard({
)}
</Button>
</div>
) : column.id === 'waiting_approval' ? (
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('waiting_approval')}
title={
selectionTarget === 'waiting_approval'
? 'Switch to Drag Mode'
: 'Select Multiple'
}
data-testid="waiting-approval-selection-mode-button"
>
{selectionTarget === 'waiting_approval' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
@@ -305,6 +334,7 @@ export function KanbanBoard({
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
isSelected={selectedFeatureIds.has(feature.id)}
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
/>

View File

@@ -43,11 +43,11 @@ export const CLAUDE_MODELS: ModelOption[] = [
/**
* Cursor models derived from CURSOR_MODEL_MAP
* ID is prefixed with "cursor-" for ProviderFactory routing
* ID is prefixed with "cursor-" for ProviderFactory routing (if not already prefixed)
*/
export const CURSOR_MODELS: ModelOption[] = Object.entries(CURSOR_MODEL_MAP).map(
([id, config]) => ({
id: `cursor-${id}`,
id: id.startsWith('cursor-') ? id : `cursor-${id}`,
label: config.label,
description: config.description,
provider: 'cursor' as ModelProvider,