Procházet zdrojové kódy

feat(dashboard): Move collections UI (#3629)

Michael Bromley před 6 měsíci
rodič
revize
a921e97aa5

+ 6 - 1
.cursor/rules/dashboard.mdc

@@ -112,7 +112,12 @@ are CRUD list/detail pages.
 - A representative list page is [administrators.tsx](mdc:packages/dashboard/src/app/routes/_authenticated/_administrators/administrators.tsx)
 - A representative detail page is [administrators_.$id.tsx](mdc:packages/dashboard/src/app/routes/_authenticated/_administrators/administrators_.$id.tsx)
 
-These examples show the common components, hooks and patterns that should be used across the app.
+These examples show the common components, hooks and patterns that should be used across the app
+
+**Important** All the files in the /src/app/routes/ dir are interpreted as routes by Tanstack
+Router. The exception is any file in a `/components`, `/hooks` dir, or any file ending `.graphql.ts`. So
+don't just create new directories and files that do not fit this pattern otherwise Tanstack router
+will break.
 
 In the "lib" context, we try to re-use components from the /src/lib/components dir, including preferring the Shadcn components
 as much as possible from the /src/lib/components/ui dir.

+ 32 - 0
packages/dashboard/src/app/routes/_authenticated/_collections/collections.graphql.ts

@@ -156,3 +156,35 @@ export const deleteCollectionsDocument = graphql(`
         }
     }
 `);
+
+export const moveCollectionDocument = graphql(`
+    mutation MoveCollection($input: MoveCollectionInput!) {
+        moveCollection(input: $input) {
+            id
+        }
+    }
+`);
+
+export const collectionListForMoveDocument = graphql(`
+    query CollectionListForMove($options: CollectionListOptions) {
+        collections(options: $options) {
+            items {
+                id
+                name
+                slug
+                breadcrumbs {
+                    id
+                    name
+                    slug
+                }
+                children {
+                    id
+                }
+                position
+                isPrivate
+                parentId
+            }
+            totalItems
+        }
+    }
+`);

+ 153 - 133
packages/dashboard/src/app/routes/_authenticated/_collections/collections.tsx

@@ -10,7 +10,7 @@ import { createFileRoute, Link } from '@tanstack/react-router';
 import { ExpandedState, getExpandedRowModel } from '@tanstack/react-table';
 import { TableOptions } from '@tanstack/table-core';
 import { ResultOf } from 'gql.tada';
-import { Folder, FolderOpen, PlusIcon } from 'lucide-react';
+import { Folder, FolderOpen, FolderTreeIcon, PlusIcon } from 'lucide-react';
 import { useState } from 'react';
 
 import { collectionListDocument, deleteCollectionDocument } from './collections.graphql.js';
@@ -18,9 +18,11 @@ import {
     AssignCollectionsToChannelBulkAction,
     DeleteCollectionsBulkAction,
     DuplicateCollectionsBulkAction,
+    MoveCollectionsBulkAction,
     RemoveCollectionsFromChannelBulkAction,
 } from './components/collection-bulk-actions.js';
 import { CollectionContentsSheet } from './components/collection-contents-sheet.js';
+import { useMoveSingleCollection } from './components/move-single-collection.js';
 
 export const Route = createFileRoute('/_authenticated/_collections/collections')({
     component: CollectionListPage,
@@ -31,6 +33,7 @@ type Collection = ResultOf<typeof collectionListDocument>['collections']['items'
 
 function CollectionListPage() {
     const [expanded, setExpanded] = useState<ExpandedState>({});
+    const { handleMoveClick, MoveDialog } = useMoveSingleCollection();
     const childrenQueries = useQueries({
         queries: Object.entries(expanded).map(([collectionId, isExpanded]) => {
             return {
@@ -77,143 +80,160 @@ function CollectionListPage() {
     };
 
     return (
-        <ListPage
-            pageId="collection-list"
-            title="Collections"
-            listQuery={collectionListDocument}
-            transformVariables={input => {
-                const filterTerm = input.options?.filter?.name?.contains;
-                const isFiltering = !!filterTerm;
-                return {
-                    options: {
-                        ...input.options,
-                        topLevelOnly: !isFiltering,
+        <>
+            <ListPage
+                pageId="collection-list"
+                title="Collections"
+                listQuery={collectionListDocument}
+                transformVariables={input => {
+                    const filterTerm = input.options?.filter?.name?.contains;
+                    const isFiltering = !!filterTerm;
+                    return {
+                        options: {
+                            ...input.options,
+                            topLevelOnly: !isFiltering,
+                        },
+                    };
+                }}
+                deleteMutation={deleteCollectionDocument}
+                customizeColumns={{
+                    name: {
+                        header: 'Collection Name',
+                        cell: ({ row }) => {
+                            const isExpanded = row.getIsExpanded();
+                            const hasChildren = !!row.original.children?.length;
+                            return (
+                                <div
+                                    style={{ marginLeft: (row.original.breadcrumbs.length - 2) * 20 + 'px' }}
+                                    className="flex gap-2 items-center"
+                                >
+                                    <Button
+                                        size="icon"
+                                        variant="secondary"
+                                        onClick={row.getToggleExpandedHandler()}
+                                        disabled={!hasChildren}
+                                        className={!hasChildren ? 'opacity-20' : ''}
+                                    >
+                                        {isExpanded ? <FolderOpen /> : <Folder />}
+                                    </Button>
+                                    <DetailPageButton id={row.original.id} label={row.original.name} />
+                                </div>
+                            );
+                        },
+                    },
+                    breadcrumbs: {
+                        cell: ({ cell }) => {
+                            const value = cell.getValue();
+                            if (!Array.isArray(value)) {
+                                return null;
+                            }
+                            return (
+                                <div>
+                                    {value
+                                        .slice(1)
+                                        .map(breadcrumb => breadcrumb.name)
+                                        .join(' / ')}
+                                </div>
+                            );
+                        },
                     },
-                };
-            }}
-            deleteMutation={deleteCollectionDocument}
-            customizeColumns={{
-                name: {
-                    header: 'Collection Name',
-                    cell: ({ row }) => {
-                        const isExpanded = row.getIsExpanded();
-                        const hasChildren = !!row.original.children?.length;
-                        return (
-                            <div
-                                style={{ marginLeft: (row.original.breadcrumbs.length - 2) * 20 + 'px' }}
-                                className="flex gap-2 items-center"
-                            >
-                                <Button
-                                    size="icon"
-                                    variant="secondary"
-                                    onClick={row.getToggleExpandedHandler()}
-                                    disabled={!hasChildren}
-                                    className={!hasChildren ? 'opacity-20' : ''}
+                    productVariants: {
+                        header: 'Contents',
+                        cell: ({ row }) => {
+                            return (
+                                <CollectionContentsSheet
+                                    collectionId={row.original.id}
+                                    collectionName={row.original.name}
                                 >
-                                    {isExpanded ? <FolderOpen /> : <Folder />}
-                                </Button>
-                                <DetailPageButton id={row.original.id} label={row.original.name} />
-                            </div>
-                        );
+                                    <Trans>{row.original.productVariants.totalItems} variants</Trans>
+                                </CollectionContentsSheet>
+                            );
+                        },
                     },
-                },
-                breadcrumbs: {
-                    cell: ({ cell }) => {
-                        const value = cell.getValue();
-                        if (!Array.isArray(value)) {
-                            return null;
-                        }
-                        return (
-                            <div>
-                                {value
-                                    .slice(1)
-                                    .map(breadcrumb => breadcrumb.name)
-                                    .join(' / ')}
+                }}
+                defaultColumnOrder={[
+                    'featuredAsset',
+                    'children',
+                    'name',
+                    'slug',
+                    'breadcrumbs',
+                    'productVariants',
+                ]}
+                transformData={data => {
+                    return addSubCollections(data);
+                }}
+                setTableOptions={(options: TableOptions<any>) => {
+                    options.state = {
+                        ...options.state,
+                        expanded: expanded,
+                    };
+                    options.onExpandedChange = setExpanded;
+                    options.getExpandedRowModel = getExpandedRowModel();
+                    options.getRowCanExpand = () => true;
+                    options.getRowId = row => {
+                        return row.id;
+                    };
+                    return options;
+                }}
+                defaultVisibility={{
+                    id: false,
+                    createdAt: false,
+                    updatedAt: false,
+                    position: false,
+                    parentId: false,
+                    children: false,
+                }}
+                onSearchTermChange={searchTerm => {
+                    return {
+                        name: { contains: searchTerm },
+                    };
+                }}
+                route={Route}
+                rowActions={[
+                    {
+                        label: (
+                            <div className="flex items-center gap-2">
+                                <FolderTreeIcon className="w-4 h-4" /> <Trans>Move</Trans>
                             </div>
-                        );
+                        ),
+                        onClick: row => handleMoveClick(row.original),
+                    },
+                ]}
+                bulkActions={[
+                    {
+                        component: AssignCollectionsToChannelBulkAction,
+                        order: 100,
+                    },
+                    {
+                        component: RemoveCollectionsFromChannelBulkAction,
+                        order: 200,
+                    },
+                    {
+                        component: DuplicateCollectionsBulkAction,
+                        order: 300,
+                    },
+                    {
+                        component: MoveCollectionsBulkAction,
+                        order: 400,
                     },
-                },
-                productVariants: {
-                    header: 'Contents',
-                    cell: ({ row }) => {
-                        return (
-                            <CollectionContentsSheet
-                                collectionId={row.original.id}
-                                collectionName={row.original.name}
-                            >
-                                <Trans>{row.original.productVariants.totalItems} variants</Trans>
-                            </CollectionContentsSheet>
-                        );
+                    {
+                        component: DeleteCollectionsBulkAction,
+                        order: 500,
                     },
-                },
-            }}
-            defaultColumnOrder={[
-                'featuredAsset',
-                'children',
-                'name',
-                'slug',
-                'breadcrumbs',
-                'productVariants',
-            ]}
-            transformData={data => {
-                return addSubCollections(data);
-            }}
-            setTableOptions={(options: TableOptions<any>) => {
-                options.state = {
-                    ...options.state,
-                    expanded: expanded,
-                };
-                options.onExpandedChange = setExpanded;
-                options.getExpandedRowModel = getExpandedRowModel();
-                options.getRowCanExpand = () => true;
-                options.getRowId = row => {
-                    return row.id;
-                };
-                return options;
-            }}
-            defaultVisibility={{
-                id: false,
-                createdAt: false,
-                updatedAt: false,
-                position: false,
-                parentId: false,
-                children: false,
-            }}
-            onSearchTermChange={searchTerm => {
-                return {
-                    name: { contains: searchTerm },
-                };
-            }}
-            route={Route}
-            bulkActions={[
-                {
-                    component: AssignCollectionsToChannelBulkAction,
-                    order: 100,
-                },
-                {
-                    component: RemoveCollectionsFromChannelBulkAction,
-                    order: 200,
-                },
-                {
-                    component: DuplicateCollectionsBulkAction,
-                    order: 300,
-                },
-                {
-                    component: DeleteCollectionsBulkAction,
-                    order: 400,
-                },
-            ]}
-        >
-            <PageActionBarRight>
-                <PermissionGuard requires={['CreateCollection', 'CreateCatalog']}>
-                    <Button asChild>
-                        <Link to="./new">
-                            <PlusIcon className="mr-2 h-4 w-4" />
-                            <Trans>New Collection</Trans>
-                        </Link>
-                    </Button>
-                </PermissionGuard>
-            </PageActionBarRight>
-        </ListPage>
+                ]}
+            >
+                <PageActionBarRight>
+                    <PermissionGuard requires={['CreateCollection', 'CreateCatalog']}>
+                        <Button asChild>
+                            <Link to="./new">
+                                <PlusIcon className="mr-2 h-4 w-4" />
+                                <Trans>New Collection</Trans>
+                            </Link>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
+            </ListPage>
+            <MoveDialog />
+        </>
     );
 }

+ 34 - 1
packages/dashboard/src/app/routes/_authenticated/_collections/components/collection-bulk-actions.tsx

@@ -1,9 +1,12 @@
 import { useQueryClient } from '@tanstack/react-query';
+import { useState } from 'react';
+import { FolderTree } from 'lucide-react';
 
+import { Trans } from '@/vdb/lib/trans.js';
 import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-channel-bulk-action.js';
 import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
 import { api } from '@/vdb/graphql/api.js';
-import { BulkActionComponent, useChannel } from '@/vdb/index.js';
+import { BulkActionComponent, useChannel, DataTableBulkActionItem, usePaginatedList } from '@/vdb/index.js';
 import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
 import { DuplicateBulkAction } from '../../../../common/duplicate-bulk-action.js';
 import {
@@ -11,6 +14,7 @@ import {
     deleteCollectionsDocument,
     removeCollectionFromChannelDocument,
 } from '../collections.graphql.js';
+import { MoveCollectionsDialog } from './move-collections-dialog.js';
 
 export const AssignCollectionsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
     const queryClient = useQueryClient();
@@ -85,3 +89,32 @@ export const DeleteCollectionsBulkAction: BulkActionComponent<any> = ({ selectio
         />
     );
 };
+
+export const MoveCollectionsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    const [dialogOpen, setDialogOpen] = useState(false);
+    const queryClient = useQueryClient();
+    const { refetchPaginatedList } = usePaginatedList();
+
+    const handleSuccess = () => {
+        queryClient.invalidateQueries({ queryKey: ['childCollections'] });
+        refetchPaginatedList();
+        table.resetRowSelection();
+    };
+
+    return (
+        <>
+            <DataTableBulkActionItem
+                requiresPermission={['UpdateCatalog', 'UpdateCollection']}
+                onClick={() => setDialogOpen(true)}
+                label={<Trans>Move</Trans>}
+                icon={FolderTree}
+            />
+            <MoveCollectionsDialog
+                open={dialogOpen}
+                onOpenChange={setDialogOpen}
+                collectionsToMove={selection}
+                onSuccess={handleSuccess}
+            />
+        </>
+    );
+};

+ 430 - 0
packages/dashboard/src/app/routes/_authenticated/_collections/components/move-collections-dialog.tsx

@@ -0,0 +1,430 @@
+import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useDebounce } from '@uidotdev/usehooks';
+import { useRef, useState } from 'react';
+import { toast } from 'sonner';
+
+import { Alert, AlertDescription } from '@/components/ui/alert.js';
+import { Button } from '@/components/ui/button.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogDescription,
+    DialogFooter,
+    DialogHeader,
+    DialogTitle,
+} from '@/components/ui/dialog.js';
+import { Input } from '@/components/ui/input.js';
+import { ScrollArea } from '@/components/ui/scroll-area.js';
+import { api } from '@/graphql/api.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+import { ChevronRight, Folder, FolderOpen, Search } from 'lucide-react';
+
+import { collectionListForMoveDocument, moveCollectionDocument } from '../collections.graphql.js';
+
+type Collection = {
+    id: string;
+    name: string;
+    slug: string;
+    children?: { id: string }[] | null;
+    breadcrumbs: Array<{ id: string; name: string; slug: string }>;
+    parentId?: string;
+};
+
+interface MoveCollectionsDialogProps {
+    open: boolean;
+    onOpenChange: (open: boolean) => void;
+    collectionsToMove: Collection[];
+    onSuccess?: () => void;
+}
+
+interface CollectionTreeNodeProps {
+    collection: Collection;
+    depth: number;
+    expanded: Record<string, boolean>;
+    onToggleExpanded: (id: string) => void;
+    onSelect: (collection: Collection) => void;
+    selectedCollectionId?: string;
+    collectionsToMove: Collection[];
+    childCollectionsByParentId: Record<string, Collection[]>;
+}
+
+interface TargetAlertProps {
+    selectedCollectionId?: string;
+    collectionsToMove: Collection[];
+    topLevelCollectionId?: string;
+    collectionNameCache: React.MutableRefObject<Map<string, string>>;
+}
+
+interface MoveToTopLevelProps {
+    selectedCollectionId?: string;
+    topLevelCollectionId?: string;
+    onSelect: (id?: string) => void;
+}
+
+function TargetAlert({
+    selectedCollectionId,
+    collectionsToMove,
+    topLevelCollectionId,
+    collectionNameCache,
+}: Readonly<TargetAlertProps>) {
+    return (
+        <Alert className={selectedCollectionId ? 'border-blue-200 bg-blue-50' : ''}>
+            <Folder className="h-4 w-4" />
+            <AlertDescription>
+                {selectedCollectionId ? (
+                    <Trans>
+                        Moving {collectionsToMove.length} collection
+                        {collectionsToMove.length === 1 ? '' : 's'} into{' '}
+                        {selectedCollectionId === topLevelCollectionId
+                            ? 'top level'
+                            : collectionNameCache.current.get(selectedCollectionId) || 'selected collection'}
+                    </Trans>
+                ) : (
+                    <Trans>Select a destination collection</Trans>
+                )}
+            </AlertDescription>
+        </Alert>
+    );
+}
+
+function MoveToTopLevel({
+    selectedCollectionId,
+    topLevelCollectionId,
+    onSelect,
+}: Readonly<MoveToTopLevelProps>) {
+    return (
+        <button
+            type="button"
+            className={`flex items-center gap-2 py-2 px-3 hover:bg-accent rounded-sm cursor-pointer w-full text-left ${
+                selectedCollectionId === topLevelCollectionId ? 'bg-accent' : ''
+            }`}
+            onClick={() => onSelect(topLevelCollectionId)}
+        >
+            <div className="w-3 h-3" />
+            <div className="flex items-center gap-2">
+                <Folder className="h-4 w-4 text-muted-foreground" />
+                <span className="text-sm font-medium">
+                    <Trans>Move to the top level</Trans>
+                </span>
+            </div>
+        </button>
+    );
+}
+
+function CollectionTreeNode({
+    collection,
+    depth,
+    expanded,
+    onToggleExpanded,
+    onSelect,
+    selectedCollectionId,
+    collectionsToMove,
+    childCollectionsByParentId,
+}: Readonly<CollectionTreeNodeProps>) {
+    const hasChildren = collection.children && collection.children.length > 0;
+    const isExpanded = expanded[collection.id];
+    const isSelected = selectedCollectionId === collection.id;
+    const isBeingMoved = collectionsToMove.some(c => c.id === collection.id);
+    const isChildOfBeingMoved = collectionsToMove.some(c => collection.breadcrumbs.some(b => b.id === c.id));
+
+    // Don't allow selecting collections that are being moved or are children of collections being moved
+    const isSelectable = !isBeingMoved && !isChildOfBeingMoved;
+
+    const childCollections = childCollectionsByParentId[collection.id] || [];
+
+    return (
+        <div className="my-0.5">
+            <div className="flex items-center" style={{ marginLeft: depth * 20 }}>
+                {hasChildren && (
+                    <Button
+                        size="icon"
+                        variant="ghost"
+                        className="h-4 w-4 p-0 mr-1"
+                        onClick={() => onToggleExpanded(collection.id)}
+                    >
+                        {isExpanded ? (
+                            <ChevronRight className="h-3 w-3 rotate-90" />
+                        ) : (
+                            <ChevronRight className="h-3 w-3" />
+                        )}
+                    </Button>
+                )}
+                {!hasChildren && <div className="w-5 h-4 mr-1" />}
+                <button
+                    type="button"
+                    className={`flex items-center gap-2 py-2 px-3 hover:bg-accent rounded-sm cursor-pointer w-full text-left ${
+                        isSelected ? 'bg-accent' : ''
+                    } ${!isSelectable ? 'opacity-50 cursor-not-allowed' : ''}`}
+                    onClick={() => {
+                        if (isSelectable) {
+                            onSelect(collection);
+                        }
+                    }}
+                    disabled={!isSelectable}
+                >
+                    <div className="flex items-center gap-2">
+                        {hasChildren &&
+                            (isExpanded ? (
+                                <FolderOpen className="h-4 w-4 text-muted-foreground" />
+                            ) : (
+                                <Folder className="h-4 w-4 text-muted-foreground" />
+                            ))}
+                        {!hasChildren && <div className="w-4 h-4" />}
+                        <div className="flex flex-col">
+                            <span className="text-sm">{collection.name}</span>
+                            {collection.breadcrumbs.length > 1 && (
+                                <span className="text-xs text-muted-foreground">
+                                    {collection.breadcrumbs
+                                        .slice(1)
+                                        .map(b => b.name)
+                                        .join(' / ')}
+                                </span>
+                            )}
+                        </div>
+                    </div>
+                </button>
+            </div>
+            {hasChildren && isExpanded && (
+                <div>
+                    {childCollections.map((childCollection: Collection) => (
+                        <CollectionTreeNode
+                            key={childCollection.id}
+                            collection={childCollection}
+                            depth={depth + 1}
+                            expanded={expanded}
+                            onToggleExpanded={onToggleExpanded}
+                            onSelect={onSelect}
+                            selectedCollectionId={selectedCollectionId}
+                            collectionsToMove={collectionsToMove}
+                            childCollectionsByParentId={childCollectionsByParentId}
+                        />
+                    ))}
+                </div>
+            )}
+        </div>
+    );
+}
+
+export function MoveCollectionsDialog({
+    open,
+    onOpenChange,
+    collectionsToMove,
+    onSuccess,
+}: Readonly<MoveCollectionsDialogProps>) {
+    const [expanded, setExpanded] = useState<Record<string, boolean>>({});
+    const [selectedCollectionId, setSelectedCollectionId] = useState<string>();
+    const [searchTerm, setSearchTerm] = useState('');
+    const debouncedSearchTerm = useDebounce(searchTerm, 300);
+    const collectionNameCache = useRef<Map<string, string>>(new Map());
+    const queryClient = useQueryClient();
+    const { i18n } = useLingui();
+    const collectionForMoveKey = ['collectionsForMove', debouncedSearchTerm];
+    const childCollectionsForMoveKey = (collectionId?: string) =>
+        collectionId ? ['childCollectionsForMove', collectionId] : ['childCollectionsForMove'];
+
+    const { data: collectionsData, isLoading } = useQuery({
+        queryKey: collectionForMoveKey,
+        queryFn: () =>
+            api.query(collectionListForMoveDocument, {
+                options: {
+                    take: 100,
+                    topLevelOnly: !debouncedSearchTerm,
+                    ...(debouncedSearchTerm && {
+                        filter: {
+                            name: { contains: debouncedSearchTerm },
+                        },
+                    }),
+                },
+            }),
+        staleTime: 1000 * 60 * 5,
+        enabled: open,
+    });
+
+    const topLevelCollectionId = collectionsData?.collections.items[0]?.parentId;
+    const selectionHasTopLevelParent = collectionsToMove.some(c => c.parentId === topLevelCollectionId);
+
+    // Load child collections for expanded nodes
+    const childrenQueries = useQueries({
+        queries: Object.entries(expanded).map(([collectionId, isExpanded]) => {
+            return {
+                queryKey: childCollectionsForMoveKey(collectionId),
+                queryFn: () =>
+                    api.query(collectionListForMoveDocument, {
+                        options: {
+                            filter: {
+                                parentId: { eq: collectionId },
+                            },
+                        },
+                    }),
+                staleTime: 1000 * 60 * 5,
+            };
+        }),
+    });
+
+    const childCollectionsByParentId = childrenQueries.reduce(
+        (acc, query, index) => {
+            const collectionId = Object.keys(expanded)[index];
+            if (query.data) {
+                const collections = query.data.collections.items as Collection[];
+                // Populate the name cache with these collections
+                collections.forEach(collection => {
+                    collectionNameCache.current.set(collection.id, collection.name);
+                });
+                acc[collectionId] = collections;
+            }
+            return acc;
+        },
+        {} as Record<string, Collection[]>,
+    );
+
+    const moveCollectionsMutation = useMutation({
+        mutationFn: api.mutate(moveCollectionDocument),
+        onSuccess: () => {
+            toast.success(i18n.t('Collections moved successfully'));
+            queryClient.invalidateQueries({ queryKey: collectionForMoveKey });
+            queryClient.invalidateQueries({ queryKey: childCollectionsForMoveKey() });
+            onSuccess?.();
+            onOpenChange(false);
+        },
+        onError: error => {
+            toast.error(i18n.t('Failed to move collections'));
+            console.error('Move collections error:', error);
+        },
+    });
+
+    const handleToggleExpanded = (id: string) => {
+        setExpanded(prev => ({
+            ...prev,
+            [id]: !prev[id],
+        }));
+    };
+
+    const handleSelect = (collection: Collection) => {
+        setSelectedCollectionId(collection.id);
+    };
+
+    const handleMove = () => {
+        if (!selectedCollectionId) {
+            toast.error(i18n.t('Please select a target collection'));
+            return;
+        }
+        // Move to a specific parent using moveCollection
+        const movePromises = collectionsToMove.map((collection: Collection) =>
+            moveCollectionsMutation.mutateAsync({
+                input: {
+                    collectionId: collection.id,
+                    parentId: selectedCollectionId,
+                    index: 0, // Move to the beginning of the target collection
+                },
+            }),
+        );
+        Promise.all(movePromises);
+    };
+
+    const collections = (collectionsData?.collections.items as Collection[]) || [];
+
+    // Populate the name cache with top-level collections
+    collections.forEach(collection => {
+        collectionNameCache.current.set(collection.id, collection.name);
+    });
+
+    return (
+        <Dialog open={open} onOpenChange={onOpenChange}>
+            <DialogContent className="sm:max-w-[600px] max-h-[80vh]">
+                <DialogHeader>
+                    <DialogTitle>
+                        <Trans>Move Collections</Trans>
+                    </DialogTitle>
+                    <DialogDescription>
+                        <Trans>
+                            Select a target collection to move{' '}
+                            {collectionsToMove.length === 1
+                                ? 'this collection'
+                                : `${collectionsToMove.length} collections`}{' '}
+                            to.
+                        </Trans>
+                    </DialogDescription>
+                </DialogHeader>
+                <div className="px-6 py-3 bg-muted/50 border-b">
+                    <div className="flex flex-wrap gap-2">
+                        {collectionsToMove.map(collection => (
+                            <div
+                                key={collection.id}
+                                className="flex items-center gap-2 px-3 py-1 bg-background border rounded-md text-sm"
+                            >
+                                <Folder className="h-3 w-3 text-muted-foreground" />
+                                <span>{collection.name}</span>
+                            </div>
+                        ))}
+                    </div>
+                </div>
+                <div className="py-4">
+                    <div className="px-6 pb-3">
+                        <div className="relative mb-3">
+                            <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+                            <Input
+                                placeholder={i18n.t('Filter by collection name')}
+                                value={searchTerm}
+                                onChange={e => setSearchTerm(e.target.value)}
+                                className="pl-10"
+                            />
+                        </div>
+                        <ScrollArea className="h-[400px]">
+                            <div className="space-y-1">
+                                {isLoading ? (
+                                    <div className="flex items-center justify-center py-8">
+                                        <Trans>Loading collections...</Trans>
+                                    </div>
+                                ) : (
+                                    <>
+                                        {!debouncedSearchTerm && !selectionHasTopLevelParent && (
+                                            <MoveToTopLevel
+                                                selectedCollectionId={selectedCollectionId}
+                                                topLevelCollectionId={topLevelCollectionId}
+                                                onSelect={setSelectedCollectionId}
+                                            />
+                                        )}
+                                        {collections.map((collection: Collection) => (
+                                            <CollectionTreeNode
+                                                key={collection.id}
+                                                collection={collection}
+                                                depth={0}
+                                                expanded={expanded}
+                                                onToggleExpanded={handleToggleExpanded}
+                                                onSelect={handleSelect}
+                                                selectedCollectionId={selectedCollectionId}
+                                                collectionsToMove={collectionsToMove}
+                                                childCollectionsByParentId={childCollectionsByParentId}
+                                            />
+                                        ))}
+                                    </>
+                                )}
+                            </div>
+                        </ScrollArea>
+                        <TargetAlert
+                            selectedCollectionId={selectedCollectionId}
+                            collectionsToMove={collectionsToMove}
+                            topLevelCollectionId={topLevelCollectionId}
+                            collectionNameCache={collectionNameCache}
+                        />
+                    </div>
+                </div>
+                <DialogFooter>
+                    <Button variant="outline" onClick={() => onOpenChange(false)}>
+                        <Trans>Cancel</Trans>
+                    </Button>
+                    <Button
+                        onClick={handleMove}
+                        disabled={!selectedCollectionId || moveCollectionsMutation.isPending}
+                    >
+                        {moveCollectionsMutation.isPending ? (
+                            <Trans>Moving...</Trans>
+                        ) : (
+                            <Trans>Move Collections</Trans>
+                        )}
+                    </Button>
+                </DialogFooter>
+            </DialogContent>
+        </Dialog>
+    );
+}

+ 33 - 0
packages/dashboard/src/app/routes/_authenticated/_collections/components/move-single-collection.tsx

@@ -0,0 +1,33 @@
+import { ResultOf } from 'gql.tada';
+import { useState } from 'react';
+
+import { collectionListDocument } from '../collections.graphql.js';
+import { MoveCollectionsDialog } from './move-collections-dialog.js';
+
+type Collection = ResultOf<typeof collectionListDocument>['collections']['items'][number];
+
+export function useMoveSingleCollection() {
+    const [moveDialogOpen, setMoveDialogOpen] = useState(false);
+    const [collectionsToMove, setCollectionsToMove] = useState<Collection[]>([]);
+
+    const handleMoveClick = (collection: Collection) => {
+        setCollectionsToMove([collection]);
+        setMoveDialogOpen(true);
+    };
+
+    const MoveDialog = () => (
+        <MoveCollectionsDialog
+            open={moveDialogOpen}
+            onOpenChange={setMoveDialogOpen}
+            collectionsToMove={collectionsToMove}
+            onSuccess={() => {
+                // The dialog will handle invalidating queries internally
+            }}
+        />
+    );
+
+    return {
+        handleMoveClick,
+        MoveDialog,
+    };
+}

+ 1 - 1
packages/dashboard/src/lib/components/data-table/data-table.tsx

@@ -81,7 +81,7 @@ export function DataTable<TData>({
     bulkActions,
     setTableOptions,
     onRefresh,
-}: DataTableProps<TData>) {
+}: Readonly<DataTableProps<TData>>) {
     const [sorting, setSorting] = React.useState<SortingState>(sortingInitialState || []);
     const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(filtersInitialState || []);
     const { activeChannel } = useChannel();

+ 1 - 1
packages/dashboard/src/lib/components/shared/paginated-list-data-table.tsx

@@ -273,7 +273,7 @@ export function PaginatedListDataTable<
     setTableOptions,
     transformData,
     registerRefresher,
-}: PaginatedListDataTableProps<T, U, V, AC>) {
+}: Readonly<PaginatedListDataTableProps<T, U, V, AC>>) {
     const [searchTerm, setSearchTerm] = React.useState<string>('');
     const debouncedSearchTerm = useDebounce(searchTerm, 500);
     const queryClient = useQueryClient();

+ 1 - 1
packages/dashboard/src/lib/framework/page/list-page.tsx

@@ -90,7 +90,7 @@ export function ListPage<
     transformData,
     setTableOptions,
     bulkActions,
-}: ListPageProps<T, U, V, AC>) {
+}: Readonly<ListPageProps<T, U, V, AC>>) {
     const route = typeof routeOrFn === 'function' ? routeOrFn() : routeOrFn;
     const routeSearch = route.useSearch();
     const navigate = useNavigate<AnyRouter>({ from: route.fullPath });

+ 1 - 1
packages/dashboard/vite/vite-plugin-vendure-dashboard.ts

@@ -111,7 +111,7 @@ export function vendureDashboardPlugin(options: VitePluginVendureDashboardOption
             : [
                   TanStackRouterVite({
                       autoCodeSplitting: true,
-                      routeFileIgnorePattern: '.graphql.ts|components',
+                      routeFileIgnorePattern: '.graphql.ts|components|hooks',
                       routesDirectory: path.join(packageRoot, 'src/app/routes'),
                       generatedRouteTree: path.join(packageRoot, 'src/app/routeTree.gen.ts'),
                   }),