Browse Source

Merge remote-tracking branch 'origin/master'

David Höck 6 months ago
parent
commit
15008b27a6

+ 134 - 0
packages/dashboard/src/app/common/duplicate-bulk-action.tsx

@@ -0,0 +1,134 @@
+import { useMutation } from '@tanstack/react-query';
+import { CopyIcon } from 'lucide-react';
+import { useState } from 'react';
+import { toast } from 'sonner';
+
+import { DataTableBulkActionItem } from '@/components/data-table/data-table-bulk-action-item.js';
+import { api } from '@/graphql/api.js';
+import { duplicateEntityDocument } from '@/graphql/common-operations.js';
+import { usePaginatedList } from '@/index.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+
+interface DuplicateBulkActionProps {
+    entityType: 'Product' | 'Collection';
+    duplicatorCode: string;
+    duplicatorArguments?: Array<{ name: string; value: string }>;
+    requiredPermissions: string[];
+    entityName: string; // For display purposes in error messages
+    onSuccess?: () => void;
+    selection: any[];
+    table: any;
+}
+
+export function DuplicateBulkAction({
+    entityType,
+    duplicatorCode,
+    duplicatorArguments = [],
+    requiredPermissions,
+    entityName,
+    onSuccess,
+    selection,
+    table,
+}: DuplicateBulkActionProps) {
+    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 entities sequentially to avoid overwhelming the server
+            for (let i = 0; i < selection.length; i++) {
+                const entity = selection[i];
+
+                try {
+                    const result = await mutateAsync({
+                        input: {
+                            entityName: entityType,
+                            entityId: entity.id,
+                            duplicatorInput: {
+                                code: duplicatorCode,
+                                arguments: duplicatorArguments,
+                            },
+                        },
+                    });
+
+                    if ('newEntityId' in result.duplicateEntity) {
+                        results.success++;
+                    } else {
+                        results.failed++;
+                        const errorMsg =
+                            result.duplicateEntity.message ||
+                            result.duplicateEntity.duplicationError ||
+                            'Unknown error';
+                        results.errors.push(`${entityName} ${entity.name || entity.id}: ${errorMsg}`);
+                    }
+                } catch (error) {
+                    results.failed++;
+                    results.errors.push(
+                        `${entityName} ${entity.name || entity.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} ${entityName.toLowerCase()}s`),
+                );
+            }
+            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} ${entityName.toLowerCase()}s: ${errorMessage}`,
+                );
+            }
+
+            if (results.success > 0) {
+                refetchPaginatedList();
+                table.resetRowSelection();
+                onSuccess?.();
+            }
+        } finally {
+            setIsDuplicating(false);
+            setProgress({ completed: 0, total: 0 });
+        }
+    };
+
+    return (
+        <DataTableBulkActionItem
+            requiresPermission={requiredPermissions}
+            onClick={handleDuplicate}
+            label={
+                isDuplicating ? (
+                    <Trans>
+                        Duplicating... ({progress.completed}/{progress.total})
+                    </Trans>
+                ) : (
+                    <Trans>Duplicate</Trans>
+                )
+            }
+            icon={CopyIcon}
+        />
+    );
+}

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

@@ -147,3 +147,12 @@ export const removeCollectionFromChannelDocument = graphql(`
         }
     }
 `);
+
+export const deleteCollectionsDocument = graphql(`
+    mutation DeleteCollections($ids: [ID!]!) {
+        deleteCollections(ids: $ids) {
+            result
+            message
+        }
+    }
+`);

+ 10 - 0
packages/dashboard/src/app/routes/_authenticated/_collections/collections.tsx

@@ -16,6 +16,8 @@ import { useState } from 'react';
 import { collectionListDocument, deleteCollectionDocument } from './collections.graphql.js';
 import {
     AssignCollectionsToChannelBulkAction,
+    DeleteCollectionsBulkAction,
+    DuplicateCollectionsBulkAction,
     RemoveCollectionsFromChannelBulkAction,
 } from './components/collection-bulk-actions.js';
 import { CollectionContentsSheet } from './components/collection-contents-sheet.js';
@@ -192,6 +194,14 @@ function CollectionListPage() {
                     component: RemoveCollectionsFromChannelBulkAction,
                     order: 200,
                 },
+                {
+                    component: DuplicateCollectionsBulkAction,
+                    order: 300,
+                },
+                {
+                    component: DeleteCollectionsBulkAction,
+                    order: 400,
+                },
             ]}
         >
             <PageActionBarRight>

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

@@ -1,17 +1,17 @@
 import { useMutation, useQueryClient } from '@tanstack/react-query';
-import { LayersIcon } from 'lucide-react';
+import { LayersIcon, 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 { useChannel, usePaginatedList } from '@/index.js';
+import { ResultOf, useChannel, usePaginatedList } from '@/index.js';
 import { Trans, useLingui } from '@/lib/trans.js';
-
-import { Permission } from '@vendure/common/lib/generated-types';
+import { DuplicateBulkAction } from '../../../../common/duplicate-bulk-action.js';
 import {
     assignCollectionToChannelDocument,
+    deleteCollectionsDocument,
     removeCollectionFromChannelDocument,
 } from '../collections.graphql.js';
 import { AssignCollectionsToChannelDialog } from './assign-collections-to-channel-dialog.js';
@@ -35,7 +35,7 @@ export const AssignCollectionsToChannelBulkAction: BulkActionComponent<any> = ({
     return (
         <>
             <DataTableBulkActionItem
-                requiresPermission={[Permission.UpdateCatalog, Permission.UpdateCollection]}
+                requiresPermission={['UpdateCatalog', 'UpdateCollection']}
                 onClick={() => setDialogOpen(true)}
                 label={<Trans>Assign to channel</Trans>}
                 icon={LayersIcon}
@@ -84,7 +84,7 @@ export const RemoveCollectionsFromChannelBulkAction: BulkActionComponent<any> =
 
     return (
         <DataTableBulkActionItem
-            requiresPermission={[Permission.UpdateCatalog, Permission.UpdateCollection]}
+            requiresPermission={['UpdateCatalog', 'UpdateCollection']}
             onClick={handleRemove}
             label={<Trans>Remove from current channel</Trans>}
             confirmationText={
@@ -97,3 +97,63 @@ export const RemoveCollectionsFromChannelBulkAction: BulkActionComponent<any> =
         />
     );
 };
+
+export const DuplicateCollectionsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    const queryClient = useQueryClient();
+    return (
+        <DuplicateBulkAction
+            entityType="Collection"
+            duplicatorCode="collection-duplicator"
+            duplicatorArguments={[]}
+            requiredPermissions={['UpdateCatalog', 'UpdateCollection']}
+            entityName="Collection"
+            selection={selection}
+            table={table}
+            onSuccess={() => {
+                queryClient.invalidateQueries({ queryKey: ['childCollections'] });
+            }}
+        />
+    );
+};
+
+export const DeleteCollectionsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    const { refetchPaginatedList } = usePaginatedList();
+    const { i18n } = useLingui();
+    const queryClient = useQueryClient();
+    const { mutate } = useMutation({
+        mutationFn: api.mutate(deleteCollectionsDocument),
+        onSuccess: (result: ResultOf<typeof deleteCollectionsDocument>) => {
+            let deleted = 0;
+            const errors: string[] = [];
+            for (const item of result.deleteCollections) {
+                if (item.result === 'DELETED') {
+                    deleted++;
+                } else if (item.message) {
+                    errors.push(item.message);
+                }
+            }
+            if (0 < deleted) {
+                toast.success(i18n.t(`Deleted ${deleted} collections`));
+            }
+            if (0 < errors.length) {
+                toast.error(i18n.t(`Failed to delete ${errors.length} collections`));
+            }
+            refetchPaginatedList();
+            table.resetRowSelection();
+            queryClient.invalidateQueries({ queryKey: ['childCollections'] });
+        },
+        onError: () => {
+            toast.error(`Failed to delete ${selection.length} collections`);
+        },
+    });
+    return (
+        <DataTableBulkActionItem
+            requiresPermission={['DeleteCatalog', 'DeleteCollection']}
+            onClick={() => mutate({ ids: selection.map(s => s.id) })}
+            label={<Trans>Delete</Trans>}
+            confirmationText={<Trans>Are you sure you want to delete {selection.length} collections?</Trans>}
+            icon={TrashIcon}
+            className="text-destructive"
+        />
+    );
+};

+ 4 - 5
packages/dashboard/src/app/routes/_authenticated/_product-variants/components/product-variant-bulk-actions.tsx

@@ -10,7 +10,6 @@ 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 { AssignFacetValuesDialog } from '../../_products/components/assign-facet-values-dialog.js';
 import { AssignToChannelDialog } from '../../_products/components/assign-to-channel-dialog.js';
 import {
@@ -52,7 +51,7 @@ export const DeleteProductVariantsBulkAction: BulkActionComponent<any> = ({ sele
     });
     return (
         <DataTableBulkActionItem
-            requiresPermission={[Permission.DeleteCatalog, Permission.DeleteProduct]}
+            requiresPermission={['DeleteCatalog', 'DeleteProduct']}
             onClick={() => mutate({ ids: selection.map(s => s.id) })}
             label={<Trans>Delete</Trans>}
             confirmationText={
@@ -81,7 +80,7 @@ export const AssignProductVariantsToChannelBulkAction: BulkActionComponent<any>
     return (
         <>
             <DataTableBulkActionItem
-                requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
+                requiresPermission={['UpdateCatalog', 'UpdateProduct']}
                 onClick={() => setDialogOpen(true)}
                 label={<Trans>Assign to channel</Trans>}
                 icon={LayersIcon}
@@ -134,7 +133,7 @@ export const RemoveProductVariantsFromChannelBulkAction: BulkActionComponent<any
 
     return (
         <DataTableBulkActionItem
-            requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
+            requiresPermission={['UpdateCatalog', 'UpdateProduct']}
             onClick={handleRemove}
             label={<Trans>Remove from current channel</Trans>}
             confirmationText={
@@ -164,7 +163,7 @@ export const AssignFacetValuesToProductVariantsBulkAction: BulkActionComponent<a
     return (
         <>
             <DataTableBulkActionItem
-                requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
+                requiresPermission={['UpdateCatalog', 'UpdateProduct']}
                 onClick={() => setDialogOpen(true)}
                 label={<Trans>Edit facet values</Trans>}
                 icon={TagIcon}

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

@@ -1,5 +1,5 @@
 import { useMutation } from '@tanstack/react-query';
-import { CopyIcon, LayersIcon, TagIcon, TrashIcon } from 'lucide-react';
+import { LayersIcon, TagIcon, TrashIcon } from 'lucide-react';
 import { useState } from 'react';
 import { toast } from 'sonner';
 
@@ -9,12 +9,10 @@ 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 { DuplicateBulkAction } from '../../../../common/duplicate-bulk-action.js';
 import {
     assignProductsToChannelDocument,
     deleteProductsDocument,
-    duplicateEntityDocument,
     getProductsWithFacetValuesByIdsDocument,
     productDetailDocument,
     removeProductsFromChannelDocument,
@@ -53,7 +51,7 @@ export const DeleteProductsBulkAction: BulkActionComponent<any> = ({ selection,
     });
     return (
         <DataTableBulkActionItem
-            requiresPermission={[Permission.DeleteCatalog, Permission.DeleteProduct]}
+            requiresPermission={['DeleteCatalog', '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>}
@@ -80,7 +78,7 @@ export const AssignProductsToChannelBulkAction: BulkActionComponent<any> = ({ se
     return (
         <>
             <DataTableBulkActionItem
-                requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
+                requiresPermission={['UpdateCatalog', 'UpdateProduct']}
                 onClick={() => setDialogOpen(true)}
                 label={<Trans>Assign to channel</Trans>}
                 icon={LayersIcon}
@@ -128,7 +126,7 @@ export const RemoveProductsFromChannelBulkAction: BulkActionComponent<any> = ({
 
     return (
         <DataTableBulkActionItem
-            requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
+            requiresPermission={['UpdateCatalog', 'UpdateProduct']}
             onClick={handleRemove}
             label={<Trans>Remove from current channel</Trans>}
             confirmationText={
@@ -154,7 +152,7 @@ export const AssignFacetValuesToProductsBulkAction: BulkActionComponent<any> = (
     return (
         <>
             <DataTableBulkActionItem
-                requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
+                requiresPermission={['UpdateCatalog', 'UpdateProduct']}
                 onClick={() => setDialogOpen(true)}
                 label={<Trans>Edit facet values</Trans>}
                 icon={TagIcon}
@@ -174,105 +172,20 @@ export const AssignFacetValuesToProductsBulkAction: BulkActionComponent<any> = (
 };
 
 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}
+        <DuplicateBulkAction
+            entityType="Product"
+            duplicatorCode="product-duplicator"
+            duplicatorArguments={[
+                {
+                    name: 'includeVariants',
+                    value: 'true',
+                },
+            ]}
+            requiredPermissions={['UpdateCatalog', 'UpdateProduct']}
+            entityName="Product"
+            selection={selection}
+            table={table}
         />
     );
 };

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

@@ -187,20 +187,3 @@ export const getProductsWithFacetValuesByIdsDocument = graphql(`
         }
     }
 `);
-
-export const duplicateEntityDocument = graphql(`
-    mutation DuplicateEntity($input: DuplicateEntityInput!) {
-        duplicateEntity(input: $input) {
-            ... on DuplicateEntitySuccess {
-                newEntityId
-            }
-            ... on ErrorResult {
-                errorCode
-                message
-            }
-            ... on DuplicateEntityError {
-                duplicationError
-            }
-        }
-    }
-`);

+ 18 - 0
packages/dashboard/src/lib/graphql/common-operations.ts

@@ -0,0 +1,18 @@
+import { graphql } from './graphql.js';
+
+export const duplicateEntityDocument = graphql(`
+    mutation DuplicateEntity($input: DuplicateEntityInput!) {
+        duplicateEntity(input: $input) {
+            ... on DuplicateEntitySuccess {
+                newEntityId
+            }
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+            ... on DuplicateEntityError {
+                duplicationError
+            }
+        }
+    }
+`);

+ 1 - 2
packages/dashboard/src/lib/graphql/fragments.tsx → packages/dashboard/src/lib/graphql/fragments.ts

@@ -1,4 +1,4 @@
-import { graphql, ResultOf } from "./graphql.js";
+import { graphql, ResultOf } from './graphql.js';
 
 export const assetFragment = graphql(`
     fragment Asset on Asset {
@@ -58,5 +58,4 @@ export const errorResultFragment = graphql(`
     }
 `);
 
-
 export type ConfigurableOperationDefFragment = ResultOf<typeof configurableOperationDefFragment>;