Kaynağa Gözat

feat(dashboard): Dashboard bulk actions (#3615)

Michael Bromley 6 ay önce
ebeveyn
işleme
39edc18a5b
30 değiştirilmiş dosya ile 1110 ekleme ve 126 silme
  1. 98 0
      packages/dashboard/src/app/routes/_authenticated/_products/components/assign-facet-values-dialog.tsx
  2. 126 0
      packages/dashboard/src/app/routes/_authenticated/_products/components/assign-to-channel-dialog.tsx
  3. 268 0
      packages/dashboard/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx
  4. 64 0
      packages/dashboard/src/app/routes/_authenticated/_products/products.graphql.ts
  5. 31 2
      packages/dashboard/src/app/routes/_authenticated/_products/products.tsx
  6. 3 1
      packages/dashboard/src/app/routes/_authenticated/_products/products_.$id.tsx
  7. 101 0
      packages/dashboard/src/lib/components/data-table/data-table-bulk-action-item.tsx
  8. 89 0
      packages/dashboard/src/lib/components/data-table/data-table-bulk-actions.tsx
  9. 16 8
      packages/dashboard/src/lib/components/data-table/data-table-filter-badge.tsx
  10. 4 4
      packages/dashboard/src/lib/components/data-table/data-table-filter-dialog.tsx
  11. 2 2
      packages/dashboard/src/lib/components/data-table/data-table-pagination.tsx
  12. 50 31
      packages/dashboard/src/lib/components/data-table/data-table.tsx
  13. 3 3
      packages/dashboard/src/lib/components/data-table/human-readable-operator.tsx
  14. 0 0
      packages/dashboard/src/lib/components/data-table/types.ts
  15. 1 5
      packages/dashboard/src/lib/components/shared/assigned-facet-values.tsx
  16. 47 11
      packages/dashboard/src/lib/components/shared/paginated-list-data-table.tsx
  17. 21 0
      packages/dashboard/src/lib/framework/data-table/data-table-extensions.ts
  18. 25 0
      packages/dashboard/src/lib/framework/data-table/data-table-types.ts
  19. 11 0
      packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts
  20. 35 0
      packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts
  21. 2 5
      packages/dashboard/src/lib/framework/form-engine/use-generated-form.tsx
  22. 6 0
      packages/dashboard/src/lib/framework/layout-engine/page-block-provider.tsx
  23. 43 33
      packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx
  24. 6 8
      packages/dashboard/src/lib/framework/page/list-page.tsx
  25. 4 2
      packages/dashboard/src/lib/framework/registry/registry-types.ts
  26. 10 0
      packages/dashboard/src/lib/hooks/use-page-block.tsx
  27. 8 1
      packages/dashboard/src/lib/index.ts
  28. 13 9
      packages/dashboard/vite/tests/barrel-exports.spec.ts
  29. 1 0
      packages/dashboard/vite/vite-plugin-config.ts
  30. 22 1
      packages/dev-server/test-plugins/reviews/dashboard/index.tsx

+ 98 - 0
packages/dashboard/src/app/routes/_authenticated/_products/components/assign-facet-values-dialog.tsx

@@ -0,0 +1,98 @@
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { useMutation } from '@tanstack/react-query';
+
+import { Button } from '@/components/ui/button.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogDescription,
+    DialogFooter,
+    DialogHeader,
+    DialogTitle,
+} from '@/components/ui/dialog.js';
+import { FacetValueSelector, FacetValue } from '@/components/shared/facet-value-selector.js';
+import { api } from '@/graphql/api.js';
+import { ResultOf } from '@/graphql/graphql.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+
+import { updateProductsDocument } from '../products.graphql.js';
+
+interface AssignFacetValuesDialogProps {
+    open: boolean;
+    onOpenChange: (open: boolean) => void;
+    productIds: string[];
+    onSuccess?: () => void;
+}
+
+export function AssignFacetValuesDialog({ open, onOpenChange, productIds, onSuccess }: AssignFacetValuesDialogProps) {
+    const { i18n } = useLingui();
+    const [selectedFacetValueIds, setSelectedFacetValueIds] = useState<string[]>([]);
+
+    const { mutate, isPending } = useMutation({
+        mutationFn: api.mutate(updateProductsDocument),
+        onSuccess: (result: ResultOf<typeof updateProductsDocument>) => {
+            toast.success(i18n.t(`Successfully updated facet values for ${productIds.length} products`));
+            onSuccess?.();
+            onOpenChange(false);
+        },
+        onError: () => {
+            toast.error(`Failed to update facet values for ${productIds.length} products`);
+        },
+    });
+
+    const handleAssign = () => {
+        if (selectedFacetValueIds.length === 0) {
+            toast.error('Please select at least one facet value');
+            return;
+        }
+
+        mutate({
+            input: productIds.map(productId => ({
+                id: productId,
+                facetValueIds: selectedFacetValueIds,
+            })),
+        });
+    };
+
+    const handleFacetValueSelect = (facetValue: FacetValue) => {
+        setSelectedFacetValueIds(prev => [...new Set([...prev, facetValue.id])]);
+    };
+
+    return (
+        <Dialog open={open} onOpenChange={onOpenChange}>
+            <DialogContent className="sm:max-w-[500px]">
+                <DialogHeader>
+                    <DialogTitle><Trans>Edit facet values</Trans></DialogTitle>
+                    <DialogDescription>
+                        <Trans>Select facet values to assign to {productIds.length} products</Trans>
+                    </DialogDescription>
+                </DialogHeader>
+                <div className="grid gap-4 py-4">
+                    <div className="grid gap-2">
+                        <label className="text-sm font-medium">
+                            <Trans>Facet values</Trans>
+                        </label>
+                        <FacetValueSelector
+                            onValueSelect={handleFacetValueSelect}
+                            placeholder="Search facet values..."
+                        />
+                    </div>
+                    {selectedFacetValueIds.length > 0 && (
+                        <div className="text-sm text-muted-foreground">
+                            <Trans>{selectedFacetValueIds.length} facet value(s) selected</Trans>
+                        </div>
+                    )}
+                </div>
+                <DialogFooter>
+                    <Button variant="outline" onClick={() => onOpenChange(false)}>
+                        <Trans>Cancel</Trans>
+                    </Button>
+                    <Button onClick={handleAssign} disabled={selectedFacetValueIds.length === 0 || isPending}>
+                        <Trans>Update</Trans>
+                    </Button>
+                </DialogFooter>
+            </DialogContent>
+        </Dialog>
+    );
+} 

+ 126 - 0
packages/dashboard/src/app/routes/_authenticated/_products/components/assign-to-channel-dialog.tsx

