Browse Source

feat(dashboard): Implement collection channel bulk actions

Michael Bromley 6 months ago
parent
commit
332e6e259f

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

@@ -131,3 +131,19 @@ export const getCollectionFiltersQueryOptions = queryOptions({
     queryKey: ['getCollectionFilters'],
     queryFn: () => api.query(getCollectionFiltersDocument),
 }) as DefinedInitialDataOptions<ResultOf<typeof getCollectionFiltersDocument>>;
+
+export const assignCollectionToChannelDocument = graphql(`
+    mutation AssignCollectionsToChannel($input: AssignCollectionsToChannelInput!) {
+        assignCollectionsToChannel(input: $input) {
+            id
+        }
+    }
+`);
+
+export const removeCollectionFromChannelDocument = graphql(`
+    mutation RemoveCollectionsFromChannel($input: RemoveCollectionsFromChannelInput!) {
+        removeCollectionsFromChannel(input: $input) {
+            id
+        }
+    }
+`);

+ 16 - 2
packages/dashboard/src/app/routes/_authenticated/_collections/collections.tsx

@@ -5,7 +5,7 @@ import { PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
 import { ListPage } from '@/framework/page/list-page.js';
 import { api } from '@/graphql/api.js';
 import { Trans } from '@/lib/trans.js';
-import { useQueries } from '@tanstack/react-query';
+import { FetchQueryOptions, useQueries } from '@tanstack/react-query';
 import { createFileRoute, Link } from '@tanstack/react-router';
 import { ExpandedState, getExpandedRowModel } from '@tanstack/react-table';
 import { TableOptions } from '@tanstack/table-core';
@@ -14,6 +14,10 @@ import { Folder, FolderOpen, PlusIcon } from 'lucide-react';
 import { useState } from 'react';
 
 import { collectionListDocument, deleteCollectionDocument } from './collections.graphql.js';
+import {
+    AssignCollectionsToChannelBulkAction,
+    RemoveCollectionsFromChannelBulkAction,
+} from './components/collection-bulk-actions.js';
 import { CollectionContentsSheet } from './components/collection-contents-sheet.js';
 
 export const Route = createFileRoute('/_authenticated/_collections/collections')({
@@ -38,7 +42,7 @@ function CollectionListPage() {
                         },
                     }),
                 staleTime: 1000 * 60 * 5,
-            };
+            } satisfies FetchQueryOptions;
         }),
     });
     const childCollectionsByParentId = childrenQueries.reduce(
@@ -179,6 +183,16 @@ function CollectionListPage() {
                 };
             }}
             route={Route}
+            bulkActions={[
+                {
+                    component: AssignCollectionsToChannelBulkAction,
+                    order: 100,
+                },
+                {
+                    component: RemoveCollectionsFromChannelBulkAction,
+                    order: 200,
+                },
+            ]}
         >
             <PageActionBarRight>
                 <PermissionGuard requires={['CreateCollection', 'CreateCatalog']}>

+ 110 - 0
packages/dashboard/src/app/routes/_authenticated/_collections/components/assign-collections-to-channel-dialog.tsx

