feat(documents): MoveToFolderDialog single-doc move picker
cmdk Combobox dialog showing all folder paths flat (' / '-separated),
plus a "Root (no folder)" pseudo-option. Move button disabled when the
picked folder matches the document's current folder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
115
src/components/documents/move-to-folder-dialog.tsx
Normal file
115
src/components/documents/move-to-folder-dialog.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, FolderInput } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import {
|
||||
buildFolderPaths,
|
||||
useDocumentFolders,
|
||||
useMoveDocument,
|
||||
} from '@/hooks/use-document-folders';
|
||||
|
||||
interface MoveToFolderDialogProps {
|
||||
documentId: string;
|
||||
documentTitle: string;
|
||||
currentFolderId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MoveToFolderDialog({
|
||||
documentId,
|
||||
documentTitle,
|
||||
currentFolderId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: MoveToFolderDialogProps) {
|
||||
const { data: tree = [] } = useDocumentFolders();
|
||||
const move = useMoveDocument();
|
||||
const [pickedId, setPickedId] = useState<string | null>(currentFolderId);
|
||||
|
||||
const paths = useMemo(() => buildFolderPaths(tree), [tree]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Move “{documentTitle}”</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search folders…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No folders match.</CommandEmpty>
|
||||
<CommandGroup heading="Special">
|
||||
<CommandItem
|
||||
value="__root__"
|
||||
onSelect={() => setPickedId(null)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Check
|
||||
className={pickedId === null ? 'h-4 w-4 opacity-100' : 'h-4 w-4 opacity-0'}
|
||||
/>
|
||||
Root (no folder)
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
{paths.length > 0 ? (
|
||||
<CommandGroup heading="Folders">
|
||||
{paths.map((p) => (
|
||||
<CommandItem
|
||||
key={p.id}
|
||||
value={p.path}
|
||||
onSelect={() => setPickedId(p.id)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Check
|
||||
className={pickedId === p.id ? 'h-4 w-4 opacity-100' : 'h-4 w-4 opacity-0'}
|
||||
/>
|
||||
<span className="truncate">{p.path}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</Command>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pickedId === currentFolderId || move.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await move.mutateAsync({ docId: documentId, folderId: pickedId });
|
||||
toast.success('Document moved');
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderInput className="mr-1.5 h-4 w-4" />
|
||||
Move
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user