@@ -0,0 +1,126 @@
+import { useMutation } from '@tanstack/react-query';
+import { useState } from 'react';
+import { toast } from 'sonner';
+
+import { ChannelCodeLabel } from '@/components/shared/channel-code-label.js';
+import { Button } from '@/components/ui/button.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogDescription,
+    DialogFooter,
+    DialogHeader,
+    DialogTitle,
+} from '@/components/ui/dialog.js';
+import { Input } from '@/components/ui/input.js';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
+import { api } from '@/graphql/api.js';
+import { ResultOf } from '@/graphql/graphql.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+
+import { useChannel } from '@/hooks/use-channel.js';
+import { assignProductsToChannelDocument } from '../products.graphql.js';
+
+interface AssignToChannelDialogProps {
+    open: boolean;
+    onOpenChange: (open: boolean) => void;
+    productIds: string[];
+    onSuccess?: () => void;
+}
+
+export function AssignToChannelDialog({
+    open,
+    onOpenChange,
+    productIds,
+    onSuccess,
+}: AssignToChannelDialogProps) {
+    const { i18n } = useLingui();
+    const [selectedChannelId, setSelectedChannelId] = useState<string>('');
+    const [priceFactor, setPriceFactor] = useState<number>(1);
+    const { channels, selectedChannel } = useChannel();
+
+    // Filter out the currently selected channel from available options
+    const availableChannels = channels.filter(channel => channel.id !== selectedChannel?.id);
+
+    const { mutate, isPending } = useMutation({
+        mutationFn: api.mutate(assignProductsToChannelDocument),
+        onSuccess: (result: ResultOf<typeof assignProductsToChannelDocument>) => {
+            toast.success(i18n.t(`Successfully assigned ${productIds.length} products to channel`));
+            onSuccess?.();
+            onOpenChange(false);
+        },
+        onError: () => {
+            toast.error(`Failed to assign ${productIds.length} products to channel`);
+        },
+    });
+
+    const handleAssign = () => {
+        if (!selectedChannelId) {
+            toast.error('Please select a channel');
+            return;
+        }
+
+        mutate({
+            input: {
+                productIds,
+                channelId: selectedChannelId,
+                priceFactor,
+            },
+        });
+    };
+
+    return (
+        <Dialog open={open} onOpenChange={onOpenChange}>
+            <DialogContent className="sm:max-w-[425px]">
+                <DialogHeader>
+                    <DialogTitle>
+                        <Trans>Assign products to channel</Trans>
+                    </DialogTitle>
+                    <DialogDescription>
+                        <Trans>Select a channel to assign {productIds.length} products to</Trans>
+                    </DialogDescription>
+                </DialogHeader>
+                <div className="grid gap-4 py-4">
+                    <div className="grid gap-2">
+                        <label className="text-sm font-medium">
+                            <Trans>Channel</Trans>
+                        </label>
+                        <Select value={selectedChannelId} onValueChange={setSelectedChannelId}>
+                            <SelectTrigger>
+                                <SelectValue placeholder={i18n.t('Select a channel')} />
+                            </SelectTrigger>
+                            <SelectContent>
+                                {availableChannels.map(channel => (
+                                    <SelectItem key={channel.id} value={channel.id}>
+                                        <ChannelCodeLabel code={channel.code} />
+                                    </SelectItem>
+                                ))}
+                            </SelectContent>
+                        </Select>
+                    </div>
+                    <div className="grid gap-2">
+                        <label className="text-sm font-medium">
+                            <Trans>Price conversion factor</Trans>
+                        </label>
+                        <Input
+                            type="number"
+                            min="0"
+                            max="99999"
+                            step="0.01"
+                            value={priceFactor}
+                            onChange={e => setPriceFactor(parseFloat(e.target.value) || 1)}
+                        />
+                    </div>
+                </div>
+                <DialogFooter>
+                    <Button variant="outline" onClick={() => onOpenChange(false)}>
+                        <Trans>Cancel</Trans>
+                    </Button>
+                    <Button onClick={handleAssign} disabled={!selectedChannelId || isPending}>
+                        <Trans>Assign</Trans>
+                    </Button>
+                </DialogFooter>
+            </DialogContent>
+        </Dialog>
+    );
+}

+ 268 - 0
packages/dashboard/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx

