Explorar el Código

feat(dashboard): Integrate facet value selection and UI components

Michael Bromley hace 10 meses
padre
commit
7659fc805b

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 508 - 46
package-lock.json


+ 3 - 0
packages/dashboard/package.json

@@ -35,6 +35,8 @@
     "@radix-ui/react-dialog": "^1.1.6",
     "@radix-ui/react-dropdown-menu": "^2.1.6",
     "@radix-ui/react-label": "^2.1.2",
+    "@radix-ui/react-popover": "^1.1.6",
+    "@radix-ui/react-scroll-area": "^1.2.3",
     "@radix-ui/react-select": "^2.1.6",
     "@radix-ui/react-separator": "^1.1.2",
     "@radix-ui/react-slot": "^1.1.2",
@@ -47,6 +49,7 @@
     "@types/node": "^22.13.4",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
+    "cmdk": "^1.0.0",
     "gql.tada": "^1.8.10",
     "graphql": "~16.10.0",
     "graphql-request": "^7.1.2",

+ 17 - 49
packages/dashboard/src/components/shared/assigned-facet-values.tsx

@@ -1,9 +1,6 @@
-import { Button } from '@/components/ui/button.js';
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
-import { Plus, X } from 'lucide-react';
-import { Badge } from '@/components/ui/badge.js';
-import { Trans } from '@lingui/react/macro';
 import { useState } from 'react';
+import { FacetValueChip } from './facet-value-chip.js';
+import { FacetValueSelector } from './facet-value-selector.js';
 
 // Interface for facet value type
 interface FacetValue {
@@ -17,41 +14,12 @@ interface FacetValue {
     };
 }
 