@@ -0,0 +1,110 @@
+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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
+import { ResultOf } from '@/graphql/graphql.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+
+import { useChannel } from '@/hooks/use-channel.js';
+
+interface AssignCollectionsToChannelDialogProps {
+    open: boolean;
+    onOpenChange: (open: boolean) => void;
+    entityIds: string[];
+    mutationFn: (variables: any) => Promise<ResultOf<any>>;
+    onSuccess?: () => void;
+}
+
+export function AssignCollectionsToChannelDialog({
+    open,
+    onOpenChange,
+    entityIds,
+    mutationFn,
+    onSuccess,
+}: AssignCollectionsToChannelDialogProps) {
+    const { i18n } = useLingui();
+    const [selectedChannelId, setSelectedChannelId] = useState<string>('');
+    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,
+        onSuccess: () => {
+            toast.success(i18n.t(`Successfully assigned ${entityIds.length} collections to channel`));
+            onSuccess?.();
+            onOpenChange(false);
+        },
+        onError: () => {
+            toast.error(`Failed to assign ${entityIds.length} collections to channel`);
+        },
+    });
+
+    const handleAssign = () => {
+        if (!selectedChannelId) {
+            toast.error('Please select a channel');
+            return;
+        }
+
+        const input = {
+            collectionIds: entityIds,
+            channelId: selectedChannelId,
+        };
+
+        mutate({ input });
+    };
+
+    return (
+        <Dialog open={open} onOpenChange={onOpenChange}>
+            <DialogContent className="sm:max-w-[425px]">
+                <DialogHeader>
+                    <DialogTitle>
+                        <Trans>Assign collections to channel</Trans>
+                    </DialogTitle>
+                    <DialogDescription>
+                        <Trans>Select a channel to assign {entityIds.length} collections 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>
+                <DialogFooter>
+                    <Button variant="outline" onClick={() => onOpenChange(false)}>
+                        <Trans>Cancel</Trans>
+                    </Button>
+                    <Button onClick={handleAssign} disabled={!selectedChannelId || isPending}>
+                        <Trans>Assign</Trans>
+                    </Button>
+                </DialogFooter>
+            </DialogContent>
+        </Dialog>
+    );
+}

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

@@ -0,0 +1,99 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { LayersIcon } 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 { Trans, useLingui } from '@/lib/trans.js';
+
+import { Permission } from '@vendure/common/lib/generated-types';
+import {
+    assignCollectionToChannelDocument,
+    removeCollectionFromChannelDocument,
+} from '../collections.graphql.js';
+import { AssignCollectionsToChannelDialog } from './assign-collections-to-channel-dialog.js';
+
+export const AssignCollectionsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    const { refetchPaginatedList } = usePaginatedList();
+    const { channels } = useChannel();
+    const [dialogOpen, setDialogOpen] = useState(false);
+    const queryClient = useQueryClient();
+
+    if (channels.length < 2) {
+        return null;
+    }
+
+    const handleSuccess = () => {
+        refetchPaginatedList();
+        table.resetRowSelection();
+        queryClient.invalidateQueries({ queryKey: ['childCollections'] });
+    };
+
+    return (
+        <>
+            <DataTableBulkActionItem
+                requiresPermission={[Permission.UpdateCatalog, Permission.UpdateCollection]}
+                onClick={() => setDialogOpen(true)}
+                label={<Trans>Assign to channel</Trans>}
+                icon={LayersIcon}
+            />
+            <AssignCollectionsToChannelDialog
+                open={dialogOpen}
+                onOpenChange={setDialogOpen}
+                entityIds={selection.map(s => s.id)}
+                mutationFn={api.mutate(assignCollectionToChannelDocument)}
+                onSuccess={handleSuccess}
+            />
+        </>
+    );
+};
+
+export const RemoveCollectionsFromChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
+    const { refetchPaginatedList } = usePaginatedList();
+    const { selectedChannel } = useChannel();
+    const { i18n } = useLingui();
+    const queryClient = useQueryClient();
+    const { mutate } = useMutation({
+        mutationFn: api.mutate(removeCollectionFromChannelDocument),
+        onSuccess: () => {
+            toast.success(i18n.t(`Successfully removed ${selection.length} collections from channel`));
+            refetchPaginatedList();
+            table.resetRowSelection();
+            queryClient.invalidateQueries({ queryKey: ['childCollections'] });
+        },
+        onError: error => {
+            toast.error(`Failed to remove ${selection.length} collections from channel: ${error.message}`);
+        },
+    });
+
+    if (!selectedChannel) {
+        return null;
+    }
+
+    const handleRemove = () => {
+        mutate({
+            input: {
+                collectionIds: selection.map(s => s.id),
+                channelId: selectedChannel.id,
+            },
+        });
+    };
+
+    return (
+        <DataTableBulkActionItem
+            requiresPermission={[Permission.UpdateCatalog, Permission.UpdateCollection]}
+            onClick={handleRemove}
+            label={<Trans>Remove from current channel</Trans>}
+            confirmationText={
+                <Trans>
+                    Are you sure you want to remove {selection.length} collections from the current channel?
+                </Trans>
+            }
+            icon={LayersIcon}
+            className="text-warning"
+        />
+    );
+};