mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
Merge pull request #413 from AutoMaker-Org/feat/bulk-delete-features-in-backlog
feat: bulk delete in backlog mass select
This commit is contained in:
@@ -10,6 +10,7 @@ import { createGetHandler } from './routes/get.js';
|
|||||||
import { createCreateHandler } from './routes/create.js';
|
import { createCreateHandler } from './routes/create.js';
|
||||||
import { createUpdateHandler } from './routes/update.js';
|
import { createUpdateHandler } from './routes/update.js';
|
||||||
import { createBulkUpdateHandler } from './routes/bulk-update.js';
|
import { createBulkUpdateHandler } from './routes/bulk-update.js';
|
||||||
|
import { createBulkDeleteHandler } from './routes/bulk-delete.js';
|
||||||
import { createDeleteHandler } from './routes/delete.js';
|
import { createDeleteHandler } from './routes/delete.js';
|
||||||
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
||||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||||
@@ -26,6 +27,11 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
|||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
createBulkUpdateHandler(featureLoader)
|
createBulkUpdateHandler(featureLoader)
|
||||||
);
|
);
|
||||||
|
router.post(
|
||||||
|
'/bulk-delete',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createBulkDeleteHandler(featureLoader)
|
||||||
|
);
|
||||||
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
|
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
|
||||||
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
||||||
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
||||||
|
|||||||
62
apps/server/src/routes/features/routes/bulk-delete.ts
Normal file
62
apps/server/src/routes/features/routes/bulk-delete.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* POST /bulk-delete endpoint - Delete multiple features at once
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
interface BulkDeleteRequest {
|
||||||
|
projectPath: string;
|
||||||
|
featureIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkDeleteResult {
|
||||||
|
featureId: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBulkDeleteHandler(featureLoader: FeatureLoader) {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, featureIds } = req.body as BulkDeleteRequest;
|
||||||
|
|
||||||
|
if (!projectPath || !featureIds || !Array.isArray(featureIds) || featureIds.length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'projectPath and featureIds (non-empty array) are required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: BulkDeleteResult[] = [];
|
||||||
|
|
||||||
|
for (const featureId of featureIds) {
|
||||||
|
try {
|
||||||
|
const success = await featureLoader.delete(projectPath, featureId);
|
||||||
|
results.push({ featureId, success });
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
featureId,
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const successCount = results.filter((r) => r.success).length;
|
||||||
|
const failureCount = results.filter((r) => !r.success).length;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: failureCount === 0,
|
||||||
|
deletedCount: successCount,
|
||||||
|
failedCount: failureCount,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Bulk delete features failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -505,6 +505,34 @@ export function BoardView() {
|
|||||||
[currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]
|
[currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handler for bulk deleting multiple features
|
||||||
|
const handleBulkDelete = useCallback(async () => {
|
||||||
|
if (!currentProject || selectedFeatureIds.size === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const featureIds = Array.from(selectedFeatureIds);
|
||||||
|
const result = await api.features.bulkDelete(currentProject.path, featureIds);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Delete from local state
|
||||||
|
featureIds.forEach((featureId) => {
|
||||||
|
persistFeatureDelete(featureId);
|
||||||
|
});
|
||||||
|
toast.success(`Deleted ${result.deletedCount} features`);
|
||||||
|
exitSelectionMode();
|
||||||
|
loadFeatures();
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to delete some features', {
|
||||||
|
description: `${result.failedCount} features failed to delete`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Bulk delete failed:', error);
|
||||||
|
toast.error('Failed to delete features');
|
||||||
|
}
|
||||||
|
}, [currentProject, selectedFeatureIds, persistFeatureDelete, exitSelectionMode, loadFeatures]);
|
||||||
|
|
||||||
// Get selected features for mass edit dialog
|
// Get selected features for mass edit dialog
|
||||||
const selectedFeatures = useMemo(() => {
|
const selectedFeatures = useMemo(() => {
|
||||||
return hookFeatures.filter((f) => selectedFeatureIds.has(f.id));
|
return hookFeatures.filter((f) => selectedFeatureIds.has(f.id));
|
||||||
@@ -1257,6 +1285,7 @@ export function BoardView() {
|
|||||||
selectedCount={selectedCount}
|
selectedCount={selectedCount}
|
||||||
totalCount={allSelectableFeatureIds.length}
|
totalCount={allSelectableFeatureIds.length}
|
||||||
onEdit={() => setShowMassEditDialog(true)}
|
onEdit={() => setShowMassEditDialog(true)}
|
||||||
|
onDelete={handleBulkDelete}
|
||||||
onClear={clearSelection}
|
onClear={clearSelection}
|
||||||
onSelectAll={() => selectAll(allSelectableFeatureIds)}
|
onSelectAll={() => selectAll(allSelectableFeatureIds)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Pencil, X, CheckSquare } from 'lucide-react';
|
import { Pencil, X, CheckSquare, Trash2 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
interface SelectionActionBarProps {
|
interface SelectionActionBarProps {
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
onSelectAll: () => void;
|
onSelectAll: () => void;
|
||||||
}
|
}
|
||||||
@@ -14,65 +24,126 @@ export function SelectionActionBar({
|
|||||||
selectedCount,
|
selectedCount,
|
||||||
totalCount,
|
totalCount,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onDelete,
|
||||||
onClear,
|
onClear,
|
||||||
onSelectAll,
|
onSelectAll,
|
||||||
}: SelectionActionBarProps) {
|
}: SelectionActionBarProps) {
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
if (selectedCount === 0) return null;
|
if (selectedCount === 0) return null;
|
||||||
|
|
||||||
const allSelected = selectedCount === totalCount;
|
const allSelected = selectedCount === totalCount;
|
||||||
|
|
||||||
|
const handleDeleteClick = () => {
|
||||||
|
setShowDeleteDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = () => {
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
onDelete();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={cn(
|
<div
|
||||||
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
|
className={cn(
|
||||||
'flex items-center gap-3 px-4 py-3 rounded-xl',
|
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
|
||||||
'bg-background/95 backdrop-blur-sm border border-border shadow-lg',
|
'flex items-center gap-3 px-4 py-3 rounded-xl',
|
||||||
'animate-in slide-in-from-bottom-4 fade-in duration-200'
|
'bg-background/95 backdrop-blur-sm border border-border shadow-lg',
|
||||||
)}
|
'animate-in slide-in-from-bottom-4 fade-in duration-200'
|
||||||
data-testid="selection-action-bar"
|
)}
|
||||||
>
|
data-testid="selection-action-bar"
|
||||||
<span className="text-sm font-medium text-foreground">
|
>
|
||||||
{selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected
|
<span className="text-sm font-medium text-foreground">
|
||||||
</span>
|
{selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className="h-4 w-px bg-border" />
|
<div className="h-4 w-px bg-border" />
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
className="h-8 bg-brand-500 hover:bg-brand-600"
|
className="h-8 bg-brand-500 hover:bg-brand-600"
|
||||||
data-testid="selection-edit-button"
|
data-testid="selection-edit-button"
|
||||||
>
|
>
|
||||||
<Pencil className="w-4 h-4 mr-1.5" />
|
<Pencil className="w-4 h-4 mr-1.5" />
|
||||||
Edit Selected
|
Edit Selected
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!allSelected && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onSelectAll}
|
onClick={handleDeleteClick}
|
||||||
className="h-8"
|
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
data-testid="selection-select-all-button"
|
data-testid="selection-delete-button"
|
||||||
>
|
>
|
||||||
<CheckSquare className="w-4 h-4 mr-1.5" />
|
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||||
Select All ({totalCount})
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
{!allSelected && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={onClear}
|
size="sm"
|
||||||
className="h-8 text-muted-foreground hover:text-foreground"
|
onClick={onSelectAll}
|
||||||
data-testid="selection-clear-button"
|
className="h-8"
|
||||||
>
|
data-testid="selection-select-all-button"
|
||||||
<X className="w-4 h-4 mr-1.5" />
|
>
|
||||||
Clear
|
<CheckSquare className="w-4 h-4 mr-1.5" />
|
||||||
</Button>
|
Select All ({totalCount})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClear}
|
||||||
|
className="h-8 text-muted-foreground hover:text-foreground"
|
||||||
|
data-testid="selection-clear-button"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-1.5" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
|
<DialogContent data-testid="bulk-delete-confirmation-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
Delete Selected Features?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to permanently delete {selectedCount} feature
|
||||||
|
{selectedCount !== 1 ? 's' : ''}?
|
||||||
|
<span className="block mt-2 text-destructive font-medium">
|
||||||
|
This action cannot be undone.
|
||||||
|
</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowDeleteDialog(false)}
|
||||||
|
data-testid="cancel-bulk-delete-button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
data-testid="confirm-bulk-delete-button"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1438,6 +1438,16 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
features?: Feature[];
|
features?: Feature[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
bulkDelete: (
|
||||||
|
projectPath: string,
|
||||||
|
featureIds: string[]
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
deletedCount?: number;
|
||||||
|
failedCount?: number;
|
||||||
|
results?: Array<{ featureId: string; success: boolean; error?: string }>;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
} = {
|
} = {
|
||||||
getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }),
|
getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }),
|
||||||
get: (projectPath: string, featureId: string) =>
|
get: (projectPath: string, featureId: string) =>
|
||||||
@@ -1466,6 +1476,8 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.post('/api/features/generate-title', { description }),
|
this.post('/api/features/generate-title', { description }),
|
||||||
bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial<Feature>) =>
|
bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial<Feature>) =>
|
||||||
this.post('/api/features/bulk-update', { projectPath, featureIds, updates }),
|
this.post('/api/features/bulk-update', { projectPath, featureIds, updates }),
|
||||||
|
bulkDelete: (projectPath: string, featureIds: string[]) =>
|
||||||
|
this.post('/api/features/bulk-delete', { projectPath, featureIds }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto Mode API
|
// Auto Mode API
|
||||||
|
|||||||
Reference in New Issue
Block a user