-interface FacetValueChipProps {
-    facetValue: FacetValue;
-    removable?: boolean;
-    onRemove?: (id: string) => void;
-}
-
-// Individual facet value chip component
-function FacetValueChip({ facetValue, removable = true, onRemove }: FacetValueChipProps) {
-    const fullText = `${facetValue.facet.name}: ${facetValue.name}`;
-
-    return (
-        <Badge variant="outline" className="mr-2 mb-2 flex items-center gap-1">
-            <span className="max-w-[200px] truncate" title={fullText}>
-                {fullText}
-            </span>
-            {removable && (
-                <button
-                    type="button"
-                    className="inline-flex h-4 w-4 items-center justify-center rounded-full hover:bg-muted/20"
-                    onClick={() => onRemove?.(facetValue.id)}
-                    aria-label={`Remove ${facetValue.facet.name}: ${facetValue.name}`}
-                >
-                    <X className="h-3 w-3" />
-                </button>
-            )}
-        </Badge>
-    );
-}
-
 interface AssignedFacetValuesProps {
+    value?: string[];
     facetValues: FacetValue[];
-    value: id[];
     canUpdate?: boolean;
     onBlur?: () => void;
-    onChange?: (value: FacetValue[]) => void;
+    onChange?: (value: string[]) => void;
 }
 
 export function AssignedFacetValues({
@@ -61,19 +29,22 @@ export function AssignedFacetValues({
     onBlur,
     onChange,
 }: AssignedFacetValuesProps) {
-    function onRemoveHandler(id: string) {
-        onChange?.(value.filter(fvId => fvId !== id));
+    const [knownFacetValues, setKnownFacetValues] = useState<FacetValue[]>(facetValues);
+    
+    function onSelectHandler(facetValue: FacetValue) {
+        setKnownFacetValues(prev => [...prev, facetValue]);
+        onChange?.([...new Set([...(value ?? []), facetValue.id])]);
     }
-
-    function onAddHandler(id: string) {
-        // onChange?.([...value, id]);
+    
+    function onRemoveHandler(id: string) {
+        onChange?.(value?.filter(fvId => fvId !== id) ?? []);
     }
 
     return (
         <>
             <div className="flex flex-wrap">
-                {value.map(id => {
-                    const facetValue = facetValues.find(fv => fv.id === id);
+                {(value ?? []).map(id => {
+                    const facetValue = knownFacetValues.find(fv => fv.id === id);
                     if (!facetValue) return null;
                     return (
                         <FacetValueChip
@@ -86,12 +57,9 @@ export function AssignedFacetValues({
                 })}
             </div>
             {canUpdate && (
-                <div>
-                    <Button variant="outline" size="sm" className="mt-2" onClick={onAddHandler}>
-                        <Plus className="h-4 w-4 mr-1" />
-                        <Trans>Add facets</Trans>
-                    </Button>
-                </div>
+                <FacetValueSelector
+                    onValueSelect={onSelectHandler}
+                />
             )}
         </>
     );

+ 44 - 0
packages/dashboard/src/components/shared/facet-value-chip.tsx

@@ -0,0 +1,44 @@
+import { Badge } from '@/components/ui/badge.js';
+import { X } from 'lucide-react';
+
+// Interface for facet value type
+interface FacetValue {
+    id: string;
+    name: string;
+    code: string;
+    facet: {
+        id: string;
+        name: string;
+        code: string;
+    };
+}
+
+interface FacetValueChipProps {
+    facetValue: FacetValue;
+    removable?: boolean;
+    onRemove?: (id: string) => void;
+}
+
+export function FacetValueChip({ facetValue, removable = true, onRemove }: FacetValueChipProps) {
+    return (
+        <Badge 
+            variant="secondary"
+            className="mr-2 mb-2 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">{facetValue.name}</span>
+                <span className="text-muted-foreground text-xs">in {facetValue.facet.name}</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"
+                    onClick={() => onRemove?.(facetValue.id)}
+                    aria-label={`Remove ${facetValue.name} from ${facetValue.facet.name}`}
+                >
+                    <X className="h-3 w-3" />
+                </button>
+            )}
+        </Badge>
+    );
+} 

+ 312 - 0
packages/dashboard/src/components/shared/facet-value-selector.tsx

@@ -0,0 +1,312 @@
+import React, { useState } from 'react';
+import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command.js';
+import { Button } from '@/components/ui/button.js';
+import { Trans } from '@lingui/react/macro';
+import { graphql } from '@/graphql/graphql.js';
+import { useDebounce } from '@/hooks/use-debounce.js';
+import { api } from '@/graphql/api.js';
+import { cn } from '@/lib/utils.js';
+import { Check, ChevronRight, Plus, Loader2 } from 'lucide-react';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover.js';
+
+interface FacetValue {
+    id: string;
+    name: string;
+    code: string;
+    facet: {
+        id: string;
+        name: string;
+        code: string;
+    };
+}
+
+interface Facet {
+    id: string;
+    name: string;
+    code: string;
+}
+
+interface FacetValueSelectorProps {
+    onValueSelect: (value: FacetValue) => void;
+    disabled?: boolean;
+    placeholder?: string;
+    pageSize?: number;
+}
+
+const getFacetValueListDocument = graphql(`
+    query GetFacetValueList($options: FacetValueListOptions) {
+        facetValues(options: $options) {
+            items {
+                id
+                name
+                code
+                facet {
+                    id
+                    name
+                    code
+                }
+            }
+            totalItems
+        }
+    }
+`);
+
+const getFacetListDocument = graphql(`
+    query GetFacetList($options: FacetListOptions) {
+        facets(options: $options) {
+            items {
+                id
+                name
+                code
+            }
+            totalItems
+        }
+    }
+`);
+
+const getFacetValuesForFacetDocument = graphql(`
+    query GetFacetValuesForFacet($options: FacetValueListOptions) {
+        facetValues(options: $options) {
+            items {
+                id
+                name
+                code
+                facet {
+                    id
+                    name
+                    code
+                }
+            }
+            totalItems
+        }
+    }
+`);
+
+export function FacetValueSelector({
+    onValueSelect,
+    disabled,
+    placeholder = 'Search facet values...',
+    pageSize = 4,
+}: FacetValueSelectorProps) {
+    const [open, setOpen] = useState(false);
+    const [searchTerm, setSearchTerm] = useState('');
+    const [expandedFacetId, setExpandedFacetId] = useState<string | null>(null);
+    const debouncedSearch = useDebounce(searchTerm, 200);
+
+    // Query for facet values based on search
+    const { data: facetValueData, isLoading: isLoadingFacetValues } = useQuery({
+        queryKey: ['facetValues', debouncedSearch],
+        queryFn: () => {
+            if (debouncedSearch.length < 2) {
+                return { facetValues: { items: [], totalItems: 0 } };
+            }
+            return api.query(getFacetValueListDocument, {
+                options: {
+                    filter: {
+                        name: { contains: debouncedSearch }
+                    },
+                    take: 100
+                }
+            });
+        },
+        enabled: debouncedSearch.length >= 2 && !expandedFacetId
+    });
+
+    // Query for facets based on search
+    const { data: facetData, isLoading: isLoadingFacets } = useQuery({
+        queryKey: ['facets', debouncedSearch],
+        queryFn: () => {
+            if (debouncedSearch.length < 2) {
+                return { facets: { items: [], totalItems: 0 } };
+            }
+            return api.query(getFacetListDocument, {
+                options: {
+                    filter: {
+                        name: { contains: debouncedSearch }
+                    },
+                    take: 100
+                }
+            });
+        },
+        enabled: debouncedSearch.length >= 2 && !expandedFacetId
+    });
+
+    // Query for paginated values of a specific facet when expanded
+    const {
+        data: expandedFacetData,
+        isLoading: isLoadingExpandedFacet,
+        fetchNextPage,
+        hasNextPage,
+        isFetchingNextPage
+    } = useInfiniteQuery({
+        queryKey: ['facetValues', expandedFacetId, 'infinite'],
+        queryFn: async ({ pageParam = 0 }) => {
+            if (!expandedFacetId) return null;
+            const response = await api.query(getFacetValuesForFacetDocument, {
+                options: {
+                    filter: { facetId: { eq: expandedFacetId } },
+                    sort: { code: 'ASC' },
+                    skip: pageParam * pageSize,
+                    take: pageSize,
+                }
+            });
+            return response.facetValues;
+        },
+        getNextPageParam: (lastPage, allPages) => {
+            if (!lastPage) return undefined;
+            const totalFetched = allPages.length * pageSize;
+            return totalFetched < lastPage.totalItems ? allPages.length : undefined;
+        },
+        enabled: !!expandedFacetId,
+        initialPageParam: 0,
+    });
+
+    const facetValues = facetValueData?.facetValues.items ?? [];
+    const facets = facetData?.facets.items ?? [];
+    const expandedFacetValues = expandedFacetData?.pages.flatMap(page => page?.items ?? []) ?? [];
+    const expandedFacetName = expandedFacetValues[0]?.facet.name;
+
+    // Group facet values by facet
+    const facetGroups = facetValues.reduce<Record<string, FacetValue[]>>((groups: Record<string, FacetValue[]>, facetValue: FacetValue) => {
+        const facetId = facetValue.facet.id;
+        if (!groups[facetId]) {
+            groups[facetId] = [];
+        }
+        groups[facetId].push(facetValue);
+        return groups;
+    }, {});
+
+    const isLoading = isLoadingFacetValues || isLoadingFacets || isLoadingExpandedFacet;
+
+    const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
+        const target = e.currentTarget;
+        const scrolledToBottom = Math.abs(
+            target.scrollHeight - target.clientHeight - target.scrollTop
+        ) < 1;
+
+        if (scrolledToBottom && hasNextPage && !isFetchingNextPage) {
+            console.log('Fetching next page...');
+            fetchNextPage();
+        }
+    };
+
+    return (
+        <Popover open={open} onOpenChange={setOpen}>
+            <PopoverTrigger asChild>
+                <Button 
+                    variant="outline" 
+                    size="sm" 
+                    type="button"
+                    disabled={disabled}
+                    className="gap-2"
+                >
+                    <Plus className="h-4 w-4" />
+                    <Trans>Add facet values</Trans>
+                </Button>
+            </PopoverTrigger>
+            <PopoverContent className="p-0 w-[400px]" align="start">
+                <Command shouldFilter={false}>
+                    <CommandInput 
+                        placeholder={placeholder} 
+                        value={searchTerm}
+                        onValueChange={(value) => {
+                            setSearchTerm(value);
+                            setExpandedFacetId(null);
+                        }}
+                        disabled={disabled}
+                    />
+                    <CommandList 
+                        className="h-[200px] overflow-y-auto" 
+                        onScroll={handleScroll}
+                    >
+                        <CommandEmpty>
+                            {debouncedSearch.length < 2 ? (
+                                <Trans>Type at least 2 characters to search...</Trans>
+                            ) : isLoading ? (
+                                <Trans>Loading...</Trans>
+                            ) : (
+                                <Trans>No results found</Trans>
+                            )}
+                        </CommandEmpty>
+                        
+                        {expandedFacetId ? (
+                            <>
+                                <CommandGroup>
+                                    <CommandItem
+                                        onSelect={() => setExpandedFacetId(null)}
+                                        className="cursor-pointer"
+                                    >
+                                        ← <Trans>Back to search</Trans>
+                                    </CommandItem>
+                                </CommandGroup>
+                                <CommandGroup heading={expandedFacetName}>
+                                    {expandedFacetValues.map((facetValue) => (
+                                        <CommandItem
+                                            key={facetValue.id}
+                                            value={facetValue.id}
+                                            onSelect={() => {
+                                                onValueSelect(facetValue);
+                                                setSearchTerm('');
+                                                setExpandedFacetId(null);
+                                                setOpen(false);
+                                            }}
+                                        >
+                                            {facetValue.name}
+                                        </CommandItem>
+                                    ))}
+                                </CommandGroup>
+                                {(isFetchingNextPage || isLoadingExpandedFacet) && (
+                                    <div className="flex items-center justify-center py-2">
+                                        <Loader2 className="h-4 w-4 animate-spin" />
+                                    </div>
+                                )}
+                                {!hasNextPage && expandedFacetValues.length > 0 && (
+                                    <div className="text-center py-2 text-sm text-muted-foreground">
+                                        <Trans>No more items</Trans>
+                                    </div>
+                                )}
+                            </>
+                        ) : (
+                            <>
+                                {facets.length > 0 && (
+                                    <CommandGroup heading={<Trans>Facets</Trans>}>
+                                        {facets.map((facet) => (
+                                            <CommandItem
+                                                key={facet.id}
+                                                value={`facet-${facet.id}`}
+                                                onSelect={() => setExpandedFacetId(facet.id)}
+                                                className="cursor-pointer"
+                                            >
+                                                <span className="flex-1">{facet.name}</span>
+                                                <ChevronRight className="h-4 w-4" />
+                                            </CommandItem>
+                                        ))}
+                                    </CommandGroup>
+                                )}
+                                
+                                {Object.entries(facetGroups).map(([facetId, values]: [string, FacetValue[]]) => (
+                                    <CommandGroup key={facetId} heading={values[0]?.facet.name}>
+                                        {values.map((facetValue: FacetValue) => (
+                                            <CommandItem
+                                                key={facetValue.id}
+                                                value={facetValue.id}
+                                                onSelect={() => {
+                                                    onValueSelect(facetValue);
+                                                    setSearchTerm('');
+                                                    setOpen(false);
+                                                }}
+                                            >
+                                                {facetValue.name}
+                                            </CommandItem>
+                                        ))}
+                                    </CommandGroup>
+                                ))}
+                            </>
+                        )}
+                    </CommandList>
+                </Command>
+            </PopoverContent>
+        </Popover>
+    );
+}

+ 0 - 0
packages/dashboard/src/components/shared/rich-text-editor.tsx


+ 175 - 0
packages/dashboard/src/components/ui/command.tsx

@@ -0,0 +1,175 @@
+import * as React from "react"
+import { Command as CommandPrimitive } from "cmdk"
+import { SearchIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog"
+
+function Command({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive>) {
+  return (
+    <CommandPrimitive
+      data-slot="command"
+      className={cn(
+        "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CommandDialog({
+  title = "Command Palette",
+  description = "Search for a command to run...",
+  children,
+  ...props
+}: React.ComponentProps<typeof Dialog> & {
+  title?: string
+  description?: string
+}) {
+  return (
+    <Dialog {...props}>
+      <DialogHeader className="sr-only">
+        <DialogTitle>{title}</DialogTitle>
+        <DialogDescription>{description}</DialogDescription>
+      </DialogHeader>
+      <DialogContent className="overflow-hidden p-0">
+        <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
+          {children}
+        </Command>
+      </DialogContent>
+    </Dialog>
+  )
+}
+
+function CommandInput({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Input>) {
+  return (
+    <div
+      data-slot="command-input-wrapper"
+      className="flex h-9 items-center gap-2 border-b px-3"
+    >
+      <SearchIcon className="size-4 shrink-0 opacity-50" />
+      <CommandPrimitive.Input
+        data-slot="command-input"
+        className={cn(
+          "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
+          className
+        )}
+        {...props}
+      />
+    </div>
+  )
+}
+
+function CommandList({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.List>) {
+  return (
+    <CommandPrimitive.List
+      data-slot="command-list"
+      className={cn(
+        "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CommandEmpty({
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
+  return (
+    <CommandPrimitive.Empty
+      data-slot="command-empty"
+      className="py-6 text-center text-sm"
+      {...props}
+    />
+  )
+}
+
+function CommandGroup({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Group>) {
+  return (
+    <CommandPrimitive.Group
+      data-slot="command-group"
+      className={cn(
+        "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CommandSeparator({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
+  return (
+    <CommandPrimitive.Separator
+      data-slot="command-separator"
+      className={cn("bg-border -mx-1 h-px", className)}
+      {...props}
+    />
+  )
+}
+
+function CommandItem({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Item>) {
+  return (
+    <CommandPrimitive.Item
+      data-slot="command-item"
+      className={cn(
+        "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CommandShortcut({
+  className,
+  ...props
+}: React.ComponentProps<"span">) {
+  return (
+    <span
+      data-slot="command-shortcut"
+      className={cn(
+        "text-muted-foreground ml-auto text-xs tracking-widest",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export {
+  Command,
+  CommandDialog,
+  CommandInput,
+  CommandList,
+  CommandEmpty,
+  CommandGroup,
+  CommandItem,
+  CommandShortcut,
+  CommandSeparator,
+}

+ 46 - 0
packages/dashboard/src/components/ui/popover.tsx

@@ -0,0 +1,46 @@
+import * as React from "react"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+
+import { cn } from "@/lib/utils"
+
+function Popover({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
+  return <PopoverPrimitive.Root data-slot="popover" {...props} />
+}
+
+function PopoverTrigger({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
+  return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
+}
+
+function PopoverContent({
+  className,
+  align = "center",
+  sideOffset = 4,
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
+  return (
+    <PopoverPrimitive.Portal>
+      <PopoverPrimitive.Content
+        data-slot="popover-content"
+        align={align}
+        sideOffset={sideOffset}
+        className={cn(
+          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
+          className
+        )}
+        {...props}
+      />
+    </PopoverPrimitive.Portal>
+  )
+}
+
+function PopoverAnchor({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
+  return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
+}
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

+ 56 - 0
packages/dashboard/src/components/ui/scroll-area.tsx

@@ -0,0 +1,56 @@
+import * as React from "react"
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
+
+import { cn } from "@/lib/utils"
+
+function ScrollArea({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
+  return (
+    <ScrollAreaPrimitive.Root
+      data-slot="scroll-area"
+      className={cn("relative", className)}
+      {...props}
+    >
+      <ScrollAreaPrimitive.Viewport
+        data-slot="scroll-area-viewport"
+        className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
+      >
+        {children}
+      </ScrollAreaPrimitive.Viewport>
+      <ScrollBar />
+      <ScrollAreaPrimitive.Corner />
+    </ScrollAreaPrimitive.Root>
+  )
+}
+
+function ScrollBar({
+  className,
+  orientation = "vertical",
+  ...props
+}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
+  return (
+    <ScrollAreaPrimitive.ScrollAreaScrollbar
+      data-slot="scroll-area-scrollbar"
+      orientation={orientation}
+      className={cn(
+        "flex touch-none p-px transition-colors select-none",
+        orientation === "vertical" &&
+          "h-full w-2.5 border-l border-l-transparent",
+        orientation === "horizontal" &&
+          "h-2.5 flex-col border-t border-t-transparent",
+        className
+      )}
+      {...props}
+    >
+      <ScrollAreaPrimitive.ScrollAreaThumb
+        data-slot="scroll-area-thumb"
+        className="bg-border relative flex-1 rounded-full"
+      />
+    </ScrollAreaPrimitive.ScrollAreaScrollbar>
+  )
+}
+
+export { ScrollArea, ScrollBar }

+ 17 - 0
packages/dashboard/src/hooks/use-debounce.ts

@@ -0,0 +1,17 @@
+import { useState, useEffect } from 'react';
+
+export function useDebounce<T>(value: T, delay: number): T {
+    const [debouncedValue, setDebouncedValue] = useState<T>(value);
+
+    useEffect(() => {
+        const timer = setTimeout(() => {
+            setDebouncedValue(value);
+        }, delay);
+
+        return () => {
+            clearTimeout(timer);
+        };
+    }, [value, delay]);
+
+    return debouncedValue;
+}

+ 7 - 1
packages/dashboard/src/routes/_authenticated/products_.$id.tsx

@@ -61,6 +61,7 @@ const productDetailFragment = graphql(
                 slug
                 description
             }
+            
             facetValues {
                 id
                 name
@@ -140,6 +141,11 @@ export function ProductDetailPage() {
         },
     });
 
+    // log changes to the form
+    useEffect(() => {
+        console.log(form.getValues());
+    }, [form.getValues()]);
+
     return (
         <DetailPage title={entity?.name ?? ''} route={Route} entity={entity}>
             <Form {...form}>
@@ -259,7 +265,7 @@ export function ProductDetailPage() {
                                                 </FormLabel>
                                                 <FormControl>
                                                     <AssignedFacetValues
-                                                        facetValues={entity.facetValues}
+                                                        facetValues={entity?.facetValues ?? []}
                                                         {...field}
                                                     />
                                                 </FormControl>

+ 101 - 0
packages/dev-server/load-testing/scripts/add-to-cart-perf-benchmark.js

@@ -0,0 +1,101 @@
+// @ts-check
+import { check } from 'k6';
+import { ShopApiRequest } from '../utils/api-request.js';
+
+const searchQuery = new ShopApiRequest('shop/search.graphql');
+const addItemToOrderMutation = new ShopApiRequest('shop/add-to-order.graphql');
+
+export let options = {
+    stages: [{ duration: '2m', target: 1 }],
+};
+
+// export function setup() {
+//     const searchResult = searchQuery.post();
+//     return searchResult.data.search.items;
+// }
+
+/**
+ * Continuously adds random items to a single order for the duration of the test.
+ */
+export default function () {
+    /**
+     * This list is generated from the following query:
+     *
+     * ```
+     * select pv.id
+     * from product_variant pv
+     *          left join public.product p on pv."productId" = p.id
+     * left join stock_level sl on pv.id = sl."productVariantId"
+     * where pv."deletedAt" is null
+     *   and p."deletedAt" is null
+     *   and p.enabled = true
+     *   and pv.enabled = true
+     * and sl."stockOnHand" > 10
+     *
+     * order by pv."createdAt" desc
+     * limit 500
+     * ```
+     * @type {number[]}
+     */
+    const productIds = [
+        21527, 21526, 21513, 21512, 21511, 21510, 21472, 21469, 21463, 21458, 21457, 21426, 21437, 21427,
+        21438, 21412, 21390, 21388, 21364, 21363, 21349, 21346, 21345, 21325, 21327, 21338, 21337, 21320,
+        21333, 21324, 21339, 21321, 21323, 21318, 21335, 21330, 21332, 21331, 21326, 21319, 21336, 21340,
+        21317, 21322, 21329, 21328, 21299, 21294, 21291, 21295, 21306, 21287, 21286, 21285, 21284, 21282,
+        21281, 21280, 21279, 21278, 21270, 21216, 21215, 21197, 21194, 21192, 21191, 21190, 21189, 21188,
+        21187, 21186, 21185, 21184, 21182, 21181, 21179, 21178, 21176, 21175, 21174, 21173, 21172, 21171,
+        21170, 21169, 21165, 21161, 21160, 21159, 21158, 21157, 21153, 21149, 21152, 21146, 21143, 21137,
+        21133, 21132, 21131, 21130, 21129, 21127, 21128, 21126, 21125, 21124, 21122, 21123, 20952, 20947,
+        20886, 20885, 20882, 20857, 20799, 20800, 20797, 20791, 20784, 20778, 20777, 20773, 20769, 20770,
+        20767, 20765, 20762, 20761, 20763, 20758, 20757, 20760, 20755, 20756, 20751, 20752, 20748, 20747,
+        20746, 20745, 20744, 20740, 20738, 20732, 20731, 20730, 20729, 20727, 20726, 20725, 20724, 20723,
+        20722, 20721, 20720, 20719, 20718, 20717, 20716, 20715, 20714, 20713, 20712, 20711, 20710, 20707,
+        20706, 20667, 20664, 20665, 20658, 20659, 20657, 20655, 20577, 20572, 20567, 20564, 20563, 20562,
+        20561, 20560, 20550, 20544, 20539, 20538, 20537, 20536, 20535, 20534, 20531, 20526, 20521, 20517,
+        20472, 20478, 20474, 20470, 20471, 20475, 20468, 20467, 20457, 20456, 20455, 20453, 20452, 20451,
+        20449, 20448, 20447, 20445, 20444, 20443, 20431, 20426, 20417, 20412, 20410, 20409, 20407, 20405,
+        20402, 20398, 20396, 20395, 20393, 20390, 20389, 20386, 20385, 20384, 20383, 20381, 20380, 20379,
+        20378, 20375, 20373, 20371, 20359, 20364, 20368, 20353, 20369, 20360, 20357, 20366, 20362, 20361,
+        20370, 20358, 20365, 20354, 20355, 20363, 20356, 20351, 20333, 20332, 20330, 20326, 20325, 20324,
+        20323, 20321, 20322, 20320, 20313, 20318, 20311, 20316, 20310, 20315, 20169, 20164, 20212, 20203,
+        20198, 20183, 20170, 20166, 20175, 20200, 20173, 20148, 20151, 20152, 20143, 20132, 20129, 20131,
+        20127, 20126, 20114, 20110, 20104, 20103, 20101, 20086, 20088, 20085, 20059, 20062, 20046, 20049,
+        20039, 20032, 20037, 20034, 20033, 20035, 20036, 20028, 20031, 20029, 20027, 20030, 20026, 20023,
+        20022, 20021, 20020, 20024, 20025, 20018, 20017, 20015, 20016, 20019, 20008, 20013, 20010, 20009,
+        20011, 20012, 20005, 20002, 20006, 20003, 20007, 20001, 20000, 19996, 19995, 19993, 19972, 19971,
+        19969, 19947, 19922, 19962, 19927, 19918, 19954, 19952, 19921, 19949, 19950, 19965, 19938, 19968,
+        19936, 19923, 19937, 19966, 19953, 19933, 19935, 19956, 19943, 19944, 19945, 19934, 19957, 19959,
+        19970, 19919, 19929, 19925, 19963, 19961, 19951, 19940, 19920, 19904, 19901, 19911, 19896, 19892,
+        19900, 19895, 19908, 19885, 19905, 19894, 19903, 19889, 19888, 19897, 19902, 19907, 19891, 19906,
+        19898, 19909, 19910, 19886, 19887, 19890, 19912, 19893, 19883, 19874, 19865, 19866, 19864, 19862,
+        19861, 19850, 19830, 19828, 19824, 19826, 19822, 19833, 19823, 19791, 19789, 19790, 19779, 19755,
+        19756, 19733, 17473, 17471, 17424, 17422, 17421, 17420, 17417, 17416, 17415, 17407, 17374, 17373,
+        17371, 17370, 17369, 17368, 17367, 17365, 17364, 17356, 17355, 17318, 17317, 17315, 17303, 17300,
+        17309, 17307, 17304, 17306, 17311, 17294, 17313, 17297, 17261, 17249, 17068, 17043, 16968, 16966,
+        16965, 16964, 16963, 16961, 16958, 16952, 16917, 16911, 16902, 16901, 16900, 16899, 16898, 16897,
+        16896, 16895, 16894, 16892, 16891, 16889, 16887, 16886, 16885, 16884,
+    ];
+
+    const productIds2 = [
+        1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28,
+        29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53,
+        54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78,
+        79, 80,
+    ];
+    for (let i = 0; i < 1000; i++) {
+        addToCart(productIds2[i % productIds2.length]);
+    }
+}
+
+function addToCart(variantId) {
+    const qty = 1;
+    const result = addItemToOrderMutation.post({ id: variantId, qty });
+    check(result.data, {
+        'Product added to cart': r =>
+            !!r.addItemToOrder.lines.find(l => +l.productVariant.id === variantId && l.quantity >= qty),
+    });
+}
+
+function randomItem(items) {
+    return items[Math.floor(Math.random() * items.length)];
+}

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio