From e915169e111cc8f090bbc1f85344e93721a6b7ec Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Fri, 5 Apr 2024 11:40:33 +0200 Subject: [PATCH] feat(dashboard): Edit variant options + create option callback for combobox (#6920) --- .../components/common/combobox/combobox.tsx | 347 +++++++++--------- .../common/country-select/country-select.tsx | 19 +- .../product-edit-variant-form.tsx | 106 +++++- 3 files changed, 296 insertions(+), 176 deletions(-) diff --git a/packages/admin-next/dashboard/src/components/common/combobox/combobox.tsx b/packages/admin-next/dashboard/src/components/common/combobox/combobox.tsx index 1a51bc95e6..5a3fe0499d 100644 --- a/packages/admin-next/dashboard/src/components/common/combobox/combobox.tsx +++ b/packages/admin-next/dashboard/src/components/common/combobox/combobox.tsx @@ -1,19 +1,27 @@ import { Combobox as PrimitiveCombobox, + ComboboxDisclosure as PrimitiveComboboxDisclosure, ComboboxItem as PrimitiveComboboxItem, ComboboxItemCheck as PrimitiveComboboxItemCheck, ComboboxItemValue as PrimitiveComboboxItemValue, - ComboboxList as PrimitiveComboboxList, + ComboboxPopover as PrimitiveComboboxPopover, ComboboxProvider as PrimitiveComboboxProvider, + Separator as PrimitiveSeparator, } from "@ariakit/react" -import { EllipseMiniSolid, TrianglesMini, XMarkMini } from "@medusajs/icons" +import { + EllipseMiniSolid, + PlusMini, + TrianglesMini, + XMarkMini, +} from "@medusajs/icons" import { Text, clx } from "@medusajs/ui" -import * as Popover from "@radix-ui/react-popover" import { matchSorter } from "match-sorter" import { ComponentPropsWithoutRef, ForwardedRef, + Fragment, useCallback, + useDeferredValue, useImperativeHandle, useMemo, useRef, @@ -21,6 +29,7 @@ import { useTransition, } from "react" import { useTranslation } from "react-i18next" + import { genericForwardRef } from "../generic-forward-ref" type ComboboxOption = { @@ -39,6 +48,7 @@ interface ComboboxProps options: ComboboxOption[] fetchNextPage?: () => void isFetchingNextPage?: boolean + onCreateOption?: (value: string) => void } const ComboboxImpl = ( @@ -52,6 +62,7 @@ const ComboboxImpl = ( placeholder, fetchNextPage, isFetchingNextPage, + onCreateOption, ...inputProps }: ComboboxProps, ref: ForwardedRef @@ -74,6 +85,8 @@ const ComboboxImpl = ( const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState( controlledSearchValue || "" ) + const defferedSearchValue = useDeferredValue(uncontrolledSearchValue) + const [uncontrolledValue, setUncontrolledValue] = useState(emptyState) const searchValue = isSearchControlled @@ -82,15 +95,25 @@ const ComboboxImpl = ( const selectedValues = isValueControlled ? controlledValue : uncontrolledValue const handleValueChange = (newValues?: T) => { - if (!isArrayValue) { - const label = options.find((o) => o.value === newValues)?.label + // check if the value already exists in options + const exists = options.find((o) => { + if (isArrayValue) { + return newValues?.includes(o.value) + } - setUncontrolledSearchValue(label || "") + return o.value === newValues + }) + + // If the value does not exist in the options, and the component has a handler + // for creating new options, call it. + if (!exists && onCreateOption && newValues) { + onCreateOption(newValues as string) } if (!isValueControlled) { setUncontrolledValue(newValues || emptyState) } + if (onChange) { onChange(newValues) } @@ -117,28 +140,10 @@ const ComboboxImpl = ( return [] } - return matchSorter(options, uncontrolledSearchValue, { + return matchSorter(options, defferedSearchValue, { keys: ["label"], - baseSort: (a, b) => { - const aIndex = selectedValues.indexOf(a.item.value) - const bIndex = selectedValues.indexOf(b.item.value) - - if (aIndex === -1 && bIndex === -1) { - return 0 - } - - if (aIndex === -1) { - return 1 - } - - if (bIndex === -1) { - return -1 - } - - return aIndex - bIndex - }, }) - }, [options, uncontrolledSearchValue, selectedValues, isSearchControlled]) + }, [options, defferedSearchValue, isSearchControlled]) const observer = useRef( new IntersectionObserver( @@ -167,6 +172,14 @@ const ComboboxImpl = ( [isFetchingNextPage] ) + const handleOpenChange = (open: boolean) => { + if (!open) { + setUncontrolledSearchValue("") + } + + setOpen(open) + } + const hasValue = selectedValues.length > 0 const showTag = hasValue && isArrayValue @@ -177,161 +190,153 @@ const ComboboxImpl = ( const hidePlaceholder = showSelected || open - const results = isSearchControlled ? options : matches + const results = useMemo(() => { + return isSearchControlled ? options : matches + }, [matches, options, isSearchControlled]) return ( - - handleValueChange(value as T)} - value={uncontrolledSearchValue} - setValue={(query) => { - startTransition(() => handleSearchChange(query)) - }} + handleValueChange(value as T)} + value={uncontrolledSearchValue} + setValue={(query) => { + startTransition(() => handleSearchChange(query)) + }} + > +
- -
- {showTag && ( -
- {selectedValues.length} - -
- )} -
- {showSelected && ( - - {t("general.selected")} - - )} - {hideInput && ( -
- - {selectedLabel} - -
- )} - -
+ {showTag && ( +
+ {selectedValues.length}
- - - event.preventDefault()} - onInteractOutside={(event) => { - const target = event.target as Element | null - const isCombobox = target === comboboxRef.current - const inListbox = target && listboxRef.current?.contains(target) - - if (isCombobox || inListbox) { - event.preventDefault() + )} +
+ {showSelected && ( + + {t("general.selected")} + + )} + {hideInput && ( +
+ + {selectedLabel} + +
+ )} + - - {results.map(({ value, label }) => ( - - - - - - {label} - - - ))} - {!!fetchNextPage &&
} - {isFetchingNextPage && ( -
-
-
- )} - {!results.length && ( -
- - {t("general.noResultsTitle")} - -
- )} - - - - - {open && ( - + { + return ( + + ) + }} /> - )} - +
+ + {results.map(({ value, label }) => ( + + + + + + {label} + + + ))} + {!!fetchNextPage &&
} + {isFetchingNextPage && ( +
+
+
+ )} + {!results.length && ( +
+ + {t("general.noResultsTitle")} + +
+ )} + {!results.length && onCreateOption && ( + + + + + + {t("actions.create")} "{searchValue}" + + + + )} + + ) } diff --git a/packages/admin-next/dashboard/src/components/common/country-select/country-select.tsx b/packages/admin-next/dashboard/src/components/common/country-select/country-select.tsx index be27ccf5dc..99c23e148d 100644 --- a/packages/admin-next/dashboard/src/components/common/country-select/country-select.tsx +++ b/packages/admin-next/dashboard/src/components/common/country-select/country-select.tsx @@ -1,4 +1,9 @@ -import { ComponentPropsWithoutRef, forwardRef } from "react" +import { + ComponentPropsWithoutRef, + forwardRef, + useImperativeHandle, + useRef, +} from "react" import { TrianglesMini } from "@medusajs/icons" import { clx } from "@medusajs/ui" @@ -10,6 +15,11 @@ export const CountrySelect = forwardRef< ComponentPropsWithoutRef<"select"> & { placeholder?: string } >(({ className, disabled, placeholder, ...props }, ref) => { const { t } = useTranslation() + const innerRef = useRef(null) + + useImperativeHandle(ref, () => innerRef.current as HTMLSelectElement) + + const isPlaceholder = innerRef.current?.value === "" return (
@@ -31,13 +41,16 @@ export const CountrySelect = forwardRef< "aria-[invalid=true]:border-ui-border-error aria-[invalid=true]:shadow-borders-error", "invalid::border-ui-border-error invalid:shadow-borders-error", "disabled:!bg-ui-bg-disabled disabled:!text-ui-fg-disabled", + { + "text-ui-fg-muted": isPlaceholder, + }, className )} {...props} - ref={ref} + ref={innerRef} > {/* Add an empty option so the first option is preselected */} - {countries.map((country) => { diff --git a/packages/admin-next/dashboard/src/routes/products/product-edit-variant/components/product-edit-variant-form/product-edit-variant-form.tsx b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/components/product-edit-variant-form/product-edit-variant-form.tsx index 08bea58e37..603051d09e 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-edit-variant/components/product-edit-variant-form/product-edit-variant-form.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/components/product-edit-variant-form/product-edit-variant-form.tsx @@ -1,12 +1,13 @@ import { zodResolver } from "@hookform/resolvers/zod" -import { Product, ProductVariant } from "@medusajs/medusa" +import { Product, ProductOption, ProductVariant } from "@medusajs/medusa" import { Button, Heading, Input, Switch } from "@medusajs/ui" import { useAdminUpdateVariant } from "medusa-react" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" import { z } from "zod" -import { Fragment } from "react" +import { Fragment, useState } from "react" +import { Combobox } from "../../../../../components/common/combobox" import { CountrySelect } from "../../../../../components/common/country-select" import { Divider } from "../../../../../components/common/divider" import { Form } from "../../../../../components/common/form" @@ -40,6 +41,11 @@ const ProductEditVariantSchema = z.object({ mid_code: z.string().optional(), hs_code: z.string().optional(), origin_country: z.string().optional(), + options: z.record( + z.object({ + value: z.string().min(1), + }) + ), }) export const ProductEditVariantForm = ({ @@ -47,6 +53,10 @@ export const ProductEditVariantForm = ({ variant, isStockAndInventoryEnabled = false, }: ProductEditVariantFormProps) => { + const [optionValues, setOptionValues] = useState>( + initOptionValues(product) + ) + const { t } = useTranslation() const { handleSuccess } = useRouteModal() @@ -68,6 +78,7 @@ export const ProductEditVariantForm = ({ mid_code: variant.mid_code || "", hs_code: variant.hs_code || "", origin_country: variant.origin_country || "", + options: getDefaultOptionValues(product, variant), }, resolver: zodResolver(ProductEditVariantSchema), }) @@ -99,6 +110,7 @@ export const ProductEditVariantForm = ({ ean, upc, barcode, + options, ...rest } = data @@ -119,6 +131,13 @@ export const ProductEditVariantForm = ({ } : {} + const optionsPayload = Object.entries(options).map(([key, value]) => { + return { + option_id: key, + value: value.value, + } + }) + await mutateAsync( { variant_id: variant.id, @@ -126,6 +145,7 @@ export const ProductEditVariantForm = ({ height: parseNumber(height), width: parseNumber(width), length: parseNumber(length), + options: optionsPayload, ...conditionalPayload, ...rest, }, @@ -137,6 +157,18 @@ export const ProductEditVariantForm = ({ ) }) + const handleCreateOption = (optionId: string) => { + return (value: string) => { + setOptionValues((prev) => { + const values = prev[optionId] || [] + return { + ...prev, + [optionId]: [...values, value], + } + }) + } + } + return (
+ {product.options.map((option) => { + return ( + { + const options = optionValues[option.id].map((value) => ({ + label: value, + value, + })) + + return ( + + {option.title} + + { + onChange({ value: v }) + }} + onCreateOption={handleCreateOption(option.id)} + {...field} + options={options} + /> + + + ) + }} + /> + ) + })}
{!isStockAndInventoryEnabled && ( @@ -463,3 +527,41 @@ export const ProductEditVariantForm = ({ ) } + +/* eslint-disable prettier/prettier */ +const getDefaultOptionValues = (product: Product, variant: ProductVariant) => { + const opts = variant.options + + return product.options.reduce( + (acc, option) => { + const variantOption = opts.find((o) => o.option_id === option.id) + + acc[option.id] = { + value: variantOption?.value || "", + } + return acc + }, + {} as Record + ) +} + +const getOptionValues = (option: ProductOption) => { + const values = option.values.map((value) => value.value) + + const filteredValues = values.filter((v, i) => values.indexOf(v) === i) + + return filteredValues.map((value) => value) +} + +const initOptionValues = (product: Product) => { + return product.options.reduce( + (acc, option) => { + const values = getOptionValues(option) + + acc[option.id] = values + return acc + }, + {} as Record + ) +} +/* eslint-enable prettier/prettier */