Browse Source

fix(dashboard): Improve data table loading UX

Michael Bromley 7 months ago
parent
commit
2227598259

+ 1 - 1
packages/dashboard/src/app/styles.css

@@ -90,7 +90,7 @@
     }
 
     .animate-rotate {
-        animation: rotate 0.5s linear;
+        animation: rotate 0.5s linear infinite;
     }
 }
 

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

@@ -4,6 +4,7 @@ import { DataTablePagination } from '@/components/data-table/data-table-paginati
 import { DataTableViewOptions } from '@/components/data-table/data-table-view-options.js';
 import { RefreshButton } from '@/components/data-table/refresh-button.js';
 import { Input } from '@/components/ui/input.js';
+import { Skeleton } from '@/components/ui/skeleton.js';
 import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table.js';
 import { BulkAction } from '@/framework/data-table/data-table-types.js';
 import { useChannel } from '@/hooks/use-channel.js';
@@ -38,6 +39,7 @@ interface DataTableProps<TData> {
     columns: ColumnDef<TData, any>[];
     data: TData[];
     totalItems: number;
+    isLoading?: boolean;
     page?: number;
     itemsPerPage?: number;
     sorting?: SortingState;
@@ -63,6 +65,7 @@ export function DataTable<TData>({
     columns,
     data,
     totalItems,
+    isLoading,
     page,
     itemsPerPage,
     sorting: sortingInitialState,
@@ -149,6 +152,7 @@ export function DataTable<TData>({
         onColumnVisibilityChange?.(table, columnVisibility);
     }, [columnVisibility]);
 
+    const visibleColumnCount = Object.values(columnVisibility).filter(Boolean).length;
     return (
         <>
             <div className="flex justify-between items-start">
@@ -200,7 +204,7 @@ export function DataTable<TData>({
                 </div>
                 <div className="flex items-center justify-start gap-2">
                     {!disableViewOptions && <DataTableViewOptions table={table} />}
-                    {onRefresh && <RefreshButton onRefresh={onRefresh} />}
+                    {onRefresh && <RefreshButton onRefresh={onRefresh} isLoading={isLoading ?? false} />}
                 </div>
             </div>
             <DataTableBulkActions bulkActions={bulkActions ?? []} table={table} />
@@ -225,18 +229,38 @@ export function DataTable<TData>({
                         ))}
                     </TableHeader>
                     <TableBody>
-                        {table.getRowModel().rows?.length ? (
+                        {isLoading && !data?.length ? (
+                            Array.from({ length: pagination.pageSize }).map((_, index) => (
+                                <TableRow
+                                    key={`skeleton-${index}`}
+                                    className="animate-in fade-in duration-100"
+                                >
+                                    {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 ? (
                             table.getRowModel().rows.map(row => (
-                                <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
+                                <TableRow
+                                    key={row.id}
+                                    data-state={row.getIsSelected() && 'selected'}
+                                    className="animate-in fade-in duration-100"
+                                >
                                     {row.getVisibleCells().map(cell => (
-                                        <TableCell key={cell.id}>
+                                        <TableCell key={cell.id} className="h-12">
                                             {flexRender(cell.column.columnDef.cell, cell.getContext())}
                                         </TableCell>
                                     ))}
                                 </TableRow>
                             ))
                         ) : (
-                            <TableRow>
+                            <TableRow className="animate-in fade-in duration-100">
                                 <TableCell colSpan={columns.length} className="h-24 text-center">
                                     No results.
                                 </TableCell>

+ 26 - 14
packages/dashboard/src/lib/components/data-table/refresh-button.tsx

@@ -1,25 +1,37 @@
-import React, { useState } from 'react';
 import { Button } from '@/components/ui/button.js';
 import { RefreshCw } from 'lucide-react';
+import { useEffect, useState } from 'react';
 
-export function RefreshButton({ onRefresh }: { onRefresh: () => void }) {
-    const [isRotating, setIsRotating] = useState(false);
+function useDelayedLoading(isLoading: boolean, delayMs: number = 100) {
+    const [delayedLoading, setDelayedLoading] = useState(isLoading);
 
-    const handleClick = () => {
-        if (!isRotating) {
-            setIsRotating(true);
-            onRefresh();
+    useEffect(() => {
+        if (isLoading) {
+            // When loading starts, wait for the delay before showing loading state
+            const timer = setTimeout(() => {
+                setDelayedLoading(true);
+            }, delayMs);
+
+            return () => clearTimeout(timer);
+        } else {
+            // When loading stops, immediately hide loading state
+            setDelayedLoading(false);
         }
+    }, [isLoading, delayMs]);
+
+    return delayedLoading;
+}
+
+export function RefreshButton({ onRefresh, isLoading }: { onRefresh: () => void; isLoading: boolean }) {
+    const delayedLoading = useDelayedLoading(isLoading, 100);
+
+    const handleClick = () => {
+        onRefresh();
     };
 
     return (
-        <Button
-            variant="ghost"
-            size="sm"
-            onClick={handleClick}
-        >
-            <RefreshCw onAnimationEnd={() => setIsRotating(false)}
-                       className={isRotating ? 'animate-rotate' : ''} />
+        <Button variant="ghost" size="sm" onClick={handleClick} disabled={delayedLoading}>
+            <RefreshCw className={delayedLoading ? 'animate-rotate' : ''} />
         </Button>
     );
 }

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

@@ -7,7 +7,7 @@ import {
 } from '@/framework/document-introspection/get-document-structure.js';
 import { useListQueryFields } from '@/framework/document-introspection/hooks.js';
 import { api } from '@/graphql/api.js';
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { useDebounce } from '@uidotdev/usehooks';
 
 import {
@@ -315,7 +315,7 @@ export function PaginatedListDataTable<
 
     registerRefresher?.(refetchPaginatedList);
 
-    const { data } = useQuery({
+    const { data, isFetching } = useQuery({
         queryFn: () => {
             const searchFilter = onSearchTermChange ? onSearchTermChange(debouncedSearchTerm) : {};
             const mergedFilter = { ...filter, ...searchFilter };
@@ -332,6 +332,7 @@ export function PaginatedListDataTable<
             return api.query(listQuery, transformedVariables);
         },
         queryKey,
+        placeholderData: keepPreviousData,
     });
 
     const fields = useListQueryFields(listQuery);
@@ -484,6 +485,7 @@ export function PaginatedListDataTable<
             <DataTable
                 columns={columns}
                 data={transformedData}
+                isLoading={isFetching}
                 page={page}
                 itemsPerPage={itemsPerPage}
                 sorting={sorting}

+ 30 - 1
packages/dashboard/src/lib/components/shared/vendure-image.tsx

@@ -43,7 +43,11 @@ export function VendureImage({
     ...imgProps
 }: VendureImageProps) {
     if (!asset) {
-        return fallback ? <>{fallback}</> : <PlaceholderImage preset={preset} width={width} height={height} className={className} />;
+        return fallback ? (
+            <>{fallback}</>
+        ) : (
+            <PlaceholderImage preset={preset} width={width} height={height} className={className} />
+        );
     }
 
     // Build the URL with query parameters
@@ -75,11 +79,15 @@ export function VendureImage({
         url.searchParams.set('fpy', asset.focalPoint.y.toString());
     }
 
+    const minDimensions = getMinDimensions(preset, width, height);
+
     return (
         <img
             src={url.toString()}
             alt={alt || asset.name || ''}
             className={cn(className, 'rounded-sm')}
+            width={minDimensions.width}
+            height={minDimensions.height}
             style={style}
             loading="lazy"
             ref={ref}
@@ -88,6 +96,27 @@ export function VendureImage({
     );
 }
 
+function getMinDimensions(preset?: ImagePreset, width?: number, height?: number) {
+    if (preset) {
+        switch (preset) {
+            case 'tiny':
+                return { width: 50, height: 50 };
+            case 'thumb':
+                return { width: 150, height: 150 };
+            case 'small':
+                return { width: 300, height: 300 };
+            case 'medium':
+                return { width: 500, height: 500 };
+        }
+    }
+
+    if (width && height) {
+        return { width, height };
+    }
+
+    return { width: 100, height: 100 };
+}
+
 export function PlaceholderImage({
     width = 100,
     height = 100,