Browse Source

feat(dashboard): Implement product variant bulk actions

Michael Bromley 6 months ago
parent
commit
2f651b2b52

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

@@ -0,0 +1,184 @@
+import { useMutation } from '@tanstack/react-query';
+import { 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 { AssignFacetValuesDialog } from '../../_products/components/assign-facet-values-dialog.js';
+import { AssignToChannelDialog } from '../../_products/components/assign-to-channel-dialog.js';
+import {
+    assignProductVariantsToChannelDocument,
+    deleteProductVariantsDocument,
+    getProductVariantsWithFacetValuesByIdsDocument,
+    productVariantDetailDocument,
+    removeProductVariantsFromChannelDocument,
+    updateProductVariantsDocument,
+} from '../product-variants.graphql.js';
+
+export const DeleteProductVariantsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    const { refetchPaginatedList } = usePaginatedList();
+    const { i18n } = useLingui();
+    const { mutate } = useMutation({
+        mutationFn: api.mutate(deleteProductVariantsDocument),
+        onSuccess: (result: ResultOf<typeof deleteProductVariantsDocument>) => {
+            let deleted = 0;
+            const errors: string[] = [];
+            for (const item of result.deleteProductVariants) {
+                if (item.result === 'DELETED') {
+                    deleted++;
+                } else if (item.message) {
+                    errors.push(item.message);
+                }
+            }
+            if (0 < deleted) {
+                toast.success(i18n.t(`Deleted ${deleted} product variants`));
+            }
+            if (0 < errors.length) {
+                toast.error(i18n.t(`Failed to delete ${errors.length} product variants`));
+            }
+            refetchPaginatedList();
+            table.resetRowSelection();
+        },
+        onError: () => {
+            toast.error(`Failed to delete ${selection.length} product variants`);
+        },
+    });
+    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} product variants?</Trans>
+            }
+            icon={TrashIcon}
+            className="text-destructive"
+        />
+    );
+};
+
+export const AssignProductVariantsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    const { refetchPaginatedList } = usePaginatedList();
+    const { channels } = 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}
+                entityIds={selection.map(s => s.id)}
+                entityType="variants"
+                mutationFn={api.mutate(assignProductVariantsToChannelDocument)}
+                onSuccess={handleSuccess}
+            />
+        </>
+    );
+};
+
+export const RemoveProductVariantsFromChannelBulkAction: BulkActionComponent<any> = ({
+    selection,
+    table,
+}) => {
+    const { refetchPaginatedList } = usePaginatedList();
+    const { selectedChannel } = useChannel();
+    const { i18n } = useLingui();
+    const { mutate } = useMutation({
+        mutationFn: api.mutate(removeProductVariantsFromChannelDocument),
+        onSuccess: () => {
+            toast.success(i18n.t(`Successfully removed ${selection.length} product variants from channel`));
+            refetchPaginatedList();
+            table.resetRowSelection();
+        },
+        onError: error => {
+            toast.error(
+                `Failed to remove ${selection.length} product variants from channel: ${error.message}`,
+            );
+        },
+    });
+
+    if (!selectedChannel) {
+        return null;
+    }
+
+    const handleRemove = () => {
+        mutate({
+            input: {
+                productVariantIds: 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} product variants from the current
+                    channel?
+                </Trans>
+            }
+            icon={LayersIcon}
+            className="text-warning"
+        />
+    );
+};
+
+export const AssignFacetValuesToProductVariantsBulkAction: 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}
+                entityIds={selection.map(s => s.id)}
+                entityType="variants"
+                queryFn={variables => api.query(getProductVariantsWithFacetValuesByIdsDocument, variables)}
+                mutationFn={api.mutate(updateProductVariantsDocument)}
+                detailDocument={productVariantDetailDocument}
+                onSuccess={handleSuccess}
+            />
+        </>
+    );
+};

+ 61 - 0
packages/dashboard/src/app/routes/_authenticated/_product-variants/product-variants.graphql.ts

@@ -121,3 +121,64 @@ export const deleteProductVariantDocument = graphql(`
         }
     }
 `);
