Просмотр исходного кода

feat(dashboard): Add pagination for sub-collections in collection list (#4154)

Will Nahmens 2 дней назад
Родитель
Сommit
2c837e040b

+ 5 - 1
packages/core/src/service/services/collection.service.ts

@@ -377,7 +377,11 @@ export class CollectionService implements OnModuleInit {
                 ctx,
             );
         }
-        return [pickProps(rootCollection), ...ancestors.map(pickProps).reverse(), pickProps(collection)];
+        return [
+            pickProps(rootCollection),
+            ...ancestors.map(a => pickProps(a)).reverse(),
+            pickProps(collection),
+        ];
     }
 
     /**

+ 166 - 45
packages/dashboard/src/app/routes/_authenticated/_collections/collections.tsx

@@ -41,49 +41,124 @@ export const Route = createFileRoute('/_authenticated/_collections/collections')
 
 type Collection = ResultOf<typeof collectionListDocument>['collections']['items'][number];
 
+const CHILDREN_PAGE_SIZE = 20;
+
+type LoadMoreRow = {
+    _isLoadMore: true;
+    _parentId: string;
+    _totalItems: number;
+    _loadedItems: number;
+    id: string;
+    breadcrumbs: { id: string; name: string; slug: string }[];
+};
+
+type CollectionOrLoadMore = Collection | LoadMoreRow;
+
+function isLoadMoreRow(row: CollectionOrLoadMore): row is LoadMoreRow {
+    return '_isLoadMore' in row && row._isLoadMore === true;
+}
+
 function CollectionListPage() {
     const { t } = useLingui();
     const queryClient = useQueryClient();
     const [expanded, setExpanded] = useState<ExpandedState>({});
     const [searchTerm, setSearchTerm] = useState<string>('');
+    const [accumulatedChildren, setAccumulatedChildren] = useState<
+        Record<string, { items: Collection[]; totalItems: number }>
+    >({});
+    const [nextPageToFetch, setNextPageToFetch] = useState<Record<string, number>>({});
 
-    const childrenQueries = useQueries({
-        queries: Object.entries(expanded).map(([collectionId, isExpanded]) => {
-            return {
-                queryKey: ['childCollections', collectionId],
-                queryFn: () =>
-                    api.query(collectionListDocument, {
-                        options: {
-                            filter: {
-                                parentId: { eq: collectionId },
+    useQueries({
+        queries: expanded === true ? [] : Object.entries(expanded)
+            .filter(([collectionId]) => !accumulatedChildren[collectionId])
+            .map(([collectionId]) => {
+                return {
+                    queryKey: ['childCollections', collectionId, 'page', 0],
+                    queryFn: async () => {
+                        const result = await api.query(collectionListDocument, {
+                            options: {
+                                filter: {
+                                    parentId: { eq: collectionId },
+                                },
+                                take: CHILDREN_PAGE_SIZE,
+                                skip: 0,
+                            },
+                        });
+                        setAccumulatedChildren(prev => ({
+                            ...prev,
+                            [collectionId]: {
+                                items: result.collections.items,
+                                totalItems: result.collections.totalItems,
                             },
-                        },
-                    }),
-                staleTime: 1000 * 60 * 5,
-            } satisfies FetchQueryOptions;
-        }),
+                        }));
+                        return result;
+                    },
+                    staleTime: 1000 * 60 * 5,
+                } satisfies FetchQueryOptions;
+            }),
     });
 
-    const childCollectionsByParentId = childrenQueries.reduce(
-        (acc, query, index) => {
-            const collectionId = Object.keys(expanded)[index];
-            if (query.data) {
-                acc[collectionId] = query.data.collections.items;
-            }
-            return acc;
-        },
-        {} as Record<string, any[]>,
-    );
+    useQueries({
+        queries: Object.entries(nextPageToFetch)
+            .filter(([_, page]) => page > 0)
+            .map(([collectionId, page]) => {
+                return {
+                    queryKey: ['childCollections', collectionId, 'page', page],
+                    queryFn: async () => {
+                        const result = await api.query(collectionListDocument, {
+                            options: {
+                                filter: {
+                                    parentId: { eq: collectionId },
+                                },
+                                take: CHILDREN_PAGE_SIZE,
+                                skip: page * CHILDREN_PAGE_SIZE,
+                            },
+                        });
+                        setAccumulatedChildren(prev => {
+                            const existing = prev[collectionId];
+                            if (!existing) return prev;
+                            return {
+                                ...prev,
+                                [collectionId]: {
+                                    items: [...existing.items, ...result.collections.items],
+                                    totalItems: result.collections.totalItems,
+                                },
+                            };
+                        });
+                        setNextPageToFetch(prev => {
+                            const { [collectionId]: _, ...rest } = prev;
+                            return rest;
+                        });
+                        return result;
+                    },
+                    staleTime: 1000 * 60 * 5,
+                } satisfies FetchQueryOptions;
+            }),
+    });
 
-    const addSubCollections = (data: Collection[]) => {
-        const allRows = [] as Collection[];
+    const addSubCollections = (data: Collection[]): CollectionOrLoadMore[] => {
+        const allRows: CollectionOrLoadMore[] = [];
         const addSubRows = (row: Collection) => {
-            const subRows = childCollectionsByParentId[row.id] || [];
-            if (subRows.length) {
-                for (const subRow of subRows) {
+            const isExpanded = expanded === true || (typeof expanded === 'object' && expanded[row.id]);
+            if (!isExpanded) {
+                return;
+            }
+            const childData = accumulatedChildren[row.id];
+            if (childData?.items.length) {
+                for (const subRow of childData.items) {
                     allRows.push(subRow);
                     addSubRows(subRow);
                 }
+                if (childData.totalItems > childData.items.length) {
+                    allRows.push({
+                        _isLoadMore: true,
+                        _parentId: row.id,
+                        _totalItems: childData.totalItems,
+                        _loadedItems: childData.items.length,
+                        id: `load-more-${row.id}`,
+                        breadcrumbs: [...(row.breadcrumbs || []), { id: row.id, name: row.name, slug: row.slug }],
+                    });
+                }
             }
         };
         data.forEach(row => {
@@ -93,37 +168,56 @@ function CollectionListPage() {
         return allRows;
     };
 
+    const handleLoadMoreChildren = (parentId: string) => {
+        const currentItems = accumulatedChildren[parentId]?.items.length ?? 0;
+        const nextPage = Math.floor(currentItems / CHILDREN_PAGE_SIZE);
+        setNextPageToFetch(prev => ({
+            ...prev,
+            [parentId]: nextPage,
+        }));
+    };
+
     const handleReorder = async (oldIndex: number, newIndex: number, item: Collection, allItems?: Collection[]) => {
+        if (isLoadMoreRow(item as CollectionOrLoadMore)) {
+            return;
+        }
         try {
-            const items = allItems || [];
+            const rawItems = (allItems || []) as CollectionOrLoadMore[];
+
+            // Filter out LoadMoreRows - they shouldn't affect position calculations
+            const items = rawItems.filter((i): i is Collection => !isLoadMoreRow(i));
+
+            // Recalculate indices in the filtered array
+            const adjustedOldIndex = items.findIndex(i => i.id === item.id);
+            const targetItem = rawItems[newIndex];
+            const adjustedNewIndex = isLoadMoreRow(targetItem)
+                ? items.findIndex(i => i.id === targetItem._parentId)
+                : items.findIndex(i => i.id === (targetItem as Collection).id);
+
             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,
+                oldIndex: adjustedOldIndex,
+                newIndex: adjustedNewIndex,
                 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 })
+                ? calculateSiblingIndex({ item, oldIndex: adjustedOldIndex, newIndex: adjustedNewIndex, items, parentId: sourceParentId })
                 : initialIndex;
 
-            // Perform the move
             await api.mutate(moveCollectionDocument, {
                 input: {
                     collectionId: item.id,
@@ -132,7 +226,15 @@ function CollectionListPage() {
                 },
             });
 
-            // Invalidate queries and show success message
+            setAccumulatedChildren(prev => {
+                const newState = { ...prev };
+                delete newState[sourceParentId];
+                if (targetParentId !== sourceParentId) {
+                    delete newState[targetParentId];
+                }
+                return newState;
+            });
+
             const queriesToInvalidate = [
                 queryClient.invalidateQueries({ queryKey: ['childCollections', sourceParentId] }),
                 queryClient.invalidateQueries({ queryKey: ['PaginatedListDataTable'] }),
@@ -178,11 +280,12 @@ function CollectionListPage() {
                         dependencies: ['children', 'breadcrumbs'],
                     },
                     cell: ({ row }) => {
+                        const original = row.original as Collection;
                         const isExpanded = row.getIsExpanded();
-                        const hasChildren = !!row.original.children?.length;
+                        const hasChildren = !!original.children?.length;
                         return (
                             <div
-                                style={{ marginLeft: (row.original.breadcrumbs?.length - 2) * 20 + 'px' }}
+                                style={{ marginLeft: (original.breadcrumbs?.length - 2) * 20 + 'px' }}
                                 className="flex gap-2 items-center"
                             >
                                 <Button
@@ -194,7 +297,7 @@ function CollectionListPage() {
                                 >
                                     {isExpanded ? <FolderOpen /> : <Folder />}
                                 </Button>
-                                <DetailPageButton id={row.original.id} label={row.original.name} />
+                                <DetailPageButton id={original.id} label={original.name} />
                             </div>
                         );
                     },
@@ -270,12 +373,30 @@ function CollectionListPage() {
                 options.onExpandedChange = setExpanded;
                 options.getExpandedRowModel = getExpandedRowModel();
                 options.getRowCanExpand = () => true;
-                options.getRowId = row => {
-                    return row.id;
-                };
+                options.getRowId = row => row.id;
+                options.enableRowSelection = row => !isLoadMoreRow(row.original);
                 options.meta = {
                     ...options.meta,
                     resetExpanded: () => setExpanded({}),
+                    isUtilityRow: (row: { original: CollectionOrLoadMore }) => isLoadMoreRow(row.original),
+                    renderUtilityRow: (row: { original: CollectionOrLoadMore }) => {
+                        const original = row.original as LoadMoreRow;
+                        const remaining = original._totalItems - original._loadedItems;
+                        return (
+                            <div
+                                style={{ paddingLeft: (original.breadcrumbs?.length - 1) * 20 + 'px' }}
+                                className="flex justify-center py-2"
+                            >
+                                <Button
+                                    size="sm"
+                                    variant="outline"
+                                    onClick={() => handleLoadMoreChildren(original._parentId)}
+                                >
+                                    <Trans>Load {Math.min(remaining, CHILDREN_PAGE_SIZE)} more ({remaining} remaining)</Trans>
+                                </Button>
+                            </div>
+                        );
+                    },
                 };
                 return options;
             }}
@@ -319,7 +440,7 @@ function CollectionListPage() {
                 },
             ]}
             onReorder={handleReorder}
-            disableDragAndDrop={!!searchTerm} // Disable dragging while searching
+            disableDragAndDrop={!!searchTerm}
         >
             <PageActionBarRight>
                 <PermissionGuard requires={['CreateCollection', 'CreateCatalog']}>

+ 34 - 12
packages/dashboard/src/lib/components/data-table/data-table-utils.ts

@@ -153,6 +153,35 @@ function isDroppingIntoExpandedPreviousWhenDraggingUp<T extends HierarchicalItem
     return !isDraggingDown && previousItem !== null && isPreviousExpanded;
 }
 
+/**
+ * Checks if an item is in an expanded state
+ */
+function isItemExpanded(itemId: string | undefined, expanded: ExpandedState): boolean {
+    if (!itemId) return false;
+    return expanded === true || (typeof expanded === 'object' && !!expanded[itemId]);
+}
+
+/**
+ * Handles dropping into expanded items (first child position)
+ */
+function handleExpandedItemDrop<T extends HierarchicalItem>(context: DragContext<T>): TargetPosition | null {
+    const { targetItem, previousItem } = context;
+
+    if (isDroppingIntoExpandedTarget(context) && targetItem) {
+        return createFirstChildPosition(targetItem.id);
+    }
+
+    if (previousItem && isDroppingIntoExpandedPreviousChildren(context)) {
+        return createFirstChildPosition(previousItem.id);
+    }
+
+    if (previousItem && isDroppingIntoExpandedPreviousWhenDraggingUp(context)) {
+        return createFirstChildPosition(previousItem.id);
+    }
+
+    return null;
+}
+
 /**
  * Creates a position for dropping into an expanded item as first child
  */
