Kaynağa Gözat

feat(dashboard): Implement dragging to reorder collections (#4035)

Will Nahmens 1 ay önce
ebeveyn
işleme
3c4716036d

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

@@ -163,6 +163,7 @@ export const moveCollectionDocument = graphql(`
     mutation MoveCollection($input: MoveCollectionInput!) {
         moveCollection(input: $input) {
             id
+            position
         }
     }
 `);

+ 249 - 167
packages/dashboard/src/app/routes/_authenticated/_collections/collections.tsx

@@ -4,18 +4,25 @@ import { Button } from '@/vdb/components/ui/button.js';
 import { PageActionBarRight } from '@/vdb/framework/layout-engine/page-layout.js';
 import { ListPage } from '@/vdb/framework/page/list-page.js';
 import { api } from '@/vdb/graphql/api.js';
-import { Trans } from '@lingui/react/macro';
-import { FetchQueryOptions, useQueries } from '@tanstack/react-query';
+import { Trans, useLingui } from '@lingui/react/macro';
+import { FetchQueryOptions, useQueries, useQueryClient } from '@tanstack/react-query';
 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 { useState } from 'react';
+import { toast } from 'sonner';
 
 import { RichTextDescriptionCell } from '@/vdb/components/shared/table-cell/order-table-cell-components.js';
 import { Badge } from '@/vdb/components/ui/badge.js';
-import { collectionListDocument } from './collections.graphql.js';
+import {
+    calculateDragTargetPosition,
+    calculateSiblingIndex,
+    getItemParentId,
+    isCircularReference,
+} from '@/vdb/components/data-table/data-table-utils.js';
+import { collectionListDocument, moveCollectionDocument } from './collections.graphql.js';
 import {
     AssignCollectionsToChannelBulkAction,
     DeleteCollectionsBulkAction,
@@ -25,15 +32,21 @@ import {
 } from './components/collection-bulk-actions.js';
 import { CollectionContentsSheet } from './components/collection-contents-sheet.js';
 
+
 export const Route = createFileRoute('/_authenticated/_collections/collections')({
     component: CollectionListPage,
     loader: () => ({ breadcrumb: () => <Trans>Collections</Trans> }),
 });
 
+
 type Collection = ResultOf<typeof collectionListDocument>['collections']['items'][number];
 
 function CollectionListPage() {
+    const { t } = useLingui();
+    const queryClient = useQueryClient();
     const [expanded, setExpanded] = useState<ExpandedState>({});
+    const [searchTerm, setSearchTerm] = useState<string>('');
+
     const childrenQueries = useQueries({
         queries: Object.entries(expanded).map(([collectionId, isExpanded]) => {
             return {
@@ -50,6 +63,7 @@ function CollectionListPage() {
             } satisfies FetchQueryOptions;
         }),
     });
+
     const childCollectionsByParentId = childrenQueries.reduce(
         (acc, query, index) => {
             const collectionId = Object.keys(expanded)[index];
@@ -79,177 +93,245 @@ function CollectionListPage() {
         return allRows;
     };
 
+    const handleReorder = async (oldIndex: number, newIndex: number, item: Collection, allItems?: Collection[]) => {
+        try {
+            const items = allItems || [];
+            const sourceParentId = getItemParentId(item);
+
+            if (!sourceParentId) {
+                throw new Error('Unable to determine parent collection ID');
+            }
+
+            // Calculate target position (parent and index)
+            const { targetParentId, adjustedIndex: initialIndex } = calculateDragTargetPosition({
+                item,
+                oldIndex,
+                newIndex,
+                items,
+                sourceParentId,
+                expanded,
+            });
+
+            // Validate no circular references when moving to different parent
+            if (targetParentId !== sourceParentId && isCircularReference(item, targetParentId, items)) {
+                toast.error(t`Cannot move a collection into its own descendant`);
+                throw new Error('Circular reference detected');
+            }
+
+            // Calculate final index (adjust for same-parent moves)
+            const adjustedIndex = targetParentId === sourceParentId
+                ? calculateSiblingIndex({ item, oldIndex, newIndex, items, parentId: sourceParentId })
+                : initialIndex;
+
+            // Perform the move
+            await api.mutate(moveCollectionDocument, {
+                input: {
+                    collectionId: item.id,
+                    parentId: targetParentId,
+                    index: adjustedIndex,
+                },
+            });
+
+            // Invalidate queries and show success message
+            const queriesToInvalidate = [
+                queryClient.invalidateQueries({ queryKey: ['childCollections', sourceParentId] }),
+                queryClient.invalidateQueries({ queryKey: ['PaginatedListDataTable'] }),
+            ];
+
+            if (targetParentId === sourceParentId) {
+                await Promise.all(queriesToInvalidate);
+                toast.success(t`Collection position updated`);
+            } else {
+                queriesToInvalidate.push(
+                    queryClient.invalidateQueries({ queryKey: ['childCollections', targetParentId] })
+                );
+                await Promise.all(queriesToInvalidate);
+                toast.success(t`Collection moved to new parent`);
+            }
+        } catch (error) {
+            console.error('Failed to reorder collection:', error);
+            if (error instanceof Error && error.message !== 'Circular reference detected') {
+                toast.error(t`Failed to update collection position`);
+            }
+            throw error;
+        }
+    };
+
     return (
-        <>
-            <ListPage
-                pageId="collection-list"
-                title={<Trans>Collections</Trans>}
-                listQuery={collectionListDocument}
-                transformVariables={input => {
-                    const filterTerm = input.options?.filter?.name?.contains;
-                    const isFiltering = !!filterTerm;
-                    return {
-                        options: {
-                            ...input.options,
-                            topLevelOnly: !isFiltering,
-                        },
-                    };
-                }}
-                customizeColumns={{
-                    name: {
-                        meta: {
-                            // This column needs the following fields to always be available
-                            // in order to correctly render.
-                            dependencies: ['children', 'breadcrumbs'],
-                        },
-                        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>
-                            );
-                        },
+        <ListPage
+            pageId="collection-list"
+            title={<Trans>Collections</Trans>}
+            listQuery={collectionListDocument}
+            transformVariables={input => {
+                const filterTerm = input.options?.filter?.name?.contains;
+                const isFiltering = !!filterTerm;
+                return {
+                    options: {
+                        ...input.options,
+                        topLevelOnly: !isFiltering,
                     },
-                    description: {
-                        cell: RichTextDescriptionCell,
+                };
+            }}
+            customizeColumns={{
+                name: {
+                    meta: {
+                        dependencies: ['children', 'breadcrumbs'],
                     },
-                    breadcrumbs: {
-                        cell: ({ cell }) => {
-                            const value = cell.getValue();
-                            if (!Array.isArray(value)) {
-                                return null;
-                            }
-                            return (
-                                <div>
-                                    {value
-                                        .slice(1)
-                                        .map(breadcrumb => breadcrumb.name)
-                                        .join(' / ')}
-                                </div>
-                            );
-                        },
-                    },
-                    productVariants: {
-                        header: () => <Trans>Contents</Trans>,
-                        cell: ({ row }) => {
-                            return (
-                                <CollectionContentsSheet
-                                    collectionId={row.original.id}
-                                    collectionName={row.original.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' : ''}
                                 >
-                                    <Trans>{row.original.productVariants?.totalItems} variants</Trans>
-                                </CollectionContentsSheet>
-                            );
-                        },
-                    },
-                    children: {
-                        cell: ({ row }) => {
-                            const children = row.original.children ?? [];
-                            const count = children.length;
-                            const maxDisplay = 5;
-                            const leftOver = Math.max(count - maxDisplay, 0);
-                            return (
-                                <div className="flex flex-wrap gap-2">
-                                    {children.slice(0, maxDisplay).map(child => (
-                                        <Badge variant="outline">{child.name}</Badge>
-                                    ))}
-                                    {leftOver > 0 ? (
-                                        <Badge variant="outline">
-                                            <Trans>+ {leftOver} more</Trans>
-                                        </Badge>
-                                    ) : null}
-                                </div>
-                            );
-                        },
+                                    {isExpanded ? <FolderOpen /> : <Folder />}
+                                </Button>
+                                <DetailPageButton id={row.original.id} label={row.original.name} />
+                            </div>
+                        );
                     },
-                }}
-                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,
-                    description: false,
-                }}
-                onSearchTermChange={searchTerm => {
-                    return {
-                        name: { contains: searchTerm },
-                    };
-                }}
-                route={Route}
-                bulkActions={[
-                    {
-                        component: AssignCollectionsToChannelBulkAction,
-                        order: 100,
+                },
+                description: {
+                    cell: RichTextDescriptionCell,
+                },
+                breadcrumbs: {
+                    cell: ({ cell }) => {
+                        const value = cell.getValue();
+                        if (!Array.isArray(value)) {
+                            return null;
+                        }
+                        return (
+                            <div>
+                                {value
+                                    .slice(1)
+                                    .map(breadcrumb => breadcrumb.name)
+                                    .join(' / ')}
+                            </div>
+                        );
                     },
-                    {
-                        component: RemoveCollectionsFromChannelBulkAction,
-                        order: 200,
+                },
+                productVariants: {
+                    header: () => <Trans>Contents</Trans>,
+                    cell: ({ row }) => {
+                        return (
+                            <CollectionContentsSheet
+                                collectionId={row.original.id}
+                                collectionName={row.original.name}
+                            >
+                                <Trans>{row.original.productVariants?.totalItems} variants</Trans>
+                            </CollectionContentsSheet>
+                        );
                     },
-                    {
-                        component: DuplicateCollectionsBulkAction,
-                        order: 300,
+                },
+                children: {
+                    cell: ({ row }) => {
+                        const children = row.original.children ?? [];
+                        const count = children.length;
+                        const maxDisplay = 5;
+                        const leftOver = Math.max(count - maxDisplay, 0);
+                        return (
+                            <div className="flex flex-wrap gap-2">
+                                {children.slice(0, maxDisplay).map(child => (
+                                    <Badge key={child.id} variant="outline">{child.name}</Badge>
+                                ))}
+                                {leftOver > 0 ? (
+                                    <Badge variant="outline">
+                                        <Trans>+ {leftOver} more</Trans>
+                                    </Badge>
+                                ) : null}
+                            </div>
+                        );
                     },
-                    {
-                        component: MoveCollectionsBulkAction,
-                        order: 400,
-                    },
-                    {
-                        component: DeleteCollectionsBulkAction,
-                        order: 500,
-                    },
-                ]}
-            >
-                <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>
-        </>
+                },
+            }}
+            defaultColumnOrder={[
+                'featuredAsset',
+                '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;
+                };
+                options.meta = {
+                    ...options.meta,
+                    resetExpanded: () => setExpanded({}),
+                };
+                return options;
+            }}
+            defaultVisibility={{
+                id: false,
+                createdAt: false,
+                updatedAt: false,
+                position: false,
+                parentId: false,
+                children: false,
+                description: false,
+                isPrivate: false,
+            }}
+            onSearchTermChange={searchTerm => {
+                setSearchTerm(searchTerm);
+                return {
+                    name: { contains: searchTerm },
+                };
+            }}
+            route={Route}
+            bulkActions={[
+                {
+                    component: AssignCollectionsToChannelBulkAction,
+                    order: 100,
+                },
+                {
+                    component: RemoveCollectionsFromChannelBulkAction,
+                    order: 200,
+                },
+                {
+                    component: DuplicateCollectionsBulkAction,
+                    order: 300,
+                },
+                {
+                    component: MoveCollectionsBulkAction,
+                    order: 400,
+                },
+                {
+                    component: DeleteCollectionsBulkAction,
+                    order: 500,
+                },
+            ]}
+            onReorder={handleReorder}
+            disableDragAndDrop={!!searchTerm} // Disable dragging while searching
+        >
+            <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>
     );
 }
+

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

@@ -103,6 +103,13 @@ export const MoveCollectionsBulkAction: BulkActionComponent<any> = ({ selection,
         table.resetRowSelection();
     };
 
+    const handleResetExpanded = () => {
+        const resetExpanded = (table.options.meta as { resetExpanded: () => void })?.resetExpanded;
+        if (resetExpanded) {
+            resetExpanded();
+        }
+    };
+
     return (
         <>
             <DataTableBulkActionItem
@@ -116,6 +123,7 @@ export const MoveCollectionsBulkAction: BulkActionComponent<any> = ({ selection,
                 onOpenChange={setDialogOpen}
                 collectionsToMove={selection}
                 onSuccess={handleSuccess}
+                onResetExpanded={handleResetExpanded}
             />
         </>
     );

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

@@ -34,6 +34,7 @@ interface MoveCollectionsDialogProps {
     onOpenChange: (open: boolean) => void;
     collectionsToMove: Collection[];
     onSuccess?: () => void;
+    onResetExpanded?: () => void;
 }
 
 interface CollectionTreeNodeProps {
@@ -209,6 +210,7 @@ export function MoveCollectionsDialog({
     onOpenChange,
     collectionsToMove,
     onSuccess,
+    onResetExpanded,
 }: Readonly<MoveCollectionsDialogProps>) {
     const [expanded, setExpanded] = useState<Record<string, boolean>>({});
     const [selectedCollectionId, setSelectedCollectionId] = useState<string>();
@@ -282,6 +284,8 @@ export function MoveCollectionsDialog({
             toast.success(t`Collections moved successfully`);
             queryClient.invalidateQueries({ queryKey: collectionForMoveKey });
             queryClient.invalidateQueries({ queryKey: childCollectionsForMoveKey() });
+            queryClient.invalidateQueries({ queryKey: ['PaginatedListDataTable'] });
+            onResetExpanded?.();
             onSuccess?.();
             onOpenChange(false);
         },

+ 241 - 1
packages/dashboard/src/lib/components/data-table/data-table-utils.ts

@@ -1,4 +1,4 @@
-import { AccessorFnColumnDef } from '@tanstack/react-table';
+import { AccessorFnColumnDef, ExpandedState } from '@tanstack/react-table';
 import { AccessorKeyColumnDef } from '@tanstack/table-core';
 
 /**
@@ -49,3 +49,243 @@ export function getStandardizedDefaultColumnOrder<T extends string | number | sy
     const rest = defaultColumnOrder.filter(c => !standardFirstColumns.has(c as string));
     return [...standardFirstColumns, ...rest] as T[];
 }
+
+/**
+ * Hierarchical item type with parent-child relationships
+ */
+export interface HierarchicalItem {
+    id: string;
+    parentId?: string | null;
+    breadcrumbs?: Array<{ id: string }>;
+    children?: Array<{ id: string }> | null;
+}
+
+/**
+ * Gets the parent ID of a hierarchical item
+ */
+export function getItemParentId<T extends HierarchicalItem>(
+    item: T | null | undefined,
+): string | null | undefined {
+    return item?.parentId || item?.breadcrumbs?.[0]?.id;
+}
+
+/**
+ * Gets all siblings (items with the same parent) for a given parent ID
+ */
+export function getItemSiblings<T extends HierarchicalItem>(
+    items: T[],
+    parentId: string | null | undefined,
+): T[] {
+    return items.filter(item => getItemParentId(item) === parentId);
+}
+
+/**
+ * Checks if moving an item to a new parent would create a circular reference
+ */
+export function isCircularReference<T extends HierarchicalItem>(
+    item: T,
+    targetParentId: string,
+    items: T[],
+): boolean {
+    const targetParentItem = items.find(i => i.id === targetParentId);
+    return (
+        item.children?.some(child => {
+            if (child.id === targetParentId) return true;
+            const targetBreadcrumbIds = targetParentItem?.breadcrumbs?.map(b => b.id) || [];
+            return targetBreadcrumbIds.includes(item.id);
+        }) ?? false
+    );
+}
+
+/**
+ * Result of calculating the target position for a drag and drop operation
+ */
+export interface TargetPosition {
+    targetParentId: string;
+    adjustedIndex: number;
+}
+
+/**
+ * Context for drag and drop position calculation
+ */
+interface DragContext<T extends HierarchicalItem> {
+    item: T;
+    targetItem: T | undefined;
+    previousItem: T | null;
+    isDraggingDown: boolean;
+    isTargetExpanded: boolean;
+    isPreviousExpanded: boolean;
+    sourceParentId: string;
+    items: T[];
+}
+
+/**
+ * Checks if dragging down directly onto an expanded item
+ */
+function isDroppingIntoExpandedTarget<T extends HierarchicalItem>(context: DragContext<T>): boolean {
+    const { isDraggingDown, targetItem, item, isTargetExpanded } = context;
+    return isDraggingDown && targetItem?.id !== item.id && isTargetExpanded;
+}
+
+/**
+ * Checks if dragging down into an expanded item's children area
+ */
+function isDroppingIntoExpandedPreviousChildren<T extends HierarchicalItem>(
+    context: DragContext<T>,
+): boolean {
+    const { isDraggingDown, targetItem, previousItem, item, isPreviousExpanded } = context;
+    return (
+        isDraggingDown &&
+        previousItem !== null &&
+        targetItem?.id !== item.id &&
+        isPreviousExpanded &&
+        targetItem?.parentId === previousItem.id
+    );
+}
+
+/**
+ * Checks if dragging up into an expanded item's children area
+ */
+function isDroppingIntoExpandedPreviousWhenDraggingUp<T extends HierarchicalItem>(
+    context: DragContext<T>,
+): boolean {
+    const { isDraggingDown, previousItem, isPreviousExpanded } = context;
+    return !isDraggingDown && previousItem !== null && isPreviousExpanded;
+}
+
+/**
+ * Creates a position for dropping into an expanded item as first child
+ */
+function createFirstChildPosition(parentId: string): TargetPosition {
+    return { targetParentId: parentId, adjustedIndex: 0 };
+}
+
+/**
+ * Calculates position for cross-parent drag operations
+ */
+function calculateCrossParentPosition<T extends HierarchicalItem>(
+    targetItem: T,
+    sourceParentId: string,
+    items: T[],
+): TargetPosition | null {
+    const targetItemParentId = getItemParentId(targetItem);
+
+    if (!targetItemParentId || targetItemParentId === sourceParentId) {
+        return null;
+    }
+
+    const targetSiblings = getItemSiblings(items, targetItemParentId);
+    const adjustedIndex = targetSiblings.findIndex(i => i.id === targetItem.id);
+
+    return { targetParentId: targetItemParentId, adjustedIndex };
+}
+
+/**
+ * Calculates position when dropping at the end of the list
+ */
+function calculateDropAtEndPosition<T extends HierarchicalItem>(
+    previousItem: T | null,
+    sourceParentId: string,
+    items: T[],
+): TargetPosition | null {
+    if (!previousItem) {
+        return null;
+    }
+
+    const previousItemParentId = getItemParentId(previousItem);
+
+    if (!previousItemParentId || previousItemParentId === sourceParentId) {
+        return null;
+    }
+
+    const targetSiblings = getItemSiblings(items, previousItemParentId);
+    return { targetParentId: previousItemParentId, adjustedIndex: targetSiblings.length };
+}
+
+/**
+ * Determines the target parent and index for a hierarchical drag and drop operation
+ */
+export function calculateDragTargetPosition<T extends HierarchicalItem>(params: {
+    item: T;
+    oldIndex: number;
+    newIndex: number;
+    items: T[];
+    sourceParentId: string;
+    expanded: ExpandedState;
+}): TargetPosition {
+    const { item, oldIndex, newIndex, items, sourceParentId, expanded } = params;
+
+    const targetItem = items[newIndex];
+    const previousItem = newIndex > 0 ? items[newIndex - 1] : null;
+
+    const context: DragContext<T> = {
+        item,
+        targetItem,
+        previousItem,
+        isDraggingDown: oldIndex < newIndex,
+        isTargetExpanded: targetItem ? !!expanded[targetItem.id as keyof ExpandedState] : false,
+        isPreviousExpanded: previousItem ? !!expanded[previousItem.id as keyof ExpandedState] : false,
+        sourceParentId,
+        items,
+    };
+
+    // Handle dropping into expanded items (becomes first child)
+    if (isDroppingIntoExpandedTarget(context)) {
+        return createFirstChildPosition(targetItem.id);
+    }
+
+    if (previousItem && isDroppingIntoExpandedPreviousChildren(context)) {
+        return createFirstChildPosition(previousItem.id);
+    }
+
+    if (previousItem && isDroppingIntoExpandedPreviousWhenDraggingUp(context)) {
+        return createFirstChildPosition(previousItem.id);
+    }
+
+    // Handle cross-parent drag operations
+    if (targetItem?.id !== item.id) {
+        const crossParentPosition = calculateCrossParentPosition(targetItem, sourceParentId, items);
+        if (crossParentPosition) {
+            return crossParentPosition;
+        }
+    }
+
+    // Handle dropping at the end of the list
+    if (!targetItem && previousItem) {
+        const dropAtEndPosition = calculateDropAtEndPosition(previousItem, sourceParentId, items);
+        if (dropAtEndPosition) {
+            return dropAtEndPosition;
+        }
+    }
+
+    // Default: stay in the same parent at the beginning
+    return { targetParentId: sourceParentId, adjustedIndex: 0 };
+}
+
+/**
+ * Calculates the adjusted sibling index when reordering within the same parent
+ */
+export function calculateSiblingIndex<T extends HierarchicalItem>(params: {
+    item: T;
+    oldIndex: number;
+    newIndex: number;
+    items: T[];
+    parentId: string;
+}): number {
+    const { item, oldIndex, newIndex, items, parentId } = params;
+
+    const siblings = getItemSiblings(items, parentId);
+    const oldSiblingIndex = siblings.findIndex(i => i.id === item.id);
+    const isDraggingDown = oldIndex < newIndex;
+
+    let newSiblingIndex = oldSiblingIndex;
+    const [start, end] = isDraggingDown ? [oldIndex + 1, newIndex] : [newIndex, oldIndex - 1];
+
+    for (let i = start; i <= end; i++) {
+        if (getItemParentId(items[i]) === parentId) {
+            newSiblingIndex += isDraggingDown ? 1 : -1;
+        }
+    }
+
+    return newSiblingIndex;
+}

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

@@ -15,6 +15,17 @@ import { useChannel } from '@/vdb/hooks/use-channel.js';
 import { usePage } from '@/vdb/hooks/use-page.js';
 import { useSavedViews } from '@/vdb/hooks/use-saved-views.js';
 import { Trans, useLingui } from '@lingui/react/macro';
+import {
+    closestCenter,
+    DndContext,
+} from '@dnd-kit/core';
+import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
+import {
+    SortableContext,
+    useSortable,
+    verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
 import {
     ColumnDef,
     ColumnFilter,
@@ -23,18 +34,66 @@ import {
     getCoreRowModel,
     getPaginationRowModel,
     PaginationState,
+    Row,
     SortingState,
     Table as TableType,
     useReactTable,
     VisibilityState,
 } from '@tanstack/react-table';
 import { RowSelectionState, TableOptions } from '@tanstack/table-core';
-import React, { Suspense, useEffect, useRef } from 'react';
+import { GripVertical } from 'lucide-react';
+import React, { Suspense, useEffect, useId, useMemo, useRef } from 'react';
 import { AddFilterMenu } from './add-filter-menu.js';
 import { DataTableBulkActions } from './data-table-bulk-actions.js';
 import { DataTableProvider } from './data-table-context.js';
 import { DataTableFacetedFilter, DataTableFacetedFilterOption } from './data-table-faceted-filter.js';
 import { DataTableFilterBadgeEditable } from './data-table-filter-badge-editable.js';
+import { useDragAndDrop } from '@/vdb/hooks/use-drag-and-drop.js';
+import { toast } from 'sonner';
+
+interface DraggableRowProps<TData> {
+    row: Row<TData>;
+    isDragDisabled: boolean;
+}
+
+function DraggableRow<TData>({ row, isDragDisabled }: Readonly<DraggableRowProps<TData>>) {
+    const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+        id: row.id,
+        disabled: isDragDisabled,
+    });
+
+    const style = {
+        transform: CSS.Transform.toString(transform),
+        transition,
+        opacity: isDragging ? 0.5 : 1,
+    };
+
+    return (
+        <TableRow
+            ref={setNodeRef}
+            style={style}
+            data-state={row.getIsSelected() && 'selected'}
+            className="animate-in fade-in duration-100"
+        >
+            {!isDragDisabled && (
+                <TableCell className="w-[40px] h-12">
+                    <div
+                        {...attributes}
+                        {...listeners}
+                        className="cursor-move text-muted-foreground hover:text-foreground transition-colors"
+                    >
+                        <GripVertical className="h-4 w-4" />
+                    </div>
+                </TableCell>
+            )}
+            {row.getVisibleCells().filter(cell => cell.column.id !== '__drag_handle__').map(cell => (
+                <TableCell key={cell.id} className="h-12">
+                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
+                </TableCell>
+            ))}
+        </TableRow>
+    );
+}
 
 export interface FacetedFilter {
     title: string;
@@ -77,6 +136,18 @@ interface DataTableProps<TData> {
      */
     setTableOptions?: (table: TableOptions<TData>) => TableOptions<TData>;
     onRefresh?: () => void;
+    /**
+     * @description
+     * Callback when items are reordered via drag and drop.
+     * When provided, enables drag-and-drop functionality.
+     * The fourth parameter provides all items for context-aware reordering.
+     */
+    onReorder?: (oldIndex: number, newIndex: number, item: TData, allItems?: TData[]) => void | Promise<void>;
+    /**
+     * @description
+     * When true, drag and drop will be disabled. This will only have an effect if the onReorder prop is also set
+     */
+    disableDragAndDrop?: boolean;
 }
 
 /**
@@ -111,6 +182,8 @@ export function DataTable<TData>({
     bulkActions,
     setTableOptions,
     onRefresh,
+    onReorder,
+    disableDragAndDrop = false,
 }: Readonly<DataTableProps<TData>>) {
     const [sorting, setSorting] = React.useState<SortingState>(sortingInitialState || []);
     const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(filtersInitialState || []);
@@ -131,6 +204,16 @@ export function DataTable<TData>({
     const prevSearchTermRef = useRef(searchTerm);
     const prevColumnFiltersRef = useRef(columnFilters);
 
+    const componentId = useId();
+    const { sensors, localData, handleDragEnd, itemIds } = useDragAndDrop({
+        data,
+        onReorder,
+        disabled: disableDragAndDrop,
+        onError: error => {
+            toast.error(t`Failed to reorder items`);
+        },
+    });
+
     useEffect(() => {
         // If the defaultColumnVisibility changes externally (e.g. the user reset the table settings),
         // we want to reset the column visibility to the default.
@@ -143,9 +226,25 @@ export function DataTable<TData>({
         // We intentionally do not include `columnVisibility` in the dependency array
     }, [defaultColumnVisibility]);
 
+    // Add drag handle column if drag and drop is enabled
+    const columnsWithOptionalDragHandle = useMemo(() => {
+        if (!disableDragAndDrop && onReorder) {
+            const dragHandleColumn: ColumnDef<TData, any> = {
+                id: '__drag_handle__',
+                header: '',
+                cell: () => null, // Rendered by DraggableRow
+                size: 40,
+                enableSorting: false,
+                enableHiding: false,
+            };
+            return [dragHandleColumn, ...columns];
+        }
+        return columns;
+    }, [columns, disableDragAndDrop, onReorder]);
+
     let tableOptions: TableOptions<TData> = {
-        data,
-        columns,
+        data: localData,
+        columns: columnsWithOptionalDragHandle,
         getRowId: row => (row as { id: string }).id,
         getCoreRowModel: getCoreRowModel(),
         getPaginationRowModel: getPaginationRowModel(),
@@ -220,6 +319,8 @@ export function DataTable<TData>({
 
     const visibleColumnCount = Object.values(columnVisibility).filter(Boolean).length;
 
+    const isDragDisabled = disableDragAndDrop || !onReorder;
+
     return (
         <DataTableProvider
             columnFilters={columnFilters}
@@ -310,66 +411,94 @@ export function DataTable<TData>({
                 ) : null}
 
                 <div className="rounded-md border my-2 relative shadow-sm">
-                    <Table>
-                        <TableHeader className="bg-muted/50">
-                            {table.getHeaderGroups().map(headerGroup => (
-                                <TableRow key={headerGroup.id}>
-                                    {headerGroup.headers.map(header => {
-                                        return (
-                                            <TableHead key={header.id}>
-                                                {header.isPlaceholder
-                                                    ? null
-                                                    : flexRender(
-                                                          header.column.columnDef.header,
-                                                          header.getContext(),
-                                                      )}
-                                            </TableHead>
-                                        );
-                                    })}
-                                </TableRow>
-                            ))}
-                        </TableHeader>
-                        <TableBody>
-                            {isLoading && !data?.length ? (
-                                Array.from({ length: Math.min(pagination.pageSize, 100) }).map((_, index) => (
-                                    <TableRow
-                                        key={`skeleton-${index}`}
-                                        className="animate-in fade-in duration-100"
-                                    >
-                                        {Array.from({ length: visibleColumnCount }).map((_, cellIndex) => (
+                    <DndContext
+                        sensors={sensors}
+                        collisionDetection={closestCenter}
+                        onDragEnd={handleDragEnd}
+                        modifiers={[restrictToVerticalAxis]}
+                    >
+                        <Table>
+                            <TableHeader className="bg-muted/50">
+                                {table.getHeaderGroups().map(headerGroup => (
+                                    <TableRow key={headerGroup.id}>
+                                        {headerGroup.headers.map(header => {
+                                            return (
+                                                <TableHead key={header.id}>
+                                                    {header.isPlaceholder
+                                                        ? null
+                                                        : flexRender(
+                                                              header.column.columnDef.header,
+                                                              header.getContext(),
+                                                          )}
+                                                </TableHead>
+                                            );
+                                        })}
+                                    </TableRow>
+                                ))}
+                            </TableHeader>
+                            <SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
+                                <TableBody>
+                                    {isLoading && !localData?.length ? (
+                                        Array.from({ length: Math.min(pagination.pageSize, 100) }).map((_, index) => (
+                                            <TableRow
+                                                key={`skeleton-${index}`}
+                                                className="animate-in fade-in duration-100"
+                                            >
+                                                {!isDragDisabled && (
+                                                    <TableCell className="w-[40px] h-12">
+                                                        <Skeleton className="h-4 w-4" />
+                                                    </TableCell>
+                                                )}
+                                                {Array.from({ length: visibleColumnCount }).map((_, cellIndex) => (
+                                                    <TableCell
+                                                        key={`skeleton-cell-${index}-${cellIndex}`}
+                                                        className="h-12"
+                                                    >
+                                                        <Skeleton className="h-4 my-2 w-full" />
+                                                    </TableCell>
+                                                ))}
+                                            </TableRow>
+                                        ))
+                                    ) : table.getRowModel().rows?.length ? (
+                                        (() => {
+                                            const isDraggableEnabled = onReorder && !isDragDisabled;
+                                            const rows = table.getRowModel().rows;
+                                            
+                                            if (isDraggableEnabled) {
+                                                return rows.map(row => (
+                                                    <DraggableRow key={`${row.id}-${componentId}`} row={row} isDragDisabled={isDragDisabled} />
+                                                ));
+                                            }
+                                            
+                                            return rows.map(row => (
+                                                <TableRow
+                                                    key={row.id}
+                                                    data-state={row.getIsSelected() && 'selected'}
+                                                    className="animate-in fade-in duration-100"
+                                                >
+                                                    {row.getVisibleCells().map(cell => (
+                                                        <TableCell key={cell.id} className="h-12">
+                                                            {flexRender(cell.column.columnDef.cell, cell.getContext())}
+                                                        </TableCell>
+                                                    ))}
+                                                </TableRow>
+                                            ));
+                                        })()
+                                    ) : (
+                                        <TableRow className="animate-in fade-in duration-100">
                                             <TableCell
-                                                key={`skeleton-cell-${index}-${cellIndex}`}
-                                                className="h-12"
+                                                colSpan={columnsWithOptionalDragHandle.length + (isDragDisabled ? 0 : 1)}
+                                                className="h-24 text-center"
                                             >
-                                                <Skeleton className="h-4 my-2 w-full" />
+                                                <Trans>No results</Trans>
                                             </TableCell>
-                                        ))}
-                                    </TableRow>
-                                ))
-                            ) : table.getRowModel().rows?.length ? (
-                                table.getRowModel().rows.map(row => (
-                                    <TableRow
-                                        key={row.id}
-                                        data-state={row.getIsSelected() && 'selected'}
-                                        className="animate-in fade-in duration-100"
-                                    >
-                                        {row.getVisibleCells().map(cell => (
-                                            <TableCell key={cell.id} className="h-12">
-                                                {flexRender(cell.column.columnDef.cell, cell.getContext())}
-                                            </TableCell>
-                                        ))}
-                                    </TableRow>
-                                ))
-                            ) : (
-                                <TableRow className="animate-in fade-in duration-100">
-                                    <TableCell colSpan={columns.length} className="h-24 text-center">
-                                        <Trans>No results</Trans>
-                                    </TableCell>
-                                </TableRow>
-                            )}
-                            {children}
-                        </TableBody>
-                    </Table>
+                                        </TableRow>
+                                    )}
+                                    {children}
+                                </TableBody>
+                            </SortableContext>
+                        </Table>
+                    </DndContext>
                     <DataTableBulkActions bulkActions={bulkActions ?? []} table={table} />
                 </div>
                 {onPageChange && totalItems != null && <DataTablePagination table={table} />}

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

@@ -234,6 +234,21 @@ export interface PaginatedListDataTableProps<
      * the list needs to be refreshed.
      */
     registerRefresher?: PaginatedListRefresherRegisterFn;
+    /**
+     * @description
+     * Callback when items are reordered via drag and drop.
+     * When provided, enables drag-and-drop functionality.
+     */
+    onReorder?: (
+        oldIndex: number,
+        newIndex: number,
+        item: PaginatedListItemFields<T>,
+    ) => void | Promise<void>;
+    /**
+     * @description
+     * When true, drag and drop will be disabled. This will only have an effect if the onReorder prop is also set
+     */
+    disableDragAndDrop?: boolean;
 }
 
 export const PaginatedListDataTableKey = 'PaginatedListDataTable';
@@ -378,6 +393,8 @@ export function PaginatedListDataTable<
     setTableOptions,
     transformData,
     registerRefresher,
+    onReorder,
+    disableDragAndDrop = false,
 }: Readonly<PaginatedListDataTableProps<T, U, V, AC>>) {
     const [searchTerm, setSearchTerm] = React.useState<string>('');
     const debouncedSearchTerm = useDebounce(searchTerm, 500);
@@ -498,6 +515,8 @@ export function PaginatedListDataTable<
                 bulkActions={bulkActions}
                 setTableOptions={setTableOptions}
                 onRefresh={refetchPaginatedList}
+                onReorder={onReorder}
+                disableDragAndDrop={disableDragAndDrop}
             />
         </PaginatedListContext.Provider>
     );

+ 1 - 1
packages/dashboard/src/lib/components/ui/alert.tsx

@@ -8,7 +8,7 @@ const alertVariants = cva(
     {
         variants: {
             variant: {
-                default: 'bg-background text-foreground',
+                default: 'bg-background text-primary/80',
                 destructive:
                     'border-destructive/50 text-destructive dark:text-destructive-foreground/80 dark:border-destructive [&>svg]:text-current dark:bg-destructive/50',
             },

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

@@ -361,6 +361,22 @@ export interface ListPageProps<
      * the list needs to be refreshed.
      */
     registerRefresher?: PaginatedListRefresherRegisterFn;
+    /**
+     * @description
+     * Callback when items are reordered via drag and drop.
+     * Only applies to top-level items. When provided, enables drag-and-drop functionality.
+     *
+     * @param oldIndex - The original index of the dragged item
+     * @param newIndex - The new index where the item was dropped
+     * @param item - The data of the item that was moved
+     */
+    onReorder?: (oldIndex: number, newIndex: number, item: any) => void | Promise<void>;
+    /**
+     * @description
+     * When true, drag and drop will be disabled. This will only have an effect if the onReorder prop is also set Useful when filtering or searching.
+     * Defaults to false. Only relevant when `onReorder` is provided.
+     */
+    disableDragAndDrop?: boolean;
 }
 
 /**
@@ -481,6 +497,8 @@ export function ListPage<
     setTableOptions,
     bulkActions,
     registerRefresher,
+    onReorder,
+    disableDragAndDrop = false,
 }: Readonly<ListPageProps<T, U, V, AC>>) {
     const route = typeof routeOrFn === 'function' ? routeOrFn() : routeOrFn;
     const routeSearch = route.useSearch();
@@ -536,6 +554,47 @@ export function ListPage<
         });
     }
 
+    const commonTableProps = {
+        listQuery,
+        deleteMutation,
+        transformVariables,
+        customizeColumns: customizeColumns as any,
+        additionalColumns: additionalColumns as any,
+        defaultColumnOrder: columnOrder as any,
+        defaultVisibility: columnVisibility as any,
+        onSearchTermChange,
+        page: pagination.page,
+        itemsPerPage: pagination.itemsPerPage,
+        sorting,
+        columnFilters,
+        onPageChange: (table: Table<any>, page: number, perPage: number) => {
+            persistListStateToUrl(table, { page, perPage });
+            if (pageId) {
+                setTableSettings(pageId, 'pageSize', perPage);
+            }
+        },
+        onSortChange: (table: Table<any>, sorting: SortingState) => {
+            persistListStateToUrl(table, { sort: sorting });
+        },
+        onFilterChange: (table: Table<any>, filters: ColumnFiltersState) => {
+            persistListStateToUrl(table, { filters });
+            if (pageId) {
+                setTableSettings(pageId, 'columnFilters', filters);
+            }
+        },
+        onColumnVisibilityChange: (table: Table<any>, columnVisibility: any) => {
+            if (pageId) {
+                setTableSettings(pageId, 'columnVisibility', columnVisibility);
+            }
+        },
+        facetedFilters,
+        rowActions,
+        bulkActions,
+        setTableOptions,
+        transformData,
+        registerRefresher,
+    };
+
     return (
         <Page pageId={pageId}>
             <PageTitle>{title}</PageTitle>
@@ -543,44 +602,9 @@ export function ListPage<
             <PageLayout>
                 <FullWidthPageBlock blockId="list-table">
                     <PaginatedListDataTable
-                        listQuery={listQuery}
-                        deleteMutation={deleteMutation}
-                        transformVariables={transformVariables}
-                        customizeColumns={customizeColumns as any}
-                        additionalColumns={additionalColumns as any}
-                        defaultColumnOrder={columnOrder as any}
-                        defaultVisibility={columnVisibility as any}
-                        onSearchTermChange={onSearchTermChange}
-                        page={pagination.page}
-                        itemsPerPage={pagination.itemsPerPage}
-                        sorting={sorting}
-                        columnFilters={columnFilters}
-                        onPageChange={(table, page, perPage) => {
-                            persistListStateToUrl(table, { page, perPage });
-                            if (pageId) {
-                                setTableSettings(pageId, 'pageSize', perPage);
-                            }
-                        }}
-                        onSortChange={(table, sorting) => {
-                            persistListStateToUrl(table, { sort: sorting });
-                        }}
-                        onFilterChange={(table, filters) => {
-                            persistListStateToUrl(table, { filters });
-                            if (pageId) {
-                                setTableSettings(pageId, 'columnFilters', filters);
-                            }
-                        }}
-                        onColumnVisibilityChange={(table, columnVisibility) => {
-                            if (pageId) {
-                                setTableSettings(pageId, 'columnVisibility', columnVisibility);
-                            }
-                        }}
-                        facetedFilters={facetedFilters}
-                        rowActions={rowActions}
-                        bulkActions={bulkActions}
-                        setTableOptions={setTableOptions}
-                        transformData={transformData}
-                        registerRefresher={registerRefresher}
+                        {...commonTableProps}
+                        onReorder={onReorder}
+                        disableDragAndDrop={disableDragAndDrop}
                     />
                 </FullWidthPageBlock>
             </PageLayout>

+ 86 - 0
packages/dashboard/src/lib/hooks/use-drag-and-drop.ts

@@ -0,0 +1,86 @@
+import { DragEndEvent, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
+import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+interface UseDragAndDropOptions<TData> {
+    data: TData[];
+    onReorder?: (oldIndex: number, newIndex: number, item: TData, allItems?: TData[]) => void | Promise<void>;
+    onError?: (error: Error) => void;
+    disabled?: boolean;
+}
+
+/**
+ * @description
+ * Provides the sensors and state management for drag and drop functionality.
+ *
+ *
+ * @docsCategory hooks
+ * @docsPage useDragAndDrop
+ * @docsWeight 0
+ * @since 3.3.0
+ */
+export function useDragAndDrop<TData = any>(options: UseDragAndDropOptions<TData>) {
+    const sensors = useSensors(
+        useSensor(PointerSensor),
+        useSensor(KeyboardSensor, {
+            coordinateGetter: sortableKeyboardCoordinates,
+        }),
+    );
+
+    const { data, onReorder, disabled = false } = options;
+
+    const [localData, setLocalData] = useState<TData[]>(data);
+    const [isReordering, setIsReordering] = useState(false);
+
+    // Update local data when data prop changes (but not during reordering)
+    useEffect(() => {
+        if (!isReordering) {
+            setLocalData(data);
+        }
+    }, [data, isReordering]);
+
+    const handleDragEnd = useCallback(
+        async (event: DragEndEvent) => {
+            const { active, over } = event;
+
+            if (!over || active.id === over.id || !onReorder || disabled) {
+                return;
+            }
+
+            const oldIndex = localData.findIndex(item => (item as { id: string }).id === active.id);
+            const newIndex = localData.findIndex(item => (item as { id: string }).id === over.id);
+
+            if (oldIndex === -1 || newIndex === -1) {
+                return;
+            }
+
+            // Optimistically update the UI
+            const originalState = [...localData];
+            const newData = arrayMove(localData, oldIndex, newIndex);
+            setLocalData(newData);
+            setIsReordering(true);
+
+            try {
+                // Call the user's onReorder callback with all items for context
+                await onReorder(oldIndex, newIndex, localData[oldIndex], localData);
+            } catch (error) {
+                // Revert on error
+                setLocalData(originalState);
+                options.onError?.(error as Error);
+            } finally {
+                setIsReordering(false);
+            }
+        },
+        [localData, onReorder, disabled],
+    );
+
+    const itemIds = useMemo(() => localData.map(item => (item as { id: string }).id), [localData]);
+
+    return {
+        sensors,
+        localData,
+        handleDragEnd,
+        itemIds,
+        isReordering,
+    };
+}

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
packages/dev-server/graphql/graphql-env.d.ts


Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor