Преглед на файлове

feat(dashboard): Product channel assigner (#4063)

Will Nahmens преди 4 седмици
родител
ревизия
fbe5de21ab

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

@@ -38,6 +38,11 @@ export const productDetailFragment = graphql(
             assets {
                 ...Asset
             }
+            channels {
+                id
+                code
+                token
+            }
             translations {
                 id
                 languageCode

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

@@ -31,7 +31,16 @@ import { toast } from 'sonner';
 import { CreateProductVariantsDialog } from './components/create-product-variants-dialog.js';
 import { ProductOptionGroupBadge } from './components/product-option-group-badge.js';
 import { ProductVariantsTable } from './components/product-variants-table.js';
-import { createProductDocument, productDetailDocument, updateProductDocument } from './products.graphql.js';
+import {
+    assignProductsToChannelDocument,
+    createProductDocument,
+    productDetailDocument,
+    removeProductsFromChannelDocument,
+    updateProductDocument,
+} from './products.graphql.js';
+import { api } from '@/vdb/graphql/api.js';
+import { AssignedChannels } from '@/vdb/components/shared/assigned-channels.js';
+import { useChannel } from '@/vdb/hooks/use-channel.js';
 
 const pageId = 'product-detail';
 
@@ -56,6 +65,7 @@ function ProductDetailPage() {
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { t } = useLingui();
     const refreshRef = useRef<() => void>(() => {});
+    const { channels } = useChannel();
 
     const { form, submitHandler, entity, isPending, refreshEntity, resetForm } = useDetailPage({
         pageId,
@@ -70,6 +80,7 @@ function ProductDetailPage() {
                 featuredAssetId: entity.featuredAsset?.id,
                 assetIds: entity.assets.map(asset => asset.id),
                 facetValueIds: entity.facetValues.map(facetValue => facetValue.id),
+                channelIds: entity.channels.map(c => c.id) ?? [],
                 translations: entity.translations.map(translation => ({
                     id: translation.id,
                     languageCode: translation.languageCode,
@@ -205,6 +216,18 @@ function ProductDetailPage() {
                         )}
                     />
                 </PageBlock>
+                {channels.length > 1 && entity && (
+                    <PageBlock column="side" blockId="channels" title={<Trans>Channels</Trans>}>
+                        <AssignedChannels
+                            channels={entity.channels}
+                            entityId={entity.id}
+                            canUpdate={!creatingNewEntity}
+                            assignMutationFn={api.mutate(assignProductsToChannelDocument)}
+                            removeMutationFn={api.mutate(removeProductsFromChannelDocument)}
+                        />
+                    </PageBlock>
+                )}
+
                 <PageBlock column="side" blockId="assets" title={<Trans>Assets</Trans>}>
                     <FormItem>
                         <FormControl>

+ 10 - 10
packages/dashboard/src/lib/components/shared/assign-to-channel-bulk-action.tsx

@@ -23,16 +23,16 @@ interface AssignToChannelBulkActionProps {
 }
 
 export function AssignToChannelBulkAction({
-                                              selection,
-                                              table,
-                                              entityType,
-                                              mutationFn,
-                                              requiredPermissions,
-                                              buildInput,
-                                              additionalFields,
-                                              additionalData = {},
-                                              onSuccess,
-                                          }: Readonly<AssignToChannelBulkActionProps>) {
+    selection,
+    table,
+    entityType,
+    mutationFn,
+    requiredPermissions,
+    buildInput,
+    additionalFields,
+    additionalData = {},
+    onSuccess,
+}: Readonly<AssignToChannelBulkActionProps>) {
     const { refetchPaginatedList } = usePaginatedList();
     const { channels } = useChannel();
     const [dialogOpen, setDialogOpen] = useState(false);

+ 1 - 1
packages/dashboard/src/lib/components/shared/assign-to-channel-dialog.tsx

@@ -70,7 +70,7 @@ export function AssignToChannelDialog({
             onOpenChange(false);
         },
         onError: () => {
-            toast.error(`Failed to assign ${entityIdsLength} ${entityType} to channel`);
+            toast.error(t`Failed to assign ${entityIdsLength} ${entityType} to channel`);
         },
     });
 

+ 108 - 0
packages/dashboard/src/lib/components/shared/assigned-channels.tsx

@@ -0,0 +1,108 @@
+import { useState } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner';
+import { Plus } from 'lucide-react';
+
+import { ChannelChip } from '@/vdb/components/shared/channel-chip.js';
+import { AssignToChannelDialog } from '@/vdb/components/shared/assign-to-channel-dialog.js';
+import { usePriceFactor } from '@/vdb/components/shared/assign-to-channel-dialog.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { useChannel } from '@/vdb/hooks/use-channel.js';
+import { Trans, useLingui } from '@lingui/react/macro';
+import { DEFAULT_CHANNEL_CODE } from '@/vdb/constants.js';
+import type { SimpleChannel } from '@/vdb/providers/channel-provider.js';
+
+interface AssignedChannelsProps {
+    channels: SimpleChannel[];
+    entityId: string;
+    canUpdate?: boolean;
+    assignMutationFn: (variables: any) => Promise<any>;
+    removeMutationFn: (variables: any) => Promise<any>;
+}
+
+export function AssignedChannels({
+    channels,
+    entityId,
+    canUpdate = true,
+    assignMutationFn,
+    removeMutationFn,
+}: AssignedChannelsProps) {
+    const { t } = useLingui();
+    const queryClient = useQueryClient();
+    const { activeChannel, channels: allChannels } = useChannel();
+    const [assignDialogOpen, setAssignDialogOpen] = useState(false);
+    const { priceFactor, priceFactorField } = usePriceFactor();
+
+    const { mutate: removeFromChannel, isPending: isRemoving } = useMutation({
+        mutationFn: removeMutationFn,
+        onSuccess: () => {
+            toast.success(t`Successfully removed product from channel`);
+            queryClient.invalidateQueries({ queryKey: ['DetailPage', 'product', { id: entityId }] });
+        },
+        onError: () => {
+            toast.error(t`Failed to remove product from channel`);
+        },
+    });
+
+    async function onRemoveHandler(channelId: string) {
+        if (channelId === activeChannel?.id) {
+            toast.error(t`Cannot remove from active channel`);
+            return;
+        }
+        removeFromChannel({
+            input: {
+                productIds: [entityId],
+                channelId,
+            },
+        });
+    }
+
+    const handleAssignSuccess = () => {
+        queryClient.invalidateQueries({ queryKey: ['DetailPage', 'product', { id: entityId }] });
+        setAssignDialogOpen(false);
+    };
+
+    // Only show add button if there are more channels available
+    const availableChannels = allChannels.filter(ch => !channels.map(c => c.id).includes(ch.id));
+    const showAddButton = canUpdate && availableChannels.length > 0;
+
+    return (
+        <>
+            <div className="flex flex-wrap gap-1 mb-2">
+                {channels.filter(c => c.code !== DEFAULT_CHANNEL_CODE).map((channel: SimpleChannel) => {
+                    return (
+                        <ChannelChip key={channel.id} channel={channel} removable={canUpdate && channel.id !== activeChannel?.id} onRemove={onRemoveHandler} />
+                    );
+                })}
+            </div>
+            {showAddButton && (
+                <>
+                    <Button
+                        type="button"
+                        variant="outline"
+                        size="sm"
+                        onClick={() => setAssignDialogOpen(true)}
+                        disabled={isRemoving}
+                    >
+                        <Plus className="h-4 w-4 mr-1" />
+                        <Trans>Assign to channel</Trans>
+                    </Button>
+                    <AssignToChannelDialog
+                        entityType="product"
+                        open={assignDialogOpen}
+                        onOpenChange={setAssignDialogOpen}
+                        entityIds={[entityId]}
+                        mutationFn={assignMutationFn}
+                        onSuccess={handleAssignSuccess}
+                        buildInput={(channelId: string) => ({
+                            productIds: [entityId],
+                            channelId,
+                            priceFactor,
+                        })}
+                        additionalFields={priceFactorField}
+                    />
+                </>
+            )}
+        </>
+    );
+}

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

@@ -18,17 +18,15 @@ interface AssignedFacetValuesProps {
     value?: string[] | null;
     facetValues: FacetValue[];
     canUpdate?: boolean;
-    onBlur?: () => void;
     onChange?: (value: string[]) => void;
 }
 
 export function AssignedFacetValues({
-                                        value = [],
-                                        facetValues,
-                                        canUpdate = true,
-                                        onBlur,
-                                        onChange,
-                                    }: AssignedFacetValuesProps) {
+    value = [],
+    facetValues,
+    canUpdate = true,
+    onChange,
+}: Readonly<AssignedFacetValuesProps>) {
     const [knownFacetValues, setKnownFacetValues] = useState<FacetValue[]>(facetValues);
 
     function onSelectHandler(facetValue: FacetValue) {

+ 43 - 0
packages/dashboard/src/lib/components/shared/channel-chip.tsx

@@ -0,0 +1,43 @@
+import { Badge } from '@/vdb/components/ui/badge.js';
+import { X } from 'lucide-react';
+import type { SimpleChannel } from '@/vdb/providers/channel-provider.js';
+
+interface ChannelChipProps {
+    channel: SimpleChannel;
+    removable?: boolean;
+    onRemove?: (id: string) => void;
+}
+
+/**
+ * @description
+ * A component for displaying a channel as a chip.
+ *
+ * @docsCategory components
+ * @since 3.5.2
+ */
+export function ChannelChip({
+    channel,
+    removable = true,
+    onRemove,
+}: Readonly<ChannelChipProps>) {
+    return (
+        <Badge
+            variant="secondary"
+            className="flex items-center gap-2 py-0.5 pl-2 pr-1 h-6 hover:bg-secondary/80"
+        >
+            <div className="flex items-center gap-1.5">
+                <span className="font-medium">{channel.code}</span>
+            </div>
+            {removable && (
+                <button
+                    type="button"
+                    className="ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full hover:bg-muted/30 hover:cursor-pointer"
+                    onClick={() => onRemove?.(channel.id)}
+                    aria-label={`Remove ${channel.code} from ${channel.token}`}
+                >
+                    <X className="h-3 w-3" />
+                </button>
+            )}
+        </Badge>
+    );
+}

+ 7 - 1
packages/dashboard/src/lib/providers/channel-provider.tsx

@@ -51,7 +51,13 @@ const channelsDocument = graphql(
 
 // Define the type for a channel
 type ActiveChannel = ResultOf<typeof activeChannelDocument>['activeChannel'];
-type Channel = ResultOf<typeof channelFragment>;
+export type Channel = ResultOf<typeof channelFragment>;
+
+/**
+ * Simplified channel type with only the basic fields (id, code, token)
+ * Used in components that don't need the full channel information
+ */
+export type SimpleChannel = Pick<Channel, 'id' | 'code' | 'token'>;
 
 /**
  * @description