@@ -223,23 +252,16 @@ export function calculateDragTargetPosition<T extends HierarchicalItem>(params:
         targetItem,
         previousItem,
         isDraggingDown: oldIndex < newIndex,
-        isTargetExpanded: targetItem ? !!expanded[targetItem.id as keyof ExpandedState] : false,
-        isPreviousExpanded: previousItem ? !!expanded[previousItem.id as keyof ExpandedState] : false,
+        isTargetExpanded: isItemExpanded(targetItem?.id, expanded),
+        isPreviousExpanded: isItemExpanded(previousItem?.id, expanded),
         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);
+    const expandedDropPosition = handleExpandedItemDrop(context);
+    if (expandedDropPosition) {
+        return expandedDropPosition;
     }
 
     // Handle cross-parent drag operations

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

@@ -54,12 +54,17 @@ import { toast } from 'sonner';
 interface DraggableRowProps<TData> {
     row: Row<TData>;
     isDragDisabled: boolean;
+    getRowCanDrag?: (row: Row<TData>) => boolean;
 }
 
-function DraggableRow<TData>({ row, isDragDisabled }: Readonly<DraggableRowProps<TData>>) {
+function DraggableRow<TData>({ row, isDragDisabled, getRowCanDrag }: Readonly<DraggableRowProps<TData>>) {
+    // Check if this specific row can be dragged
+    const rowCanDrag = getRowCanDrag ? getRowCanDrag(row) : true;
+    const isRowDragDisabled = isDragDisabled || !rowCanDrag;
+
     const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
         id: row.id,
-        disabled: isDragDisabled,
+        disabled: isRowDragDisabled,
     });
 
     const style = {
@@ -77,13 +82,15 @@ function DraggableRow<TData>({ row, isDragDisabled }: Readonly<DraggableRowProps
         >
             {!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>
+                    {rowCanDrag ? (
+                        <div
+                            {...attributes}
+                            {...listeners}
+                            className="cursor-move text-muted-foreground hover:text-foreground transition-colors"
+                        >
+                            <GripVertical className="h-4 w-4" />
+                        </div>
+                    ) : null}
                 </TableCell>
             )}
             {row.getVisibleCells().filter(cell => cell.column.id !== '__drag_handle__').map(cell => (
@@ -463,31 +470,62 @@ export function DataTable<TData>({
                                         (() => {
                                             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>
-                                            ));
+                                            const tableMeta = table.options.meta as any;
+                                            const getRowCanDrag = tableMeta?.getRowCanDrag;
+                                            const renderUtilityRow = tableMeta?.renderUtilityRow;
+                                            const isUtilityRow = tableMeta?.isUtilityRow;
+                                            const totalColumns = columnsWithOptionalDragHandle.length;
+
+                                            const renderRow = (row: Row<TData>) => {
+                                                // Check if this is a utility row that needs custom rendering
+                                                if (isUtilityRow?.(row) && renderUtilityRow) {
+                                                    return (
+                                                        <TableRow
+                                                            key={row.id}
+                                                            className="animate-in fade-in duration-100"
+                                                        >
+                                                            <TableCell
+                                                                colSpan={totalColumns}
+                                                                className="h-12"
+                                                            >
+                                                                {renderUtilityRow(row)}
+                                                            </TableCell>
+                                                        </TableRow>
+                                                    );
+                                                }
+
+                                                if (isDraggableEnabled) {
+                                                    return (
+                                                        <DraggableRow
+                                                            key={`${row.id}-${componentId}`}
+                                                            row={row}
+                                                            isDragDisabled={isDragDisabled}
+                                                            getRowCanDrag={getRowCanDrag}
+                                                        />
+                                                    );
+                                                }
+
+                                                return (
+                                                    <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>
+                                                );
+                                            };
+
+                                            return rows.map(renderRow);
                                         })()
                                     ) : (
                                         <TableRow className="animate-in fade-in duration-100">
                                             <TableCell
-                                                colSpan={columnsWithOptionalDragHandle.length + (isDragDisabled ? 0 : 1)}
+                                                colSpan={columnsWithOptionalDragHandle.length}
                                                 className="h-24 text-center"
                                             >
                                                 <Trans>No results</Trans>