+
+export const deleteProductVariantsDocument = graphql(`
+    mutation DeleteProductVariants($ids: [ID!]!) {
+        deleteProductVariants(ids: $ids) {
+            result
+            message
+        }
+    }
+`);
+
+export const assignProductVariantsToChannelDocument = graphql(`
+    mutation AssignProductVariantsToChannel($input: AssignProductVariantsToChannelInput!) {
+        assignProductVariantsToChannel(input: $input) {
+            id
+        }
+    }
+`);
+
+export const removeProductVariantsFromChannelDocument = graphql(`
+    mutation RemoveProductVariantsFromChannel($input: RemoveProductVariantsFromChannelInput!) {
+        removeProductVariantsFromChannel(input: $input) {
+            id
+        }
+    }
+`);
+
+export const getProductVariantsWithFacetValuesByIdsDocument = graphql(`
+    query GetProductVariantsWithFacetValuesByIds($ids: [String!]!) {
+        productVariants(options: { filter: { id: { in: $ids } } }) {
+            items {
+                id
+                name
+                sku
+                facetValues {
+                    id
+                    name
+                    code
+                    facet {
+                        id
+                        name
+                        code
+                    }
+                }
+            }
+        }
+    }
+`);
+
+export const updateProductVariantsDocument = graphql(`
+    mutation UpdateProductVariants($input: [UpdateProductVariantInput!]!) {
+        updateProductVariants(input: $input) {
+            id
+            name
+            facetValues {
+                id
+                name
+                code
+            }
+        }
+    }
+`);

+ 33 - 3
packages/dashboard/src/app/routes/_authenticated/_product-variants/product-variants.tsx

@@ -5,6 +5,12 @@ import { ListPage } from '@/framework/page/list-page.js';
 import { useLocalFormat } from '@/hooks/use-local-format.js';
 import { Trans } from '@/lib/trans.js';
 import { createFileRoute } from '@tanstack/react-router';
