Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1556,7 +1556,10 @@
"dialogRenameCollectionTitle": "Rename collection",
"dialogRenameCollectionDescription": "Enter a new name for the collection.",
"placeholderRenameCollection": "Collection name",
"renameAction": "Rename"
"renameAction": "Rename",
"dialogDeleteBulkTitle": "Delete Collections",
"dialogDeleteBulkDescription": "This action cannot be undone. The following collections will be permanently deleted:",
"cancel": "Cancel"
},
"collectionItem": {
"newFolder": "New folder",
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/components/api-client/api-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function ApiClient() {
const [activeTabId, setActiveTabId] = React.useState<string>(tabs[0].id)
const [isInitialized, setIsInitialized] = React.useState(false)
const abortControllerRef = React.useRef<AbortController | null>(null)
const { collections, addFolder, deleteItem, saveRequest, toggleFolder, createCollection, renameCollection, renameFolder, isLoading: collectionsLoading } = useCollections()
const { collections, addFolder, deleteItem, saveRequest, toggleFolder, createCollection, renameCollection, renameFolder, deleteMultipleCollections, isLoading: collectionsLoading } = useCollections()
const { history, addHistoryItem, clearHistory, deleteHistoryItem } = useHistory()
const {
environments,
Expand Down Expand Up @@ -530,7 +530,7 @@ export function ApiClient() {
try {
const parsed = parseCurlCommand(curl)
const resolvedUrl = replaceUrlWithEnvBaseUrl(parsed.url)

updateActiveTab({
...parsed,
url: resolvedUrl || activeTab.url,
Expand All @@ -543,6 +543,10 @@ export function ApiClient() {
}
}

const handleDeleteMultipleCollections = async (ids: string[]) => {
await deleteMultipleCollections(ids)
}

return (
<div className="flex h-full min-h-0 w-full flex-col gap-4 mobile-nav-offset lg:flex-row">
<div className="flex-1 flex flex-col gap-4 min-w-0 h-full">
Expand Down Expand Up @@ -575,6 +579,7 @@ export function ApiClient() {
history={history}
onClearHistory={clearHistory}
onDeleteHistoryItem={deleteHistoryItem}
onDeleteMultiple={handleDeleteMultipleCollections}
/>
</div>
</SheetContent>
Expand Down Expand Up @@ -763,6 +768,7 @@ export function ApiClient() {
history={history}
onClearHistory={clearHistory}
onDeleteHistoryItem={deleteHistoryItem}
onDeleteMultiple={handleDeleteMultipleCollections}
/>
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import * as React from "react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Collection, CollectionFolder, CollectionRequest, HistoryRequest } from "../types"
import { CollectionItem } from "./collection-item"
Expand Down Expand Up @@ -42,6 +43,7 @@ interface CollectionsSidebarProps {
history?: HistoryRequest[]
onClearHistory?: () => void
onDeleteHistoryItem?: (id: string) => void
onDeleteMultiple?: (ids: string[]) => void
}

export function CollectionsSidebar({
Expand All @@ -57,6 +59,7 @@ export function CollectionsSidebar({
history,
onClearHistory,
onDeleteHistoryItem,
onDeleteMultiple,
}: CollectionsSidebarProps) {
const t = useTranslations("ApiClient.collectionsSidebar")
const tRoot = useTranslations("ApiClient")
Expand Down Expand Up @@ -89,6 +92,9 @@ export function CollectionsSidebar({
const [renameCollectionName, setRenameCollectionName] = React.useState("")
const [targetParentId, setTargetParentId] = React.useState<string | null>(null)
const [targetCollectionId, setTargetCollectionId] = React.useState<string | null>(null)
const [selectedCollections, setSelectedCollections] = React.useState<Set<string>>(new Set())
const [deleteBulkDialogOpen, setDeleteBulkDialogOpen] = React.useState(false)
const [isDeleting, setIsDeleting] = React.useState(false)

const handleAddFolder = () => {
if (newFolderName && targetParentId) {
Expand Down Expand Up @@ -127,21 +133,50 @@ export function CollectionsSidebar({
setRenameCollectionDialogOpen(true)
}

const toggleCollectionSelection = (collectionId: string) => {
setSelectedCollections(prev => {
const next = new Set(prev)
if (next.has(collectionId)) {
next.delete(collectionId)
} else {
next.add(collectionId)
}
return next
})
}

const clearSelection = () => {
setSelectedCollections(new Set())
}

return (
<div className="flex flex-col w-full h-full bg-background/50">
<Tabs defaultValue="collections" className="flex-1 flex flex-col h-full min-h-0">
<div className="px-4 py-3 border-b flex flex-col gap-3 shrink-0 bg-card/40 backdrop-blur-sm">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm tracking-tight">{t("title")}</h3>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-lg hover:bg-primary/10 hover:text-primary transition-colors"
onClick={() => setNewCollectionDialogOpen(true)}
title={t("newCollection")}
>
<FolderPlus className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
{selectedCollections.size > 0 && (
<Button
variant="destructive"
size="sm"
className="h-7 px-3 rounded-lg text-xs font-medium gap-2"
onClick={() => setDeleteBulkDialogOpen(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete ({selectedCollections.size})
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-lg hover:bg-primary/10 hover:text-primary transition-colors"
onClick={() => setNewCollectionDialogOpen(true)}
title={t("newCollection")}
>
<FolderPlus className="h-4 w-4" />
</Button>
</div>
</div>
<TabsList className="w-full grid grid-cols-2 p-1 bg-muted/50 rounded-lg">
<TabsTrigger value="collections" className="rounded-md text-xs font-medium">{t("tabCollections")}</TabsTrigger>
Expand Down Expand Up @@ -173,9 +208,18 @@ export function CollectionsSidebar({
collections.map((collection) => (
<div key={collection.id} className="mb-4">
<div className="flex items-center justify-between px-2 py-1.5 mb-1 group rounded-md hover:bg-muted/50 transition-colors">
<span className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider truncate flex-1 mr-2 px-1">
{collection.name}
</span>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center">
<Checkbox
checked={selectedCollections.has(collection.id)}
onCheckedChange={() => toggleCollectionSelection(collection.id)}
className="h-4 w-4"
/>
</div>
<span className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider truncate flex-1 px-1">
{collection.name}
</span>
</div>
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
Expand All @@ -200,14 +244,14 @@ export function CollectionsSidebar({
<DropdownMenuItem onClick={() => openRenameCollectionDialog(collection)}>
<Pencil className="h-4 w-4 mr-2" />
{t("rename")}
</DropdownMenuItem>
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(collection.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
{t("delete")}
</DropdownMenuItem>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
Expand Down Expand Up @@ -432,6 +476,55 @@ export function CollectionsSidebar({
</DialogFooter>
</DialogContent>
</Dialog>

<Dialog open={deleteBulkDialogOpen} onOpenChange={setDeleteBulkDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("dialogDeleteBulkTitle") || "Delete Collections"}</DialogTitle>
<DialogDescription>
{t("dialogDeleteBulkDescription") || "This action cannot be undone. The following collections will be permanently deleted:"}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{Array.from(selectedCollections).map(collectionId => {
const collection = collections.find(c => c.id === collectionId)
return (
<div key={collectionId} className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-md border border-border/50">
<Trash2 className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate">{collection?.name || "Unknown"}</span>
</div>
)
})}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteBulkDialogOpen(false)}>
{t("cancel") || "Cancel"}
</Button>
<Button
variant="destructive"
disabled={isDeleting}
onClick={async () => {
setIsDeleting(true)
try {
await onDeleteMultiple?.(Array.from(selectedCollections))
// Only close dialog and clear selection on successful deletion
setDeleteBulkDialogOpen(false)
clearSelection()
} catch (error) {
// Keep dialog open if deletion failed so user can retry
console.error("Error deleting collections:", error)
} finally {
setIsDeleting(false)
}
}}
>
{isDeleting ? "Deleting..." : (t("delete") || "Delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,66 @@ export function useCollections() {
}
}

const deleteMultipleCollections = async (ids: string[]) => {
if (!user) return
if (ids.length === 0) return

try {
// Use Promise.allSettled to handle partial failures gracefully
const deleteResults = await Promise.allSettled(
ids.map((id) =>
authedFetch(`/api/backend/api-client/collections/${id}`, { method: "DELETE" })
)
)

const successfulIds: string[] = []
const failedIds: string[] = []

deleteResults.forEach((result, index) => {
if (result.status === "fulfilled") {
if (result.value.ok) {
successfulIds.push(ids[index])
} else {
failedIds.push(ids[index])
}
} else {
failedIds.push(ids[index])
}
})

// Remove successfully deleted collections from state
if (successfulIds.length > 0) {
setCollections((prev) => prev.filter((c) => !successfulIds.includes(c.id)))
}

// Handle results with appropriate feedback
if (failedIds.length === 0) {
// All successful
toast.success(ids.length === 1 ? "Collection deleted" : `${ids.length} collections deleted`)
} else if (successfulIds.length === 0) {
// All failed
console.error("Failed to delete collections:", failedIds)
toast.error(ids.length === 1 ? "Failed to delete collection" : "Failed to delete collections")
throw new Error("All collections failed to delete")
} else {
// Partial failure
const failedNames = failedIds
.map(id => collections.find(c => c.id === id)?.name)
.filter(Boolean)
.join(", ")
console.error("Partial failure deleting collections:", failedIds)
toast.error(`Deleted ${successfulIds.length} of ${ids.length} collections. Failed: ${failedNames || "unknown"}`)
throw new Error(`Partial failure: ${failedIds.length} collections failed to delete`)
}
} catch (error) {
console.error("Failed to delete collections:", error)
if (!(error instanceof Error) || !error.message.includes("Partial failure")) {
toast.error("Failed to delete collections")
}
throw error // Re-throw so caller knows deletion failed
}
}

return {
collections,
addFolder,
Expand All @@ -376,6 +436,7 @@ export function useCollections() {
createCollection,
renameCollection,
renameFolder,
deleteMultipleCollections,
isLoading
}
}
Loading