Browse Source

fix(dashboard): Fix bulk editing of product facet values

Michael Bromley 6 months ago
parent
commit
943b71f142

+ 9 - 9
packages/admin-ui/src/lib/catalog/src/components/bulk-add-facet-values-dialog/bulk-add-facet-values-dialog.component.ts

@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnIni
 import {
     DataService,
     Dialog,
-    FacetWithValuesFragment,
+    FacetValueFragment,
     GetProductsWithFacetValuesByIdsQuery,
     GetProductsWithFacetValuesByIdsQueryVariables,
     GetVariantsWithFacetValuesByIdsQuery,
@@ -13,7 +13,6 @@ import {
 } from '@vendure/admin-ui/core';
 import { unique } from '@vendure/common/lib/unique';
 import { Observable, Subscription } from 'rxjs';
-import { shareReplay, switchMap } from 'rxjs/operators';
 
 import {
     GET_PRODUCTS_WITH_FACET_VALUES_BY_IDS,
@@ -43,22 +42,23 @@ interface ProductOrVariant {
     templateUrl: './bulk-add-facet-values-dialog.component.html',
     styleUrls: ['./bulk-add-facet-values-dialog.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
-    standalone: false
+    standalone: false,
 })
-export class BulkAddFacetValuesDialogComponent
-    implements OnInit, OnDestroy, Dialog<FacetWithValuesFragment[]>
-{
-    resolveWith: (result?: FacetWithValuesFragment[]) => void;
+export class BulkAddFacetValuesDialogComponent implements OnInit, OnDestroy, Dialog<FacetValueFragment[]> {
+    resolveWith: (result?: FacetValueFragment[]) => void;
     /* provided by call to ModalService */
     mode: 'product' | 'variant' = 'product';
     ids?: string[];
     state: 'loading' | 'ready' | 'saving' = 'loading';
 
-    selectedValues: FacetWithValuesFragment[] = [];
+    selectedValues: FacetValueFragment[] = [];
     items: ProductOrVariant[] = [];
     facetValuesRemoved = false;
     private subscription: Subscription;
-    constructor(private dataService: DataService, private changeDetectorRef: ChangeDetectorRef) {}
+    constructor(
+        private dataService: DataService,
+        private changeDetectorRef: ChangeDetectorRef,
+    ) {}
 
     ngOnInit(): void {
         const fetchData$: Observable<any> =

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

@@ -1,7 +1,9 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { useState } from 'react';
 import { toast } from 'sonner';
-import { useMutation } from '@tanstack/react-query';
 
+import { FacetValueChip } from '@/components/shared/facet-value-chip.js';
+import { FacetValue, FacetValueSelector } from '@/components/shared/facet-value-selector.js';
 import { Button } from '@/components/ui/button.js';
 import {
     Dialog,
@@ -11,12 +13,31 @@ import {
     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';
+import { getDetailQueryOptions } from '@/framework/page/use-detail-page.js';
+import {
+    getProductsWithFacetValuesByIdsDocument,
+    productDetailDocument,
+    updateProductsDocument,
+} from '../products.graphql.js';
+
+interface ProductWithFacetValues {
+    id: string;
+    name: string;
+    facetValues: Array<{
+        id: string;
+        name: string;
+        code: string;
+        facet: {
+            id: string;
+            name: string;
+            code: string;
+        };
+    }>;
+}
 
 interface AssignFacetValuesDialogProps {
     open: boolean;
@@ -25,9 +46,24 @@ interface AssignFacetValuesDialogProps {
     onSuccess?: () => void;
 }
 
-export function AssignFacetValuesDialog({ open, onOpenChange, productIds, onSuccess }: AssignFacetValuesDialogProps) {
+export function AssignFacetValuesDialog({
+    open,
+    onOpenChange,
+    productIds,
+    onSuccess,
+}: AssignFacetValuesDialogProps) {
     const { i18n } = useLingui();
-    const [selectedFacetValueIds, setSelectedFacetValueIds] = useState<string[]>([]);
+    const [selectedValues, setSelectedValues] = useState<FacetValue[]>([]);
+    const [facetValuesRemoved, setFacetValuesRemoved] = useState(false);
+    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,
+    });
 
     const { mutate, isPending } = useMutation({
         mutationFn: api.mutate(updateProductsDocument),
@@ -35,6 +71,14 @@ export function AssignFacetValuesDialog({ open, onOpenChange, productIds, onSucc
             toast.success(i18n.t(`Successfully updated facet values for ${productIds.length} products`));
             onSuccess?.();
             onOpenChange(false);
+            // Reset state
+            setSelectedValues([]);
+            setFacetValuesRemoved(false);
+            setRemovedFacetValues(new Set());
+            productIds.forEach(id => {
+                const { queryKey } = getDetailQueryOptions(productDetailDocument, { id });
+                queryClient.removeQueries({ queryKey });
+            });
         },
         onError: () => {
             toast.error(`Failed to update facet values for ${productIds.length} products`);
@@ -42,57 +86,165 @@ export function AssignFacetValuesDialog({ open, onOpenChange, productIds, onSucc
     });
 
     const handleAssign = () => {
-        if (selectedFacetValueIds.length === 0) {
-            toast.error('Please select at least one facet value');
+        if (selectedValues.length === 0 && !facetValuesRemoved) {
+            toast.error('Please select at least one facet value or make changes to existing ones');
             return;
         }
 
+        if (!productsData?.products.items) {
+            return;
+        }
+
+        const selectedFacetValueIds = selectedValues.map(sv => sv.id);
+
         mutate({
-            input: productIds.map(productId => ({
-                id: productId,
-                facetValueIds: selectedFacetValueIds,
+            input: productsData.products.items.map(product => ({
+                id: product.id,
+                facetValueIds: [
+                    ...new Set([
+                        ...product.facetValues.filter(fv => !removedFacetValues.has(fv.id)).map(fv => fv.id),
+                        ...selectedFacetValueIds,
+                    ]),
+                ],
             })),
         });
     };
 
     const handleFacetValueSelect = (facetValue: FacetValue) => {
-        setSelectedFacetValueIds(prev => [...new Set([...prev, facetValue.id])]);
+        setSelectedValues(prev => [...prev, facetValue]);
+    };
+
+    const removeFacetValue = (productId: string, facetValueId: string) => {
+        setRemovedFacetValues(prev => new Set([...prev, facetValueId]));
+        setFacetValuesRemoved(true);
+    };
+
+    const handleCancel = () => {
+        onOpenChange(false);
+        // Reset state
+        setSelectedValues([]);
+        setFacetValuesRemoved(false);
+        setRemovedFacetValues(new Set());
+    };
+
+    // Filter out removed facet values for display
+    const getDisplayFacetValues = (product: ProductWithFacetValues) => {
+        return product.facetValues.filter(fv => !removedFacetValues.has(fv.id));
     };
 
     return (
         <Dialog open={open} onOpenChange={onOpenChange}>
-            <DialogContent className="sm:max-w-[500px]">
+            <DialogContent className="sm:max-w-[800px] max-h-[80vh] overflow-hidden flex flex-col">
                 <DialogHeader>
-                    <DialogTitle><Trans>Edit facet values</Trans></DialogTitle>
+                    <DialogTitle>
+                        <Trans>Edit facet values</Trans>
+                    </DialogTitle>
                     <DialogDescription>
-                        <Trans>Select facet values to assign to {productIds.length} products</Trans>
+                        <Trans>Add or remove facet values for {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>
+
+                <div className="flex-1 overflow-hidden flex flex-col gap-4">
+                    {/* Add new facet values section */}
+                    <div className="flex items-center gap-2">
+                        <div className="text-sm font-medium">
+                            <Trans>Add facet value</Trans>
+                        </div>
                         <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>
+
+                    {/* Products 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 ? (
+                            <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>
+                                            </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);
+                                            return (
+                                                <tr key={product.id} className="border-t">
+                                                    <td className="p-3 align-top">
+                                                        <div className="font-medium">{product.name}</div>
+                                                    </td>
+                                                    <td className="p-3">
+                                                        <div className="flex flex-wrap gap-2">
+                                                            {displayFacetValues.map(facetValue => (
+                                                                <FacetValueChip
+                                                                    key={facetValue.id}
+                                                                    facetValue={facetValue}
+                                                                    removable={true}
+                                                                    onRemove={() =>
+                                                                        removeFacetValue(
+                                                                            product.id,
+                                                                            facetValue.id,
+                                                                        )
+                                                                    }
+                                                                />
+                                                            ))}
+                                                            {displayFacetValues.length === 0 && (
+                                                                <div className="text-sm text-muted-foreground">
+                                                                    <Trans>No facet values</Trans>
+                                                                </div>
+                                                            )}
+                                                        </div>
+                                                    </td>
+                                                </tr>
+                                            );
+                                        })}
+                                    </tbody>
+                                </table>
+                            </div>
+                        ) : null}
+                    </div>
+
+                    {/* Selected values summary */}
+                    {selectedValues.length > 0 && (
+                        <div className="border-t pt-4">
+                            <div className="text-sm font-medium mb-2">
+                                <Trans>New facet values to add:</Trans>
+                            </div>
+                            <div className="flex flex-wrap gap-2">
+                                {selectedValues.map(facetValue => (
+                                    <FacetValueChip
+                                        key={facetValue.id}
+                                        facetValue={facetValue}
+                                        removable={false}
+                                    />
+                                ))}
+                            </div>
                         </div>
                     )}
                 </div>
+
                 <DialogFooter>
-                    <Button variant="outline" onClick={() => onOpenChange(false)}>
+                    <Button variant="outline" onClick={handleCancel}>
                         <Trans>Cancel</Trans>
                     </Button>
-                    <Button onClick={handleAssign} disabled={selectedFacetValueIds.length === 0 || isPending}>
+                    <Button
+                        onClick={handleAssign}
+                        disabled={(selectedValues.length === 0 && !facetValuesRemoved) || isPending}
+                    >
                         <Trans>Update</Trans>
                     </Button>
                 </DialogFooter>
             </DialogContent>
         </Dialog>
     );
-} 
+}

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

@@ -61,7 +61,7 @@ export const DeleteProductsBulkAction: BulkActionComponent<any> = ({ selection,
 
 export const AssignProductsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
     const { refetchPaginatedList } = usePaginatedList();
-    const { channels, selectedChannel } = useChannel();
+    const { channels } = useChannel();
     const [dialogOpen, setDialogOpen] = useState(false);
 
     if (channels.length < 2) {

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

@@ -167,6 +167,27 @@ export const updateProductsDocument = graphql(`
     }
 `);
 
+export const getProductsWithFacetValuesByIdsDocument = graphql(`
+    query GetProductsWithFacetValuesByIds($ids: [String!]!) {
+        products(options: { filter: { id: { in: $ids } } }) {
+            items {
+                id
+                name
+                facetValues {
+                    id
+                    name
+                    code
+                    facet {
+                        id
+                        name
+                        code
+                    }
+                }
+            }
+        }
+    }
+`);
+
 export const duplicateEntityDocument = graphql(`
     mutation DuplicateEntity($input: DuplicateEntityInput!) {
         duplicateEntity(input: $input) {

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

@@ -50,8 +50,7 @@ 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',