+import {
+    AssignFacetValuesToProductVariantsBulkAction,
+    AssignProductVariantsToChannelBulkAction,
+    DeleteProductVariantsBulkAction,
+    RemoveProductVariantsFromChannelBulkAction,
+} from './components/product-variant-bulk-actions.js';
 import { deleteProductVariantDocument, productVariantListDocument } from './product-variants.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_product-variants/product-variants')({
@@ -20,19 +26,43 @@ function ProductListPage() {
             title={<Trans>Product Variants</Trans>}
             listQuery={productVariantListDocument}
             deleteMutation={deleteProductVariantDocument}
+            bulkActions={[
+                {
+                    component: AssignProductVariantsToChannelBulkAction,
+                    order: 100,
+                },
+                {
+                    component: RemoveProductVariantsFromChannelBulkAction,
+                    order: 200,
+                },
+                {
+                    component: AssignFacetValuesToProductVariantsBulkAction,
+                    order: 300,
+                },
+                {
+                    component: DeleteProductVariantsBulkAction,
+                    order: 400,
+                },
+            ]}
             customizeColumns={{
                 name: {
                     header: 'Product Name',
-                    cell: ({ row: { original } }) => <DetailPageButton id={original.id} label={original.name} />,
+                    cell: ({ row: { original } }) => (
+                        <DetailPageButton id={original.id} label={original.name} />
+                    ),
                 },
                 currencyCode: {
                     cell: ({ row: { original } }) => formatCurrencyName(original.currencyCode, 'full'),
                 },
                 price: {
-                    cell: ({ row: { original } }) => <Money value={original.price} currency={original.currencyCode} />,
+                    cell: ({ row: { original } }) => (
+                        <Money value={original.price} currency={original.currencyCode} />
+                    ),
                 },
                 priceWithTax: {
-                    cell: ({ row: { original } }) => <Money value={original.priceWithTax} currency={original.currencyCode} />,
+                    cell: ({ row: { original } }) => (
+                        <Money value={original.priceWithTax} currency={original.currencyCode} />
+                    ),
                 },
                 stockLevels: {
                     cell: ({ row: { original } }) => <StockLevelLabel stockLevels={original.stockLevels} />,

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

@@ -13,20 +13,15 @@ import {
     DialogHeader,
     DialogTitle,
 } from '@/components/ui/dialog.js';
-import { api } from '@/graphql/api.js';
 import { ResultOf } from '@/graphql/graphql.js';
 import { Trans, useLingui } from '@/lib/trans.js';
 
 import { getDetailQueryOptions } from '@/framework/page/use-detail-page.js';
-import {
-    getProductsWithFacetValuesByIdsDocument,
-    productDetailDocument,
-    updateProductsDocument,
-} from '../products.graphql.js';
 
-interface ProductWithFacetValues {
+interface EntityWithFacetValues {
     id: string;
     name: string;
+    sku?: string;
     facetValues: Array<{
         id: string;
         name: string;
@@ -42,14 +37,22 @@ interface ProductWithFacetValues {
 interface AssignFacetValuesDialogProps {
     open: boolean;
     onOpenChange: (open: boolean) => void;
-    productIds: string[];
+    entityIds: string[];
+    entityType: 'products' | 'variants';
+    queryFn: (variables: any) => Promise<ResultOf<any>>;
+    mutationFn: (variables: any) => Promise<ResultOf<any>>;
+    detailDocument: any;
     onSuccess?: () => void;
 }
 
 export function AssignFacetValuesDialog({
     open,
     onOpenChange,
-    productIds,
+    entityIds,
+    entityType,
+    queryFn,
+    mutationFn,
+    detailDocument,
     onSuccess,
 }: AssignFacetValuesDialogProps) {
     const { i18n } = useLingui();
@@ -58,30 +61,30 @@ export function AssignFacetValuesDialog({
     const [removedFacetValues, setRemovedFacetValues] = useState<Set<string>>(new Set());
     const queryClient = useQueryClient();
 
-    // Fetch existing facet values for the products
-    const { data: productsData, isLoading } = useQuery({
-        queryKey: ['productsWithFacetValues', productIds],
-        queryFn: () => api.query(getProductsWithFacetValuesByIdsDocument, { ids: productIds }),
-        enabled: open && productIds.length > 0,
+    // Fetch existing facet values for the entities
+    const { data: entitiesData, isLoading } = useQuery({
+        queryKey: [`${entityType}WithFacetValues`, entityIds],
+        queryFn: () => queryFn({ ids: entityIds }),
+        enabled: open && entityIds.length > 0,
     });
 
     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`));
+        mutationFn,
+        onSuccess: () => {
+            toast.success(i18n.t(`Successfully updated facet values for ${entityIds.length} ${entityType}`));
             onSuccess?.();
             onOpenChange(false);
             // Reset state
             setSelectedValues([]);
             setFacetValuesRemoved(false);
             setRemovedFacetValues(new Set());
-            productIds.forEach(id => {
-                const { queryKey } = getDetailQueryOptions(productDetailDocument, { id });
+            entityIds.forEach(id => {
+                const { queryKey } = getDetailQueryOptions(detailDocument, { id });
                 queryClient.removeQueries({ queryKey });
             });
         },
         onError: () => {
-            toast.error(`Failed to update facet values for ${productIds.length} products`);
+            toast.error(`Failed to update facet values for ${entityIds.length} ${entityType}`);
         },
     });
 
@@ -91,18 +94,25 @@ export function AssignFacetValuesDialog({
             return;
         }
 
-        if (!productsData?.products.items) {
+        const items =
+            entityType === 'products'
+                ? (entitiesData as any)?.products?.items
+                : (entitiesData as any)?.productVariants?.items;
+
+        if (!items) {
             return;
         }
 
         const selectedFacetValueIds = selectedValues.map(sv => sv.id);
 
         mutate({
-            input: productsData.products.items.map(product => ({
-                id: product.id,
+            input: items.map((entity: EntityWithFacetValues) => ({
+                id: entity.id,
                 facetValueIds: [
                     ...new Set([
-                        ...product.facetValues.filter(fv => !removedFacetValues.has(fv.id)).map(fv => fv.id),
+                        ...entity.facetValues
+                            .filter((fv: any) => !removedFacetValues.has(fv.id))
+                            .map((fv: any) => fv.id),
                         ...selectedFacetValueIds,
                     ]),
                 ],
@@ -114,7 +124,7 @@ export function AssignFacetValuesDialog({
         setSelectedValues(prev => [...prev, facetValue]);
     };
 
-    const removeFacetValue = (productId: string, facetValueId: string) => {
+    const removeFacetValue = (entityId: string, facetValueId: string) => {
         setRemovedFacetValues(prev => new Set([...prev, facetValueId]));
         setFacetValuesRemoved(true);
     };
@@ -128,10 +138,15 @@ export function AssignFacetValuesDialog({
     };
 
     // Filter out removed facet values for display
-    const getDisplayFacetValues = (product: ProductWithFacetValues) => {
-        return product.facetValues.filter(fv => !removedFacetValues.has(fv.id));
+    const getDisplayFacetValues = (entity: EntityWithFacetValues) => {
+        return entity.facetValues.filter(fv => !removedFacetValues.has(fv.id));
     };
 
+    const items =
+        entityType === 'products'
+            ? (entitiesData as any)?.products?.items
+            : (entitiesData as any)?.productVariants?.items;
+
     return (
         <Dialog open={open} onOpenChange={onOpenChange}>
             <DialogContent className="sm:max-w-[800px] max-h-[80vh] overflow-hidden flex flex-col">
@@ -140,7 +155,9 @@ export function AssignFacetValuesDialog({
                         <Trans>Edit facet values</Trans>
                     </DialogTitle>
                     <DialogDescription>
-                        <Trans>Add or remove facet values for {productIds.length} products</Trans>
+                        <Trans>
+                            Add or remove facet values for {entityIds.length} {entityType}
+                        </Trans>
                     </DialogDescription>
                 </DialogHeader>
 
@@ -156,33 +173,47 @@ export function AssignFacetValuesDialog({
                         />
                     </div>
 
-                    {/* Products table */}
+                    {/* Entities table */}
                     <div className="flex-1 overflow-auto">
                         {isLoading ? (
                             <div className="flex items-center justify-center py-8">
                                 <div className="text-sm text-muted-foreground">Loading...</div>
                             </div>
-                        ) : productsData?.products.items ? (
+                        ) : items ? (
                             <div className="border rounded-md">
                                 <table className="w-full">
                                     <thead className="bg-muted/50">
                                         <tr>
                                             <th className="text-left p-3 text-sm font-medium">
-                                                <Trans>Product</Trans>
+                                                <Trans>
+                                                    {entityType === 'products' ? 'Product' : 'Variant'}
+                                                </Trans>
                                             </th>
+                                            {entityType === 'variants' && (
+                                                <th className="text-left p-3 text-sm font-medium">
+                                                    <Trans>SKU</Trans>
+                                                </th>
+                                            )}
                                             <th className="text-left p-3 text-sm font-medium">
                                                 <Trans>Current facet values</Trans>
                                             </th>
                                         </tr>
                                     </thead>
                                     <tbody>
-                                        {productsData.products.items.map(product => {
-                                            const displayFacetValues = getDisplayFacetValues(product);
+                                        {items.map((entity: EntityWithFacetValues) => {
+                                            const displayFacetValues = getDisplayFacetValues(entity);
                                             return (
-                                                <tr key={product.id} className="border-t">
+                                                <tr key={entity.id} className="border-t">
                                                     <td className="p-3 align-top">
-                                                        <div className="font-medium">{product.name}</div>
+                                                        <div className="font-medium">{entity.name}</div>
                                                     </td>
+                                                    {entityType === 'variants' && (
+                                                        <td className="p-3 align-top">
+                                                            <div className="text-sm text-muted-foreground">
+                                                                {entity.sku}
+                                                            </div>
+                                                        </td>
+                                                    )}
                                                     <td className="p-3">
                                                         <div className="flex flex-wrap gap-2">
                                                             {displayFacetValues.map(facetValue => (
@@ -192,7 +223,7 @@ export function AssignFacetValuesDialog({
                                                                     removable={true}
                                                                     onRemove={() =>
                                                                         removeFacetValue(
-                                                                            product.id,
+                                                                            entity.id,
                                                                             facetValue.id,
                                                                         )
                                                                     }

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

@@ -14,24 +14,26 @@ import {
 } 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[];
+    entityIds: string[];
+    entityType: 'products' | 'variants';
+    mutationFn: (variables: any) => Promise<ResultOf<any>>;
     onSuccess?: () => void;
 }
 
 export function AssignToChannelDialog({
     open,
     onOpenChange,
-    productIds,
+    entityIds,
+    entityType,
+    mutationFn,
     onSuccess,
 }: AssignToChannelDialogProps) {
     const { i18n } = useLingui();
@@ -43,14 +45,14 @@ export function AssignToChannelDialog({
     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`));
+        mutationFn,
+        onSuccess: () => {
+            toast.success(i18n.t(`Successfully assigned ${entityIds.length} ${entityType} to channel`));
             onSuccess?.();
             onOpenChange(false);
         },
         onError: () => {
-            toast.error(`Failed to assign ${productIds.length} products to channel`);
+            toast.error(`Failed to assign ${entityIds.length} ${entityType} to channel`);
         },
     });
 
@@ -60,13 +62,20 @@ export function AssignToChannelDialog({
             return;
         }
 
-        mutate({
-            input: {
-                productIds,
-                channelId: selectedChannelId,
-                priceFactor,
-            },
-        });
+        const input =
+            entityType === 'products'
+                ? {
+                      productIds: entityIds,
+                      channelId: selectedChannelId,
+                      priceFactor,
+                  }
+                : {
+                      productVariantIds: entityIds,
+                      channelId: selectedChannelId,
+                      priceFactor,
+                  };
+
+        mutate({ input });
     };
 
     return (
@@ -74,10 +83,12 @@ export function AssignToChannelDialog({
             <DialogContent className="sm:max-w-[425px]">
                 <DialogHeader>
                     <DialogTitle>
-                        <Trans>Assign products to channel</Trans>
+                        <Trans>Assign {entityType} to channel</Trans>
                     </DialogTitle>
                     <DialogDescription>
-                        <Trans>Select a channel to assign {productIds.length} products to</Trans>
+                        <Trans>
+                            Select a channel to assign {entityIds.length} {entityType} to
+                        </Trans>
                     </DialogDescription>
                 </DialogHeader>
                 <div className="grid gap-4 py-4">

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

@@ -12,9 +12,13 @@ import { Trans, useLingui } from '@/lib/trans.js';
 
 import { Permission } from '@vendure/common/lib/generated-types';
 import {
+    assignProductsToChannelDocument,
     deleteProductsDocument,
     duplicateEntityDocument,
+    getProductsWithFacetValuesByIdsDocument,
+    productDetailDocument,
     removeProductsFromChannelDocument,
+    updateProductsDocument,
 } from '../products.graphql.js';
 import { AssignFacetValuesDialog } from './assign-facet-values-dialog.js';
 import { AssignToChannelDialog } from './assign-to-channel-dialog.js';
@@ -84,7 +88,9 @@ export const AssignProductsToChannelBulkAction: BulkActionComponent<any> = ({ se
             <AssignToChannelDialog
                 open={dialogOpen}
                 onOpenChange={setDialogOpen}
-                productIds={selection.map(s => s.id)}
+                entityIds={selection.map(s => s.id)}
+                entityType="products"
+                mutationFn={api.mutate(assignProductsToChannelDocument)}
                 onSuccess={handleSuccess}
             />
         </>
@@ -156,7 +162,11 @@ export const AssignFacetValuesToProductsBulkAction: BulkActionComponent<any> = (
             <AssignFacetValuesDialog
                 open={dialogOpen}
                 onOpenChange={setDialogOpen}
-                productIds={selection.map(s => s.id)}
+                entityIds={selection.map(s => s.id)}
+                entityType="products"
+                queryFn={variables => api.query(getProductsWithFacetValuesByIdsDocument, variables)}
+                mutationFn={api.mutate(updateProductsDocument)}
+                detailDocument={productDetailDocument}
                 onSuccess={handleSuccess}
             />
         </>