@@ -0,0 +1,268 @@
+import { useMutation } from '@tanstack/react-query';
+import { CopyIcon, LayersIcon, TagIcon, TrashIcon } from 'lucide-react';
+import { useState } from 'react';
+import { toast } from 'sonner';
+
+import { DataTableBulkActionItem } from '@/components/data-table/data-table-bulk-action-item.js';
+import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
+import { api } from '@/graphql/api.js';
+import { ResultOf } from '@/graphql/graphql.js';
+import { useChannel, usePaginatedList } from '@/index.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+
+import { Permission } from '@vendure/common/lib/generated-types';
+import {
+    deleteProductsDocument,
+    duplicateEntityDocument,
+    removeProductsFromChannelDocument,
+} from '../products.graphql.js';
+import { AssignFacetValuesDialog } from './assign-facet-values-dialog.js';
+import { AssignToChannelDialog } from './assign-to-channel-dialog.js';
+
+export const DeleteProductsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    const { refetchPaginatedList } = usePaginatedList();
+    const { i18n } = useLingui();
+    const { mutate } = useMutation({
+        mutationFn: api.mutate(deleteProductsDocument),
+        onSuccess: (result: ResultOf<typeof deleteProductsDocument>) => {
+            let deleted = 0;
+            const errors: string[] = [];
+            for (const item of result.deleteProducts) {
+                if (item.result === 'DELETED') {
+                    deleted++;
+                } else if (item.message) {
+                    errors.push(item.message);
+                }
+            }
+            if (0 < deleted) {
+                toast.success(i18n.t(`Deleted ${deleted} products`));
+            }
+            if (0 < errors.length) {
+                toast.error(i18n.t(`Failed to delete ${errors.length} products`));
+            }
+            refetchPaginatedList();
+            table.resetRowSelection();
+        },
+        onError: () => {
+            toast.error(`Failed to delete ${selection.length} products`);
+        },
+    });
+    return (
+        <DataTableBulkActionItem
+            requiresPermission={[Permission.DeleteCatalog, Permission.DeleteProduct]}
+            onClick={() => mutate({ ids: selection.map(s => s.id) })}
+            label={<Trans>Delete</Trans>}
+            confirmationText={<Trans>Are you sure you want to delete {selection.length} products?</Trans>}
+            icon={TrashIcon}
+            className="text-destructive"
+        />
+    );
+};
+
+export const AssignProductsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    const { refetchPaginatedList } = usePaginatedList();
+    const { channels, selectedChannel } = useChannel();
+    const [dialogOpen, setDialogOpen] = useState(false);
+
+    if (channels.length < 2) {
+        return null;
+    }
+
+    const handleSuccess = () => {
+        refetchPaginatedList();
+        table.resetRowSelection();
+    };
+
+    return (
+        <>
+            <DataTableBulkActionItem
+                requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
+                onClick={() => setDialogOpen(true)}
+                label={<Trans>Assign to channel</Trans>}
+                icon={LayersIcon}
+            />
+            <AssignToChannelDialog
+                open={dialogOpen}
+                onOpenChange={setDialogOpen}
+                productIds={selection.map(s => s.id)}
+                onSuccess={handleSuccess}
+            />
+        </>
+    );
+};
+
+export const RemoveProductsFromChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    const { refetchPaginatedList } = usePaginatedList();
+    const { selectedChannel } = useChannel();
+    const { i18n } = useLingui();
+    const { mutate } = useMutation({
+        mutationFn: api.mutate(removeProductsFromChannelDocument),
+        onSuccess: () => {
+            toast.success(i18n.t(`Successfully removed ${selection.length} products from channel`));
+            refetchPaginatedList();
+            table.resetRowSelection();
+        },
+        onError: error => {
+            toast.error(`Failed to remove ${selection.length} products from channel: ${error.message}`);
+        },
+    });
+
+    if (!selectedChannel) {
+        return null;
+    }
+
+    const handleRemove = () => {
+        mutate({
+            input: {
+                productIds: selection.map(s => s.id),
+                channelId: selectedChannel.id,
+            },
+        });
+    };
+
+    return (
+        <DataTableBulkActionItem
+            requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
+            onClick={handleRemove}
+            label={<Trans>Remove from current channel</Trans>}
+            confirmationText={
+                <Trans>
+                    Are you sure you want to remove {selection.length} products from the current channel?
+                </Trans>
+            }
+            icon={LayersIcon}
+            className="text-warning"
+        />
+    );
+};
+
+export const AssignFacetValuesToProductsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    const { refetchPaginatedList } = usePaginatedList();
+    const [dialogOpen, setDialogOpen] = useState(false);
+
+    const handleSuccess = () => {
+        refetchPaginatedList();
+        table.resetRowSelection();
+    };
+
+    return (
+        <>
+            <DataTableBulkActionItem
+                requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
+                onClick={() => setDialogOpen(true)}
+                label={<Trans>Edit facet values</Trans>}
+                icon={TagIcon}
+            />
+            <AssignFacetValuesDialog
+                open={dialogOpen}
+                onOpenChange={setDialogOpen}
+                productIds={selection.map(s => s.id)}
+                onSuccess={handleSuccess}
+            />
+        </>
+    );
+};
+
+export const DuplicateProductsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    const { refetchPaginatedList } = usePaginatedList();
+    const { i18n } = useLingui();
+    const [isDuplicating, setIsDuplicating] = useState(false);
+    const [progress, setProgress] = useState({ completed: 0, total: 0 });
+
+    const { mutateAsync } = useMutation({
+        mutationFn: api.mutate(duplicateEntityDocument),
+    });
+
+    const handleDuplicate = async () => {
+        if (isDuplicating) return;
+
+        setIsDuplicating(true);
+        setProgress({ completed: 0, total: selection.length });
+
+        const results = {
+            success: 0,
+            failed: 0,
+            errors: [] as string[],
+        };
+
+        try {
+            // Process products sequentially to avoid overwhelming the server
+            for (let i = 0; i < selection.length; i++) {
+                const product = selection[i];
+
+                try {
+                    const result = await mutateAsync({
+                        input: {
+                            entityName: 'Product',
+                            entityId: product.id,
+                            duplicatorInput: {
+                                code: 'product-duplicator',
+                                arguments: [
+                                    {
+                                        name: 'includeVariants',
+                                        value: 'true',
+                                    },
+                                ],
+                            },
+                        },
+                    });
+
+                    if ('newEntityId' in result.duplicateEntity) {
+                        results.success++;
+                    } else {
+                        results.failed++;
+                        const errorMsg =
+                            result.duplicateEntity.message ||
+                            result.duplicateEntity.duplicationError ||
+                            'Unknown error';
+                        results.errors.push(`Product ${product.name || product.id}: ${errorMsg}`);
+                    }
+                } catch (error) {
+                    results.failed++;
+                    results.errors.push(
+                        `Product ${product.name || product.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
+                    );
+                }
+
+                setProgress({ completed: i + 1, total: selection.length });
+            }
+
+            // Show results
+            if (results.success > 0) {
+                toast.success(i18n.t(`Successfully duplicated ${results.success} products`));
+            }
+            if (results.failed > 0) {
+                const errorMessage =
+                    results.errors.length > 3
+                        ? `${results.errors.slice(0, 3).join(', ')}... and ${results.errors.length - 3} more`
+                        : results.errors.join(', ');
+                toast.error(`Failed to duplicate ${results.failed} products: ${errorMessage}`);
+            }
+
+            if (results.success > 0) {
+                refetchPaginatedList();
+                table.resetRowSelection();
+            }
+        } finally {
+            setIsDuplicating(false);
+            setProgress({ completed: 0, total: 0 });
+        }
+    };
+
+    return (
+        <DataTableBulkActionItem
+            requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
+            onClick={handleDuplicate}
+            label={
+                isDuplicating ? (
+                    <Trans>
+                        Duplicating... ({progress.completed}/{progress.total})
+                    </Trans>
+                ) : (
+                    <Trans>Duplicate</Trans>
+                )
+            }
+            icon={CopyIcon}
+        />
+    );
+};

+ 64 - 0
packages/dashboard/src/app/routes/_authenticated/_products/products.graphql.ts

@@ -119,3 +119,67 @@ export const deleteProductDocument = graphql(`
         }
     }
 `);
+
+export const deleteProductsDocument = graphql(`
+    mutation DeleteProducts($ids: [ID!]!) {
+        deleteProducts(ids: $ids) {
+            result
+            message
+        }
+    }
+`);
+
+export const assignProductsToChannelDocument = graphql(`
+    mutation AssignProductsToChannel($input: AssignProductsToChannelInput!) {
+        assignProductsToChannel(input: $input) {
+            id
+            channels {
+                id
+                code
+            }
+        }
+    }
+`);
+
+export const removeProductsFromChannelDocument = graphql(`
+    mutation RemoveProductsFromChannel($input: RemoveProductsFromChannelInput!) {
+        removeProductsFromChannel(input: $input) {
+            id
+            channels {
+                id
+                code
+            }
+        }
+    }
+`);
+
+export const updateProductsDocument = graphql(`
+    mutation UpdateProducts($input: [UpdateProductInput!]!) {
+        updateProducts(input: $input) {
+            id
+            name
+            facetValues {
+                id
+                name
+                code
+            }
+        }
+    }
+`);
+
+export const duplicateEntityDocument = graphql(`
+    mutation DuplicateEntity($input: DuplicateEntityInput!) {
+        duplicateEntity(input: $input) {
+            ... on DuplicateEntitySuccess {
+                newEntityId
+            }
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+            ... on DuplicateEntityError {
+                duplicationError
+            }
+        }
+    }
+`);

+ 31 - 2
packages/dashboard/src/app/routes/_authenticated/_products/products.tsx

@@ -1,11 +1,18 @@
 import { DetailPageButton } from '@/components/shared/detail-page-button.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Button } from '@/components/ui/button.js';
-import { PageActionBar, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
+import { PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
 import { ListPage } from '@/framework/page/list-page.js';
 import { Trans } from '@/lib/trans.js';
 import { createFileRoute, Link } from '@tanstack/react-router';
-import { PlusIcon, TrashIcon } from 'lucide-react';
+import { PlusIcon } from 'lucide-react';
+import {
+    AssignFacetValuesToProductsBulkAction,
+    AssignProductsToChannelBulkAction,
+    DeleteProductsBulkAction,
+    DuplicateProductsBulkAction,
+    RemoveProductsFromChannelBulkAction,
+} from './components/product-bulk-actions.js';
 import { deleteProductDocument, productListDocument } from './products.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_products/products')({
@@ -32,6 +39,28 @@ function ProductListPage() {
                 };
             }}
             route={Route}
+            bulkActions={[
+                {
+                    component: AssignProductsToChannelBulkAction,
+                    order: 100,
+                },
+                {
+                    component: RemoveProductsFromChannelBulkAction,
+                    order: 200,
+                },
+                {
+                    component: AssignFacetValuesToProductsBulkAction,
+                    order: 300,
+                },
+                {
+                    component: DuplicateProductsBulkAction,
+                    order: 400,
+                },
+                {
+                    component: DeleteProductsBulkAction,
+                    order: 500,
+                },
+            ]}
         >
             <PageActionBarRight>
                 <PermissionGuard requires={['CreateProduct', 'CreateCatalog']}>

+ 3 - 1
packages/dashboard/src/app/routes/_authenticated/_products/products_.$id.tsx

@@ -50,9 +50,11 @@ function ProductDetailPage() {
     const navigate = useNavigate();
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { i18n } = useLingui();
-    const refreshRef = useRef<() => void>(() => {});
+    const refreshRef = useRef<() => void>(() => {
+    });
 
     const { form, submitHandler, entity, isPending, refreshEntity, resetForm } = useDetailPage({
+        entityName: 'Product',
         queryDocument: productDetailDocument,
         createDocument: createProductDocument,
         updateDocument: updateProductDocument,

+ 101 - 0
packages/dashboard/src/lib/components/data-table/data-table-bulk-action-item.tsx

@@ -0,0 +1,101 @@
+import { usePermissions } from '@/hooks/use-permissions.js';
+import { Trans } from '@/lib/trans.js';
+import { cn } from '@/lib/utils.js';
+import { LucideIcon } from 'lucide-react';
+import { useState } from 'react';
+import {
+    AlertDialog,
+    AlertDialogAction,
+    AlertDialogCancel,
+    AlertDialogContent,
+    AlertDialogDescription,
+    AlertDialogFooter,
+    AlertDialogHeader,
+    AlertDialogTitle,
+    AlertDialogTrigger,
+} from '../ui/alert-dialog.js';
+import { DropdownMenuItem } from '../ui/dropdown-menu.js';
+
+export interface DataTableBulkActionItemProps {
+    label: React.ReactNode;
+    icon?: LucideIcon;
+    confirmationText?: React.ReactNode;
+    onClick: () => void;
+    className?: string;
+    requiresPermission?: string[];
+}
+
+export function DataTableBulkActionItem({
+    label,
+    icon: Icon,
+    confirmationText,
+    className,
+    onClick,
+    requiresPermission,
+}: DataTableBulkActionItemProps) {
+    const [isOpen, setIsOpen] = useState(false);
+    const { hasPermissions } = usePermissions();
+    const userHasPermission = hasPermissions(requiresPermission ?? []);
+
+    const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
+        e.preventDefault();
+        e.stopPropagation();
+        if (!userHasPermission) {
+            return;
+        }
+        if (confirmationText) {
+            setIsOpen(true);
+        } else {
+            onClick?.();
+        }
+    };
+
+    const handleConfirm = () => {
+        setIsOpen(false);
+        onClick?.();
+    };
+
+    const handleCancel = () => {
+        setIsOpen(false);
+    };
+
+    if (confirmationText) {
+        return (
+            <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
+                <AlertDialogTrigger asChild>
+                    <DropdownMenuItem onClick={handleClick} disabled={!userHasPermission}>
+                        {Icon && <Icon className={cn('mr-1 h-4 w-4', className)} />}
+                        <span className={cn('text-sm', className)}>
+                            <Trans>{label}</Trans>
+                        </span>
+                    </DropdownMenuItem>
+                </AlertDialogTrigger>
+                <AlertDialogContent>
+                    <AlertDialogHeader>
+                        <AlertDialogTitle>
+                            <Trans>Confirm Action</Trans>
+                        </AlertDialogTitle>
+                        <AlertDialogDescription>{confirmationText}</AlertDialogDescription>
+                    </AlertDialogHeader>
+                    <AlertDialogFooter>
+                        <AlertDialogCancel onClick={handleCancel}>
+                            <Trans>Cancel</Trans>
+                        </AlertDialogCancel>
+                        <AlertDialogAction onClick={handleConfirm}>
+                            <Trans>Continue</Trans>
+                        </AlertDialogAction>
+                    </AlertDialogFooter>
+                </AlertDialogContent>
+            </AlertDialog>
+        );
+    }
+
+    return (
+        <DropdownMenuItem onClick={handleClick}>
+            {Icon && <Icon className={cn('mr-1 h-4 w-4', className)} />}
+            <span className={cn('text-sm', className)}>
+                <Trans>{label}</Trans>
+            </span>
+        </DropdownMenuItem>
+    );
+}

+ 89 - 0
packages/dashboard/src/lib/components/data-table/data-table-bulk-actions.tsx

@@ -0,0 +1,89 @@
+'use client';
+
+import { Button } from '@/components/ui/button.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu.js';
+import { getBulkActions } from '@/framework/data-table/data-table-extensions.js';
+import { BulkAction } from '@/framework/data-table/data-table-types.js';
+import { usePageBlock } from '@/hooks/use-page-block.js';
+import { usePage } from '@/hooks/use-page.js';
+import { Trans } from '@/lib/trans.js';
+import { Table } from '@tanstack/react-table';
+import { ChevronDown } from 'lucide-react';
+import { useRef } from 'react';
+
+interface DataTableBulkActionsProps<TData> {
+    table: Table<TData>;
+    bulkActions: BulkAction[];
+}
+
+export function DataTableBulkActions<TData>({ table, bulkActions }: DataTableBulkActionsProps<TData>) {
+    const { pageId } = usePage();
+    const { blockId } = usePageBlock();
+
+    // Cache to store selected items across page changes
+    const selectedItemsCache = useRef<Map<string, TData>>(new Map());
+    const selectedRowIds = Object.keys(table.getState().rowSelection);
+
+    // Get selection from cache instead of trying to get from table
+    const selection = selectedRowIds
+        .map(key => {
+            try {
+                const row = table.getRow(key);
+                if (row) {
+                    selectedItemsCache.current.set(key, row.original);
+                    return row.original;
+                }
+            } catch (error) {
+                // ignore the error, it just means the row is not on the
+                // current page.
+            }
+            if (selectedItemsCache.current.has(key)) {
+                return selectedItemsCache.current.get(key);
+            }
+            return undefined;
+        })
+        .filter((item): item is TData => item !== undefined);
+
+    if (selection.length === 0) {
+        return null;
+    }
+    const extendedBulkActions = pageId ? getBulkActions(pageId, blockId) : [];
+    const allBulkActions = [...extendedBulkActions, ...(bulkActions ?? [])];
+    allBulkActions.sort((a, b) => (a.order ?? 10_000) - (b.order ?? 10_000));
+
+    return (
+        <div className="flex items-center gap-2 px-2 py-1 bg-muted/50 rounded-md border">
+            <span className="text-sm text-muted-foreground">
+                <Trans>{selection.length} selected</Trans>
+            </span>
+            <DropdownMenu>
+                <DropdownMenuTrigger asChild>
+                    <Button variant="outline" size="sm" className="h-8">
+                        <Trans>With selected...</Trans>
+                        <ChevronDown className="ml-2 h-4 w-4" />
+                    </Button>
+                </DropdownMenuTrigger>
+                <DropdownMenuContent align="start">
+                    {allBulkActions.length > 0 ? (
+                        allBulkActions.map((action, index) => (
+                            <action.component
+                                key={`bulk-action-${index}`}
+                                selection={selection}
+                                table={table}
+                            />
+                        ))
+                    ) : (
+                        <DropdownMenuItem className="text-muted-foreground" disabled>
+                            <Trans>No actions available</Trans>
+                        </DropdownMenuItem>
+                    )}
+                </DropdownMenuContent>
+            </DropdownMenu>
+        </div>
+    );
+}

+ 16 - 8
packages/dashboard/src/lib/components/data-table/data-table-filter-badge.tsx

@@ -1,10 +1,8 @@
-import { Filter } from 'lucide-react';
-
-import { CircleX } from 'lucide-react';
-import { Badge } from '../ui/badge.js';
 import { useLocalFormat } from '@/hooks/use-local-format.js';
-import { ColumnDataType } from './data-table-types.js';
-import { HumanReadableOperator } from './human-readable-operator.js';
+import { CircleX, Filter } from 'lucide-react';
+import { Badge } from '../ui/badge.js';
+import { HumanReadableOperator, Operator } from './human-readable-operator.js';
+import { ColumnDataType } from './types.js';
 
 export function DataTableFilterBadge({
     filter,
@@ -22,7 +20,9 @@ export function DataTableFilterBadge({
         <Badge key={filter.id} className="flex gap-1 items-center" variant="secondary">
             <Filter size="12" className="opacity-50" />
             <div>{filter.id}</div>
-            <div className="text-muted-foreground"><HumanReadableOperator operator={operator} mode="short" /></div>
+            <div className="text-muted-foreground">
+                <HumanReadableOperator operator={operator as Operator} mode="short" />
+            </div>
             <FilterValue value={value} dataType={dataType} currencyCode={currencyCode} />
             <button className="cursor-pointer" onClick={() => onRemove(filter)}>
                 <CircleX size="14" />
@@ -31,7 +31,15 @@ export function DataTableFilterBadge({
     );
 }
 
-function FilterValue({ value, dataType, currencyCode }: { value: unknown, dataType: ColumnDataType, currencyCode: string }) {
+function FilterValue({
+    value,
+    dataType,
+    currencyCode,
+}: {
+    value: unknown;
+    dataType: ColumnDataType;
+    currencyCode: string;
+}) {
     const { formatDate, formatCurrency } = useLocalFormat();
     if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
         return Object.entries(value as Record<string, unknown>).map(([key, value]) => (

+ 4 - 4
packages/dashboard/src/lib/components/data-table/data-table-filter-dialog.tsx

@@ -15,7 +15,7 @@ import { DataTableDateTimeFilter } from './filters/data-table-datetime-filter.js
 import { DataTableIdFilter } from './filters/data-table-id-filter.js';
 import { DataTableNumberFilter } from './filters/data-table-number-filter.js';
 import { DataTableStringFilter } from './filters/data-table-string-filter.js';
-import { ColumnDataType } from './data-table-types.js';
+import { ColumnDataType } from './types.js';
 
 export interface DataTableFilterDialogProps {
     column: Column<any>;
@@ -38,7 +38,7 @@ export function DataTableFilterDialog({ column }: DataTableFilterDialogProps) {
             {columnDataType === 'String' ? (
                 <DataTableStringFilter value={filter} onChange={e => setFilter(e)} />
             ) : columnDataType === 'Int' || columnDataType === 'Float' ? (
-                <DataTableNumberFilter value={filter} onChange={e => setFilter(e)} mode='number' />
+                <DataTableNumberFilter value={filter} onChange={e => setFilter(e)} mode="number" />
             ) : columnDataType === 'DateTime' ? (
                 <DataTableDateTimeFilter value={filter} onChange={e => setFilter(e)} />
             ) : columnDataType === 'Boolean' ? (
@@ -46,7 +46,7 @@ export function DataTableFilterDialog({ column }: DataTableFilterDialogProps) {
             ) : columnDataType === 'ID' ? (
                 <DataTableIdFilter value={filter} onChange={e => setFilter(e)} />
             ) : columnDataType === 'Money' ? (
-                <DataTableNumberFilter value={filter} onChange={e => setFilter(e)} mode='money' />
+                <DataTableNumberFilter value={filter} onChange={e => setFilter(e)} mode="money" />
             ) : null}
             <DialogFooter className="sm:justify-end">
                 {columnFilter && (
@@ -58,7 +58,7 @@ export function DataTableFilterDialog({ column }: DataTableFilterDialogProps) {
                     <Button
                         type="button"
                         variant="secondary"
-                            onClick={e => {
+                        onClick={() => {
                             column.setFilterValue(filter);
                             setFilter(undefined);
                         }}

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

@@ -9,11 +9,11 @@ interface DataTablePaginationProps<TData> {
 }
 
 export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
+    const selectedRowCount = Object.keys(table.getState().rowSelection).length;
     return (
         <div className="flex items-center justify-between px-2">
             <div className="flex-1 text-sm text-muted-foreground">
-                {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length}{' '}
-                row(s) selected.
+                {selectedRowCount} of {table.getFilteredRowModel().rows.length} row(s) selected.
             </div>
             <div className="flex items-center space-x-6 lg:space-x-8">
                 <div className="flex items-center space-x-2">

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

@@ -2,8 +2,11 @@
 
 import { DataTablePagination } from '@/components/data-table/data-table-pagination.js';
 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 { 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';
 import {
     ColumnDef,
     ColumnFilter,
@@ -17,13 +20,12 @@ import {
     useReactTable,
     VisibilityState,
 } from '@tanstack/react-table';
-import { TableOptions } from '@tanstack/table-core';
+import { RowSelectionState, TableOptions } from '@tanstack/table-core';
 import React, { Suspense, useEffect } from 'react';
 import { AddFilterMenu } from './add-filter-menu.js';
+import { DataTableBulkActions } from './data-table-bulk-actions.js';
 import { DataTableFacetedFilter, DataTableFacetedFilterOption } from './data-table-faceted-filter.js';
 import { DataTableFilterBadge } from './data-table-filter-badge.js';
-import { useChannel } from '@/hooks/use-channel.js';
-import { RefreshButton } from '@/components/data-table/refresh-button.js';
 
 export interface FacetedFilter {
     title: string;
@@ -48,6 +50,7 @@ interface DataTableProps<TData> {
     defaultColumnVisibility?: VisibilityState;
     facetedFilters?: { [key: string]: FacetedFilter | undefined };
     disableViewOptions?: boolean;
+    bulkActions?: BulkAction[];
     /**
      * This property allows full control over _all_ features of TanStack Table
      * when needed.
@@ -57,24 +60,25 @@ interface DataTableProps<TData> {
 }
 
 export function DataTable<TData>({
-                                     columns,
-                                     data,
-                                     totalItems,
-                                     page,
-                                     itemsPerPage,
-                                     sorting: sortingInitialState,
-                                     columnFilters: filtersInitialState,
-                                     onPageChange,
-                                     onSortChange,
-                                     onFilterChange,
-                                     onSearchTermChange,
-                                     onColumnVisibilityChange,
-                                     defaultColumnVisibility,
-                                     facetedFilters,
-                                     disableViewOptions,
-                                     setTableOptions,
-                                     onRefresh,
-                                 }: DataTableProps<TData>) {
+    columns,
+    data,
+    totalItems,
+    page,
+    itemsPerPage,
+    sorting: sortingInitialState,
+    columnFilters: filtersInitialState,
+    onPageChange,
+    onSortChange,
+    onFilterChange,
+    onSearchTermChange,
+    onColumnVisibilityChange,
+    defaultColumnVisibility,
+    facetedFilters,
+    disableViewOptions,
+    bulkActions,
+    setTableOptions,
+    onRefresh,
+}: DataTableProps<TData>) {
     const [sorting, setSorting] = React.useState<SortingState>(sortingInitialState || []);
     const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(filtersInitialState || []);
     const { activeChannel } = useChannel();
@@ -85,11 +89,15 @@ export function DataTable<TData>({
     const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(
         defaultColumnVisibility ?? {},
     );
+    const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
 
     useEffect(() => {
         // If the defaultColumnVisibility changes externally (e.g. the user reset the table settings),
         // we want to reset the column visibility to the default.
-        if (defaultColumnVisibility && JSON.stringify(defaultColumnVisibility) !== JSON.stringify(columnVisibility)) {
+        if (
+            defaultColumnVisibility &&
+            JSON.stringify(defaultColumnVisibility) !== JSON.stringify(columnVisibility)
+        ) {
             setColumnVisibility(defaultColumnVisibility);
         }
         // We intentionally do not include `columnVisibility` in the dependency array
@@ -98,6 +106,7 @@ export function DataTable<TData>({
     let tableOptions: TableOptions<TData> = {
         data,
         columns,
+        getRowId: row => (row as { id: string }).id,
         getCoreRowModel: getCoreRowModel(),
         getPaginationRowModel: getPaginationRowModel(),
         manualPagination: true,
@@ -108,11 +117,13 @@ export function DataTable<TData>({
         onSortingChange: setSorting,
         onColumnVisibilityChange: setColumnVisibility,
         onColumnFiltersChange: setColumnFilters,
+        onRowSelectionChange: setRowSelection,
         state: {
             pagination,
             sorting,
             columnVisibility,
             columnFilters,
+            rowSelection,
         },
     };
 
@@ -171,12 +182,19 @@ export function DataTable<TData>({
                             .map(f => {
                                 const column = table.getColumn(f.id);
                                 const currency = activeChannel?.defaultCurrencyCode ?? 'USD';
-                                return <DataTableFilterBadge
-                                    key={f.id}
-                                    filter={f}
-                                    currencyCode={currency}
-                                    dataType={(column?.columnDef.meta as any)?.fieldInfo?.type ?? 'String'}
-                                    onRemove={() => setColumnFilters(old => old.filter(x => x.id !== f.id))} />;
+                                return (
+                                    <DataTableFilterBadge
+                                        key={f.id}
+                                        filter={f}
+                                        currencyCode={currency}
+                                        dataType={
+                                            (column?.columnDef.meta as any)?.fieldInfo?.type ?? 'String'
+                                        }
+                                        onRemove={() =>
+                                            setColumnFilters(old => old.filter(x => x.id !== f.id))
+                                        }
+                                    />
+                                );
                             })}
                     </div>
                 </div>
@@ -185,6 +203,7 @@ export function DataTable<TData>({
                     {onRefresh && <RefreshButton onRefresh={onRefresh} />}
                 </div>
             </div>
+            <DataTableBulkActions bulkActions={bulkActions ?? []} table={table} />
             <div className="rounded-md border my-2">
                 <Table>
                     <TableHeader>
@@ -196,9 +215,9 @@ export function DataTable<TData>({
                                             {header.isPlaceholder
                                                 ? null
                                                 : flexRender(
-                                                    header.column.columnDef.header,
-                                                    header.getContext(),
-                                                )}
+                                                      header.column.columnDef.header,
+                                                      header.getContext(),
+                                                  )}
                                         </TableHead>
                                     );
                                 })}

+ 3 - 3
packages/dashboard/src/lib/components/data-table/human-readable-operator.tsx

@@ -1,11 +1,11 @@
-import { DATETIME_OPERATORS } from './filters/data-table-datetime-filter.js';
+import { Trans } from '@/lib/trans.js';
 import { BOOLEAN_OPERATORS } from './filters/data-table-boolean-filter.js';
+import { DATETIME_OPERATORS } from './filters/data-table-datetime-filter.js';
 import { ID_OPERATORS } from './filters/data-table-id-filter.js';
 import { NUMBER_OPERATORS } from './filters/data-table-number-filter.js';
 import { STRING_OPERATORS } from './filters/data-table-string-filter.js';
-import { Trans } from '@/lib/trans.js';
 
-type Operator =
+export type Operator =
     | (typeof DATETIME_OPERATORS)[number]
     | (typeof BOOLEAN_OPERATORS)[number]
     | (typeof ID_OPERATORS)[number]

+ 0 - 0
packages/dashboard/src/lib/components/data-table/data-table-types.ts → packages/dashboard/src/lib/components/data-table/types.ts


+ 1 - 5
packages/dashboard/src/lib/components/shared/assigned-facet-values.tsx

@@ -57,11 +57,7 @@ export function AssignedFacetValues({
                     );
                 })}
             </div>
-            {canUpdate && (
-                <FacetValueSelector
-                    onValueSelect={onSelectHandler}
-                />
-            )}
+            {canUpdate && <FacetValueSelector onValueSelect={onSelectHandler} />}
         </>
     );
 }

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

@@ -7,15 +7,9 @@ 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, useQueryClient } from '@tanstack/react-query';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { useDebounce } from '@uidotdev/usehooks';
 
-import {
-    DropdownMenu,
-    DropdownMenuContent,
-    DropdownMenuItem,
-    DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu.js';
 import {
     AlertDialog,
     AlertDialogAction,
@@ -27,11 +21,17 @@ import {
     AlertDialogTitle,
     AlertDialogTrigger,
 } from '@/components/ui/alert-dialog.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu.js';
 import { DisplayComponent } from '@/framework/component-registry/dynamic-component.js';
+import { BulkAction } from '@/framework/data-table/data-table-types.js';
 import { ResultOf } from '@/graphql/graphql.js';
 import { Trans, useLingui } from '@/lib/trans.js';
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
-import { useQuery } from '@tanstack/react-query';
 import {
     ColumnFiltersState,
     ColumnSort,
@@ -44,6 +44,7 @@ import { EllipsisIcon, TrashIcon } from 'lucide-react';
 import React, { useMemo } from 'react';
 import { toast } from 'sonner';
 import { Button } from '../ui/button.js';
+import { Checkbox } from '../ui/checkbox.js';
 
 // Type that identifies a paginated list structure (has items array and totalItems)
 type IsPaginatedList<T> = T extends { items: any[]; totalItems: number } ? true : false;
@@ -227,6 +228,7 @@ export interface PaginatedListDataTableProps<
     onColumnVisibilityChange?: (table: Table<any>, columnVisibility: VisibilityState) => void;
     facetedFilters?: FacetedFilterConfig<T>;
     rowActions?: RowAction<PaginatedListItemFields<T>>[];
+    bulkActions?: BulkAction[];
     disableViewOptions?: boolean;
     transformData?: (data: PaginatedListItemFields<T>[]) => PaginatedListItemFields<T>[];
     setTableOptions?: (table: TableOptions<any>) => TableOptions<any>;
@@ -265,6 +267,7 @@ export function PaginatedListDataTable<
     onColumnVisibilityChange,
     facetedFilters,
     rowActions,
+    bulkActions,
     disableViewOptions,
     setTableOptions,
     transformData,
@@ -309,6 +312,7 @@ export function PaginatedListDataTable<
     function refetchPaginatedList() {
         queryClient.invalidateQueries({ queryKey });
     }
+
     registerRefresher?.(refetchPaginatedList);
 
     const { data } = useQuery({
@@ -427,7 +431,10 @@ export function PaginatedListDataTable<
             // existing order
             const orderedColumns = finalColumns
                 .filter(column => column.id && defaultColumnOrder.includes(column.id as any))
-                .sort((a, b) => defaultColumnOrder.indexOf(a.id as any) - defaultColumnOrder.indexOf(b.id as any));
+                .sort(
+                    (a, b) =>
+                        defaultColumnOrder.indexOf(a.id as any) - defaultColumnOrder.indexOf(b.id as any),
+                );
             const remainingColumns = finalColumns.filter(
                 column => !column.id || !defaultColumnOrder.includes(column.id as any),
             );
@@ -441,6 +448,31 @@ export function PaginatedListDataTable<
             }
         }
 
+        // Add the row selection column
+        finalColumns.unshift({
+            id: 'selection',
+            accessorKey: 'selection',
+            header: ({ table }) => (
+                <Checkbox
+                    className="mx-1"
+                    checked={table.getIsAllRowsSelected()}
+                    onCheckedChange={checked =>
+                        table.toggleAllRowsSelected(checked === 'indeterminate' ? undefined : checked)
+                    }
+                />
+            ),
+            enableColumnFilter: false,
+            cell: ({ row }) => {
+                return (
+                    <Checkbox
+                        className="mx-1"
+                        checked={row.getIsSelected()}
+                        onCheckedChange={row.getToggleSelectedHandler()}
+                    />
+                );
+            },
+        });
+
         return { columns: finalColumns, customFieldColumnNames };
     }, [fields, customizeColumns, rowActions]);
 
@@ -465,6 +497,7 @@ export function PaginatedListDataTable<
                 defaultColumnVisibility={columnVisibility}
                 facetedFilters={facetedFilters}
                 disableViewOptions={disableViewOptions}
+                bulkActions={bulkActions}
                 setTableOptions={setTableOptions}
                 onRefresh={refetchPaginatedList}
             />
@@ -536,7 +569,7 @@ function DeleteMutationRowAction({
     return (
         <AlertDialog>
             <AlertDialogTrigger asChild>
-                <DropdownMenuItem onSelect={(e) => e.preventDefault()}>
+                <DropdownMenuItem onSelect={e => e.preventDefault()}>
                     <div className="flex items-center gap-2 text-destructive">
                         <TrashIcon className="w-4 h-4 text-destructive" />
                         <Trans>Delete</Trans>
@@ -549,7 +582,9 @@ function DeleteMutationRowAction({
                         <Trans>Confirm deletion</Trans>
                     </AlertDialogTitle>
                     <AlertDialogDescription>
-                        <Trans>Are you sure you want to delete this item? This action cannot be undone.</Trans>
+                        <Trans>
+                            Are you sure you want to delete this item? This action cannot be undone.
+                        </Trans>
                     </AlertDialogDescription>
                 </AlertDialogHeader>
                 <AlertDialogFooter>
@@ -567,6 +602,7 @@ function DeleteMutationRowAction({
         </AlertDialog>
     );
 }
+
 /**
  * Returns the default column visibility configuration.
  */

+ 21 - 0
packages/dashboard/src/lib/framework/data-table/data-table-extensions.ts

@@ -0,0 +1,21 @@
+import { BulkAction } from '@/framework/data-table/data-table-types.js';
+
+import { globalRegistry } from '../registry/global-registry.js';
+
+globalRegistry.register('bulkActionsRegistry', new Map<string, BulkAction[]>());
+
+export function getBulkActions(pageId: string, blockId = 'list-table'): BulkAction[] {
+    const key = createKey(pageId, blockId);
+    return globalRegistry.get('bulkActionsRegistry').get(key) || [];
+}
+
+export function addBulkAction(pageId: string, blockId: string | undefined, action: BulkAction) {
+    const bulkActionsRegistry = globalRegistry.get('bulkActionsRegistry');
+    const key = createKey(pageId, blockId);
+    const existingActions = bulkActionsRegistry.get(key) || [];
+    bulkActionsRegistry.set(key, [...existingActions, action]);
+}
+
+function createKey(pageId: string, blockId: string | undefined): string {
+    return `${pageId}__${blockId ?? 'list-table'}`;
+}

+ 25 - 0
packages/dashboard/src/lib/framework/data-table/data-table-types.ts

@@ -0,0 +1,25 @@
+import { Table } from '@tanstack/react-table';
+
+export type BulkActionContext<Item extends { id: string } & Record<string, any>> = {
+    selection: Item[];
+    table: Table<Item>;
+};
+
+export type BulkActionComponent<Item extends { id: string } & Record<string, any>> = React.FunctionComponent<
+    BulkActionContext<Item>
+>;
+
+/**
+ * @description
+ * **Status: Developer Preview**
+ *
+ * A bulk action is a component that will be rendered in the bulk actions dropdown.
+ *
+ * @docsCategory components
+ * @docsPage DataTableBulkActions
+ * @since 3.4.0
+ */
+export type BulkAction = {
+    order?: number;
+    component: BulkActionComponent<any>;
+};

+ 11 - 0
packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts

@@ -1,3 +1,5 @@
+import { addBulkAction } from '@/framework/data-table/data-table-extensions.js';
+
 import { registerDashboardWidget } from '../dashboard-widget/widget-extensions.js';
 import { addCustomFormComponent } from '../form-engine/custom-form-component-extensions.js';
 import {
@@ -82,6 +84,15 @@ export function defineDashboardExtension(extension: DashboardExtension) {
                 addCustomFormComponent(component);
             }
         }
+        if (extension.dataTables) {
+            for (const dataTable of extension.dataTables) {
+                if (dataTable.bulkActions?.length) {
+                    for (const action of dataTable.bulkActions) {
+                        addBulkAction(dataTable.pageId, dataTable.blockId, action);
+                    }
+                }
+            }
+        }
         const callbacks = globalRegistry.get('extensionSourceChangeCallbacks');
         if (callbacks.size) {
             for (const callback of callbacks) {

+ 35 - 0
packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts

@@ -5,6 +5,7 @@ import type React from 'react';
 
 import { DashboardAlertDefinition } from '../alert/types.js';
 import { DashboardWidgetDefinition } from '../dashboard-widget/types.js';
+import { BulkAction } from '../data-table/data-table-types.js';
 import { CustomFormComponentInputProps } from '../form-engine/custom-form-component.js';
 import { NavMenuItem } from '../nav-menu/nav-menu-extensions.js';
 
@@ -109,6 +110,35 @@ export interface DashboardPageBlockDefinition {
     requiresPermission?: string | string[];
 }
 
+/**
+ * @description
+ * **Status: Developer Preview**
+ *
+ * This allows you to customize aspects of existing data tables in the dashboard.
+ *
+ * @docsCategory extensions
+ * @since 3.4.0
+ */
+export interface DashboardDataTableDefinition {
+    /**
+     * @description
+     * The ID of the page where the data table is located, e.g. `'product-list'`, `'order-list'`.
+     */
+    pageId: string;
+    /**
+     * @description
+     * The ID of the data table block. Defaults to `'list-table'`, which is the default blockId
+     * for the standard list pages. However, some other pages may use a different blockId,
+     * such as `'product-variants-table'` on the `'product-detail'` page.
+     */
+    blockId?: string;
+    /**
+     * @description
+     * An array of additional bulk actions that will be available on the data table.
+     */
+    bulkActions?: BulkAction[];
+}
+
 /**
  * @description
  * **Status: Developer Preview**
@@ -155,4 +185,9 @@ export interface DashboardExtension {
      * Allows you to define custom form components for custom fields in the dashboard.
      */
     customFormComponents?: DashboardCustomFormComponent[];
+    /**
+     * @description
+     * Allows you to customize aspects of existing data tables in the dashboard.
+     */
+    dataTables?: DashboardDataTableDefinition[];
 }

+ 2 - 5
packages/dashboard/src/lib/framework/form-engine/use-generated-form.tsx

@@ -1,8 +1,5 @@
 import { getOperationVariablesFields } from '@/framework/document-introspection/get-document-structure.js';
-import {
-    createFormSchemaFromFields,
-    getDefaultValuesFromFields,
-} from '@/framework/form-engine/form-schema-tools.js';
+import { createFormSchemaFromFields, getDefaultValuesFromFields } from '@/framework/form-engine/form-schema-tools.js';
 import { useChannel } from '@/hooks/use-channel.js';
 import { useServerConfig } from '@/hooks/use-server-config.js';
 import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
@@ -57,7 +54,7 @@ export function useGeneratedForm<
         },
         mode: 'onChange',
         defaultValues,
-        values: processedEntity ? processedEntity : defaultValues,
+        values: processedEntity ? setValues(processedEntity) : defaultValues,
     });
     let submitHandler = (event: FormEvent) => {
         event.preventDefault();

+ 6 - 0
packages/dashboard/src/lib/framework/layout-engine/page-block-provider.tsx

@@ -0,0 +1,6 @@
+import { PageBlockProps } from '@/framework/layout-engine/page-layout.js';
+import { createContext } from 'react';
+
+export type PageBlockContextValue = Pick<PageBlockProps, 'blockId' | 'column' | 'title' | 'description'>;
+
+export const PageBlockContext = createContext<PageBlockContextValue | undefined>(undefined);

+ 43 - 33
packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx

@@ -1,20 +1,21 @@
 import { CustomFieldsForm } from '@/components/shared/custom-fields-form.js';
+import { NavigationConfirmation } from '@/components/shared/navigation-confirmation.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
 import { Form } from '@/components/ui/form.js';
 import { useCustomFieldConfig } from '@/hooks/use-custom-field-config.js';
 import { usePage } from '@/hooks/use-page.js';
 import { cn } from '@/lib/utils.js';
-import { NavigationConfirmation } from '@/components/shared/navigation-confirmation.js';
 import { useMediaQuery } from '@uidotdev/usehooks';
 import React, { ComponentProps } from 'react';
 import { Control, UseFormReturn } from 'react-hook-form';
 
 import { DashboardActionBarItem } from '../extension-api/extension-api-types.js';
 
+import { PageBlockContext } from '@/framework/layout-engine/page-block-provider.js';
+import { PageContext, PageContextValue } from '@/framework/layout-engine/page-provider.js';
 import { getDashboardActionBarItems, getDashboardPageBlocks } from './layout-extensions.js';
 import { LocationWrapper } from './location-wrapper.js';
-import { PageContext, PageContextValue } from '@/framework/layout-engine/page-provider.js';
 
 export interface PageProps extends ComponentProps<'div'> {
     pageId?: string;
@@ -45,9 +46,7 @@ export function Page({ children, pageId, entity, form, submitHandler, ...props }
     const childArray = React.Children.toArray(children);
 
     const pageTitle = childArray.find(child => React.isValidElement(child) && child.type === PageTitle);
-    const pageActionBar = childArray.find(
-        child => isOfType(child, PageActionBar),
-    );
+    const pageActionBar = childArray.find(child => isOfType(child, PageActionBar));
 
     const pageContent = childArray.filter(
         child => !isOfType(child, PageTitle) && !isOfType(child, PageActionBar),
@@ -73,7 +72,13 @@ export function Page({ children, pageId, entity, form, submitHandler, ...props }
     );
 }
 
-function PageContent({ pageHeader, pageContent, form, submitHandler, ...props }: {
+function PageContent({
+    pageHeader,
+    pageContent,
+    form,
+    submitHandler,
+    ...props
+}: {
     pageHeader: React.ReactNode;
     pageContent: React.ReactNode;
     form?: UseFormReturn<any>;
@@ -94,9 +99,14 @@ function PageContent({ pageHeader, pageContent, form, submitHandler, ...props }:
     );
 }
 
-export function PageContentWithOptionalForm({ form, pageHeader, pageContent, submitHandler }: {
+export function PageContentWithOptionalForm({
+    form,
+    pageHeader,
+    pageContent,
+    submitHandler,
+}: {
     form?: UseFormReturn<any>;
-    pageHeader: React.ReactNode
+    pageHeader: React.ReactNode;
     pageContent: React.ReactNode;
     submitHandler?: any;
 }) {
@@ -261,12 +271,8 @@ export function PageTitle({ children }: { children: React.ReactNode }) {
 export function PageActionBar({ children }: { children: React.ReactNode }) {
     let childArray = React.Children.toArray(children);
 
-    const leftContent = childArray.filter(
-        child => isOfType(child, PageActionBarLeft),
-    );
-    const rightContent = childArray.filter(
-        child => isOfType(child, PageActionBarRight),
-    );
+    const leftContent = childArray.filter(child => isOfType(child, PageActionBarLeft));
+    const rightContent = childArray.filter(child => isOfType(child, PageActionBarRight));
 
     return (
         <div className={cn('flex gap-2', leftContent.length > 0 ? 'justify-between' : 'justify-end')}>
@@ -348,18 +354,20 @@ export type PageBlockProps = {
  * @docsWeight 0
  * @since 3.3.0
  */
-export function PageBlock({ children, title, description, className, blockId }: PageBlockProps) {
+export function PageBlock({ children, title, description, className, blockId, column }: PageBlockProps) {
     return (
         <LocationWrapper blockId={blockId}>
-            <Card className={cn('w-full', className)}>
-                {title || description ? (
-                    <CardHeader>
-                        {title && <CardTitle>{title}</CardTitle>}
-                        {description && <CardDescription>{description}</CardDescription>}
-                    </CardHeader>
-                ) : null}
-                <CardContent className={cn(!title ? 'pt-6' : '')}>{children}</CardContent>
-            </Card>
+            <PageBlockContext.Provider value={{ blockId, title, description, column }}>
+                <Card className={cn('w-full', className)}>
+                    {title || description ? (
+                        <CardHeader>
+                            {title && <CardTitle>{title}</CardTitle>}
+                            {description && <CardDescription>{description}</CardDescription>}
+                        </CardHeader>
+                    ) : null}
+                    <CardContent className={cn(!title ? 'pt-6' : '')}>{children}</CardContent>
+                </Card>
+            </PageBlockContext.Provider>
         </LocationWrapper>
     );
 }
@@ -376,13 +384,15 @@ export function PageBlock({ children, title, description, className, blockId }:
  * @since 3.3.0
  */
 export function FullWidthPageBlock({
-                                       children,
-                                       className,
-                                       blockId,
-                                   }: Pick<PageBlockProps, 'children' | 'className' | 'blockId'>) {
+    children,
+    className,
+    blockId,
+}: Pick<PageBlockProps, 'children' | 'className' | 'blockId'>) {
     return (
         <LocationWrapper blockId={blockId}>
-            <div className={cn('w-full', className)}>{children}</div>
+            <PageBlockContext.Provider value={{ blockId, column: 'main' }}>
+                <div className={cn('w-full', className)}>{children}</div>
+            </PageBlockContext.Provider>
         </LocationWrapper>
     );
 }
@@ -398,10 +408,10 @@ export function FullWidthPageBlock({
  * @since 3.3.0
  */
 export function CustomFieldsPageBlock({
-                                          column,
-                                          entityType,
-                                          control,
-                                      }: {
+    column,
+    entityType,
+    control,
+}: {
     column: 'main' | 'side';
     entityType: string;
     control: Control<any, any>;

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

@@ -3,12 +3,13 @@ import {
     CustomFieldKeysOfItem,
     CustomizeColumnConfig,
     FacetedFilterConfig,
+    ListQueryFields,
     ListQueryOptionsShape,
     ListQueryShape,
-    ListQueryFields,
     PaginatedListDataTable,
     RowAction,
 } from '@/components/shared/paginated-list-data-table.js';
+import { BulkAction } from '@/framework/data-table/data-table-types.js';
 import { useUserSettings } from '@/hooks/use-user-settings.js';
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { AnyRoute, AnyRouter, useNavigate } from '@tanstack/react-router';
@@ -16,13 +17,7 @@ import { ColumnFiltersState, SortingState, Table } from '@tanstack/react-table';
 import { TableOptions } from '@tanstack/table-core';
 
 import { addCustomFields } from '../document-introspection/add-custom-fields.js';
-import {
-    FullWidthPageBlock,
-    Page,
-    PageActionBar,
-    PageLayout,
-    PageTitle,
-} from '../layout-engine/page-layout.js';
+import { FullWidthPageBlock, Page, PageActionBar, PageLayout, PageTitle } from '../layout-engine/page-layout.js';
 
 /**
  * @description
@@ -57,6 +52,7 @@ export interface ListPageProps<
     rowActions?: RowAction<ListQueryFields<T>>[];
     transformData?: (data: any[]) => any[];
     setTableOptions?: (table: TableOptions<any>) => TableOptions<any>;
+    bulkActions?: BulkAction[];
 }
 
 /**
@@ -93,6 +89,7 @@ export function ListPage<
     rowActions,
     transformData,
     setTableOptions,
+    bulkActions,
 }: ListPageProps<T, U, V, AC>) {
     const route = typeof routeOrFn === 'function' ? routeOrFn() : routeOrFn;
     const routeSearch = route.useSearch();
@@ -191,6 +188,7 @@ export function ListPage<
                         }}
                         facetedFilters={facetedFilters}
                         rowActions={rowActions}
+                        bulkActions={bulkActions}
                         setTableOptions={setTableOptions}
                         transformData={transformData}
                     />

+ 4 - 2
packages/dashboard/src/lib/framework/registry/registry-types.ts

@@ -1,5 +1,8 @@
+import React from 'react';
+
 import { DashboardAlertDefinition } from '../alert/types.js';
 import { DashboardWidgetDefinition } from '../dashboard-widget/types.js';
+import { BulkAction } from '../data-table/data-table-types.js';
 import {
     DashboardActionBarItem,
     DashboardPageBlockDefinition,
@@ -16,6 +19,5 @@ export interface GlobalRegistryContents {
     dashboardWidgetRegistry: Map<string, DashboardWidgetDefinition>;
     dashboardAlertRegistry: Map<string, DashboardAlertDefinition>;
     customFormComponents: Map<string, React.FunctionComponent<CustomFormComponentInputProps>>;
+    bulkActionsRegistry: Map<string, BulkAction[]>;
 }
-
-export type GlobalRegistryKey = keyof GlobalRegistryContents;

+ 10 - 0
packages/dashboard/src/lib/hooks/use-page-block.tsx

@@ -0,0 +1,10 @@
+import { PageBlockContext } from '@/framework/layout-engine/page-block-provider.js';
+import { useContext } from 'react';
+
+export function usePageBlock() {
+    const pageBlock = useContext(PageBlockContext);
+    if (!pageBlock) {
+        throw new Error('PageBlockProvider not found');
+    }
+    return pageBlock;
+}

+ 8 - 1
packages/dashboard/src/lib/index.ts

@@ -11,12 +11,13 @@ export * from './components/data-input/facet-value-input.js';
 export * from './components/data-input/money-input.js';
 export * from './components/data-input/richt-text-input.js';
 export * from './components/data-table/add-filter-menu.js';
+export * from './components/data-table/data-table-bulk-action-item.js';
+export * from './components/data-table/data-table-bulk-actions.js';
 export * from './components/data-table/data-table-column-header.js';
 export * from './components/data-table/data-table-faceted-filter.js';
 export * from './components/data-table/data-table-filter-badge.js';
 export * from './components/data-table/data-table-filter-dialog.js';
 export * from './components/data-table/data-table-pagination.js';
-export * from './components/data-table/data-table-types.js';
 export * from './components/data-table/data-table-view-options.js';
 export * from './components/data-table/data-table.js';
 export * from './components/data-table/filters/data-table-boolean-filter.js';
@@ -26,6 +27,7 @@ export * from './components/data-table/filters/data-table-number-filter.js';
 export * from './components/data-table/filters/data-table-string-filter.js';
 export * from './components/data-table/human-readable-operator.js';
 export * from './components/data-table/refresh-button.js';
+export * from './components/data-table/types.js';
 export * from './components/layout/app-layout.js';
 export * from './components/layout/app-sidebar.js';
 export * from './components/layout/channel-switcher.js';
@@ -137,6 +139,8 @@ export * from './framework/dashboard-widget/orders-summary/index.js';
 export * from './framework/dashboard-widget/orders-summary/order-summary-widget.graphql.js';
 export * from './framework/dashboard-widget/types.js';
 export * from './framework/dashboard-widget/widget-extensions.js';
+export * from './framework/data-table/data-table-extensions.js';
+export * from './framework/data-table/data-table-types.js';
 export * from './framework/defaults.js';
 export * from './framework/document-introspection/add-custom-fields.js';
 export * from './framework/document-introspection/get-document-structure.js';
@@ -150,6 +154,7 @@ export * from './framework/form-engine/form-schema-tools.js';
 export * from './framework/form-engine/use-generated-form.js';
 export * from './framework/layout-engine/layout-extensions.js';
 export * from './framework/layout-engine/location-wrapper.js';
+export * from './framework/layout-engine/page-block-provider.js';
 export * from './framework/layout-engine/page-layout.js';
 export * from './framework/layout-engine/page-provider.js';
 export * from './framework/nav-menu/nav-menu-extensions.js';
@@ -164,12 +169,14 @@ export * from './framework/registry/global-registry.js';
 export * from './framework/registry/registry-types.js';
 export * from './graphql/api.js';
 export * from './graphql/fragments.js';
+export * from './graphql/graphql.js';
 export * from './hooks/use-auth.js';
 export * from './hooks/use-channel.js';
 export * from './hooks/use-custom-field-config.js';
 export * from './hooks/use-grouped-permissions.js';
 export * from './hooks/use-local-format.js';
 export * from './hooks/use-mobile.js';
+export * from './hooks/use-page-block.js';
 export * from './hooks/use-page.js';
 export * from './hooks/use-permissions.js';
 export * from './hooks/use-server-config.js';

+ 13 - 9
packages/dashboard/vite/tests/barrel-exports.spec.ts

@@ -4,14 +4,18 @@ import { describe, expect, it } from 'vitest';
 import { loadVendureConfig } from '../utils/config-loader.js';
 
 describe('detecting plugins in barrel exports', () => {
-    it('should detect plugins in barrel exports', async () => {
-        const result = await loadVendureConfig({
-            tempDir: join(__dirname, './__temp'),
-            vendureConfigPath: join(__dirname, 'barrel-exports', 'vendure-config.ts'),
-        });
+    it(
+        'should detect plugins in barrel exports',
+        async () => {
+            const result = await loadVendureConfig({
+                tempDir: join(__dirname, './__temp'),
+                vendureConfigPath: join(__dirname, 'barrel-exports', 'vendure-config.ts'),
+            });
 
-        expect(result.pluginInfo).toHaveLength(1);
-        expect(result.pluginInfo[0].name).toBe('MyPlugin');
-        expect(result.pluginInfo[0].dashboardEntryPath).toBe('./dashboard/index.tsx');
-    });
+            expect(result.pluginInfo).toHaveLength(1);
+            expect(result.pluginInfo[0].name).toBe('MyPlugin');
+            expect(result.pluginInfo[0].dashboardEntryPath).toBe('./dashboard/index.tsx');
+        },
+        { timeout: 10_000 },
+    );
 });

+ 1 - 0
packages/dashboard/vite/vite-plugin-config.ts

@@ -63,6 +63,7 @@ export function viteConfigPlugin({ packageRoot }: { packageRoot: string }): Plug
                     ...(config.optimizeDeps?.include || []),
                     '@/components > recharts',
                     '@/components > react-dropzone',
+                    '@vendure/common/lib/generated-types',
                 ],
             };
             return config;

+ 22 - 1
packages/dev-server/test-plugins/reviews/dashboard/index.tsx

@@ -1,4 +1,6 @@
-import { Button, defineDashboardExtension } from '@vendure/dashboard';
+import { Button, DataTableBulkActionItem, defineDashboardExtension } from '@vendure/dashboard';
+import { InfoIcon } from 'lucide-react';
+import { toast } from 'sonner';
 
 import { TextareaCustomField } from './custom-form-components';
 import { CustomWidget } from './custom-widget';
@@ -53,4 +55,23 @@ export default defineDashboardExtension({
             component: TextareaCustomField,
         },
     ],
+    dataTables: [
+        {
+            pageId: 'product-list',
+            bulkActions: [
+                {
+                    component: props => (
+                        <DataTableBulkActionItem
+                            onClick={() => {
+                                console.log('Selection:', props.selection);
+                                toast.message(`There are ${props.selection.length} selected items`);
+                            }}
+                            label="My Custom Action"
+                            icon={InfoIcon}
+                        />
+                    ),
+                },
+            ],
+        },
+    ],
 });