feat(admin-ui, medusa-react): product page categories management + nested multiselect (#3401)
* chore: allow products to be categorized in product create/edit page * refactor: cleanup * feat: invalidate product details cache when categories change * fix: update changesets * fix: push ner changeset * feat: limit popup height --------- Co-authored-by: fPolic <frane@medusajs.com> Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
6
.changeset/wild-rabbits-travel.md
Normal file
6
.changeset/wild-rabbits-travel.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"medusa-react": patch
|
||||
"@medusajs/admin-ui": patch
|
||||
---
|
||||
|
||||
feat(admin-ui, medusa-react): allow products to be categorized in product create/edit page
|
||||
@@ -26,7 +26,7 @@ const DelimitedList: React.FC<DelimitedListProps> = ({ list, delimit = 1 }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inter-small-regular">
|
||||
<span className="inter-base-regular text-grey-50">
|
||||
{itemsToDisplay}
|
||||
|
||||
{showExtraItemsInTooltip && (
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import clsx from "clsx"
|
||||
|
||||
import useToggleState from "../../../../hooks/use-toggle-state"
|
||||
import useOutsideClick from "../../../../hooks/use-outside-click"
|
||||
import CheckIcon from "../../../../components/fundamentals/icons/check-icon"
|
||||
import ChevronDownIcon from "../../../../components/fundamentals/icons/chevron-down"
|
||||
import ChevronRightIcon from "../../../../components/fundamentals/icons/chevron-right-icon"
|
||||
import UTurnIcon from "../../../../components/fundamentals/icons/u-turn-icon"
|
||||
import CrossIcon from "../../../../components/fundamentals/icons/cross-icon"
|
||||
import Tooltip from "../../../../components/atoms/tooltip"
|
||||
import { sum } from "lodash"
|
||||
|
||||
/**
|
||||
* Types
|
||||
*/
|
||||
export type NestedMultiselectOption = {
|
||||
value: string
|
||||
label: string
|
||||
children?: NestedMultiselectOption[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected categories count tooltip
|
||||
*/
|
||||
const ToolTipContent = (props: { list: string[] }) => {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{props.list.map((listItem) => (
|
||||
<span key={listItem}>{listItem}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type InputProps = {
|
||||
isOpen: boolean
|
||||
selected: Record<string, true>
|
||||
options: NestedMultiselectOption[]
|
||||
openPopup: () => void
|
||||
resetSelected: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiselect input area
|
||||
*/
|
||||
function Input(props: InputProps) {
|
||||
const { isOpen, selected, openPopup, resetSelected, options } = props
|
||||
const selectedCount = Object.keys(selected).length
|
||||
|
||||
const selectedOption = useMemo(() => {
|
||||
const ret: string[] = []
|
||||
|
||||
const visit = (option: NestedMultiselectOption) => {
|
||||
if (selected[option.value]) {
|
||||
ret.push(option.label)
|
||||
}
|
||||
option.children?.forEach(visit)
|
||||
}
|
||||
|
||||
options.forEach(visit)
|
||||
return ret
|
||||
}, [selected, options])
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={openPopup}
|
||||
className="rounded-rounded border-grey-20 bg-grey-5 px-small focus-within:border-violet-60 focus-within:shadow-cta flex h-10 items-center justify-between border"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{!!selectedCount && (
|
||||
<Tooltip
|
||||
side="top"
|
||||
delayDuration={1500}
|
||||
content={<ToolTipContent list={selectedOption} />}
|
||||
>
|
||||
<span className="rounded-rounded bg-grey-10 text-small flex h-[28px] items-center gap-2 border px-2 font-medium text-gray-500">
|
||||
{selectedCount}
|
||||
<CrossIcon
|
||||
className="cursor-pointer"
|
||||
onClick={resetSelected}
|
||||
size={16}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span>Categories</span>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
size={16}
|
||||
style={{
|
||||
transition: ".2s transform",
|
||||
transform: `rotate(${isOpen ? 180 : 0}deg)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CheckboxProps = { isSelected: boolean }
|
||||
|
||||
/**
|
||||
* List item checkbox
|
||||
*/
|
||||
const Checkbox = ({ isSelected }: CheckboxProps) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
`rounded-base border-grey-30 text-grey-0 flex h-5 w-5 justify-center border`,
|
||||
{
|
||||
"bg-violet-60": isSelected,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span className="self-center">
|
||||
{isSelected && <CheckIcon size={12} />}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PopupItemProps = {
|
||||
isSelected: boolean
|
||||
option: NestedMultiselectOption
|
||||
selectedSubcategoriesCount: number
|
||||
onOptionClick: (option: NestedMultiselectOption) => void
|
||||
onOptionCheckboxClick: (option: NestedMultiselectOption) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Popup list item
|
||||
*/
|
||||
function PopupItem(props: PopupItemProps) {
|
||||
const {
|
||||
option,
|
||||
isSelected,
|
||||
onOptionClick,
|
||||
onOptionCheckboxClick,
|
||||
selectedSubcategoriesCount,
|
||||
} = props
|
||||
|
||||
const hasChildren = !!option.children?.length
|
||||
|
||||
const onClick = (e) => {
|
||||
e.stopPropagation()
|
||||
if (hasChildren) {
|
||||
onOptionClick(option)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={clsx("flex h-[40px] items-center justify-between gap-2 px-3", {
|
||||
"hover:bg-grey-10 cursor-pointer": hasChildren,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOptionCheckboxClick(option)
|
||||
}}
|
||||
>
|
||||
<Checkbox isSelected={isSelected} />
|
||||
</div>
|
||||
{option.label}
|
||||
</div>
|
||||
|
||||
{hasChildren && (
|
||||
<div className="flex items-center gap-2">
|
||||
{!!selectedSubcategoriesCount && (
|
||||
<span className="text-small text-gray-400">
|
||||
{selectedSubcategoriesCount} selected
|
||||
</span>
|
||||
)}
|
||||
<ChevronRightIcon size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PopupProps = {
|
||||
pop: () => void
|
||||
selected: Record<string, true>
|
||||
activeOption: NestedMultiselectOption
|
||||
selectedSubcategoriesCount: Record<string, number>
|
||||
onOptionClick: (option: NestedMultiselectOption) => void
|
||||
onOptionCheckboxClick: (option: NestedMultiselectOption) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Popup menu
|
||||
*/
|
||||
function Popup(props: PopupProps) {
|
||||
const {
|
||||
activeOption,
|
||||
onOptionClick,
|
||||
onOptionCheckboxClick,
|
||||
pop,
|
||||
selected,
|
||||
selectedSubcategoriesCount,
|
||||
} = props
|
||||
|
||||
const showBack = !!activeOption.value
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
top: 8,
|
||||
overflow: "scroll",
|
||||
boxShadow: "0px 2px 16px rgba(0, 0, 0, 0.08)",
|
||||
maxHeight: activeOption.value === null ? 228 : 242,
|
||||
}}
|
||||
className="rounded-rounded relative z-50 w-[100%] border bg-white"
|
||||
>
|
||||
{showBack && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
pop()
|
||||
}}
|
||||
className="border-grey-20 hover:bg-grey-10 mb-1 flex h-[50px] cursor-pointer items-center gap-2 border-b px-3"
|
||||
>
|
||||
<UTurnIcon size={16} />
|
||||
<span className="font-medium">{activeOption.label}</span>
|
||||
</div>
|
||||
)}
|
||||
{activeOption.children!.map((o) => (
|
||||
<PopupItem
|
||||
option={o}
|
||||
isSelected={selected[o.value]}
|
||||
onOptionClick={onOptionClick}
|
||||
onOptionCheckboxClick={onOptionCheckboxClick}
|
||||
selectedSubcategoriesCount={selectedSubcategoriesCount[o.value]}
|
||||
key={o.value}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type NestedMultiselectProps = {
|
||||
options: NestedMultiselectOption[]
|
||||
onSelect: (values: string[]) => void
|
||||
initiallySelected?: Record<string, true>
|
||||
}
|
||||
|
||||
/**
|
||||
* Nested multiselect container
|
||||
*/
|
||||
function NestedMultiselect(props: NestedMultiselectProps) {
|
||||
const { options, initiallySelected, onSelect } = props
|
||||
const [isOpen, openPopup, closePopup] = useToggleState(false)
|
||||
|
||||
const rootRef = React.useRef<HTMLDivElement>(null)
|
||||
useOutsideClick(closePopup, rootRef, true)
|
||||
|
||||
const [activeOption, setActiveOption] = useState<NestedMultiselectOption>({
|
||||
value: null,
|
||||
label: null,
|
||||
children: options,
|
||||
})
|
||||
|
||||
const [selected, setSelected] = useState<Record<string, true>>(
|
||||
initiallySelected || {}
|
||||
)
|
||||
|
||||
const select = (option: NestedMultiselectOption) => {
|
||||
const nextState = { ...selected }
|
||||
nextState[option.value] = true
|
||||
setSelected(nextState)
|
||||
}
|
||||
|
||||
const deselect = (option: NestedMultiselectOption) => {
|
||||
const nextState = { ...selected }
|
||||
delete nextState[option.value]
|
||||
setSelected(nextState)
|
||||
}
|
||||
|
||||
const onOptionCheckboxClick = (option: NestedMultiselectOption) => {
|
||||
if (selected[option.value]) {
|
||||
deselect(option)
|
||||
} else {
|
||||
select(option)
|
||||
}
|
||||
}
|
||||
|
||||
const onOptionClick = (option: NestedMultiselectOption) => {
|
||||
setActiveOption(option)
|
||||
}
|
||||
|
||||
const pop = () => {
|
||||
let parent
|
||||
|
||||
const find = (o: NestedMultiselectOption) => {
|
||||
if (o.children?.some((c) => c.value === activeOption.value)) {
|
||||
parent = o
|
||||
}
|
||||
o.children?.forEach(find)
|
||||
}
|
||||
|
||||
find({ value: null, label: null, children: options })
|
||||
|
||||
if (parent) {
|
||||
setActiveOption(parent)
|
||||
}
|
||||
}
|
||||
|
||||
const resetSelected = () => {
|
||||
setSelected({})
|
||||
closePopup()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setActiveOption({
|
||||
value: null,
|
||||
label: null,
|
||||
children: options,
|
||||
})
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
onSelect(Object.keys(selected))
|
||||
}, [selected])
|
||||
|
||||
const selectedSubcategoriesCount = useMemo(() => {
|
||||
const counts = {}
|
||||
|
||||
const visit = (option: NestedMultiselectOption) => {
|
||||
const numOfSelectedDescendants = sum(option.children?.map(visit))
|
||||
|
||||
counts[option.value] = numOfSelectedDescendants
|
||||
return selected[option.value]
|
||||
? numOfSelectedDescendants + 1
|
||||
: numOfSelectedDescendants
|
||||
}
|
||||
|
||||
options.forEach(visit)
|
||||
|
||||
return counts
|
||||
}, [selected, options])
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className=" h-[40px]">
|
||||
<Input
|
||||
isOpen={isOpen}
|
||||
openPopup={openPopup}
|
||||
resetSelected={resetSelected}
|
||||
selected={selected}
|
||||
options={options}
|
||||
/>
|
||||
{isOpen && (
|
||||
<Popup
|
||||
pop={pop}
|
||||
selected={selected}
|
||||
activeOption={activeOption}
|
||||
onOptionClick={onOptionClick}
|
||||
onOptionCheckboxClick={onOptionCheckboxClick}
|
||||
selectedSubcategoriesCount={selectedSubcategoriesCount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NestedMultiselect
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ProductCategory } from "@medusajs/medusa"
|
||||
|
||||
import { NestedMultiselectOption } from "../components/multiselect"
|
||||
|
||||
export function transformCategoryToNestedFormOptions(
|
||||
category: ProductCategory
|
||||
): NestedMultiselectOption {
|
||||
const children =
|
||||
category.category_children?.map(transformCategoryToNestedFormOptions) || []
|
||||
|
||||
return { value: category.id, label: category.name, children }
|
||||
}
|
||||
@@ -8,11 +8,18 @@ import TagInput from "../../../../components/molecules/tag-input"
|
||||
import { Option } from "../../../../types/shared"
|
||||
import { NestedForm } from "../../../../utils/nested-form"
|
||||
import useOrganizeData from "./use-organize-data"
|
||||
import NestedMultiselect from "../../../categories/components/multiselect"
|
||||
import InputHeader from "../../../../components/fundamentals/input-header"
|
||||
import {
|
||||
useFeatureFlag,
|
||||
FeatureFlag,
|
||||
} from "../../../../providers/feature-flag-provider"
|
||||
|
||||
export type OrganizeFormType = {
|
||||
type: Option | null
|
||||
collection: Option | null
|
||||
tags: string[] | null
|
||||
categories: string[] | null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@@ -21,7 +28,10 @@ type Props = {
|
||||
|
||||
const OrganizeForm = ({ form }: Props) => {
|
||||
const { control, path, setValue } = form
|
||||
const { productTypeOptions, collectionOptions } = useOrganizeData()
|
||||
const { productTypeOptions, collectionOptions, categoriesOptions } =
|
||||
useOrganizeData()
|
||||
|
||||
const { isFeatureEnabled } = useFeatureFlag()
|
||||
|
||||
const typeOptions = productTypeOptions
|
||||
|
||||
@@ -38,7 +48,7 @@ const OrganizeForm = ({ form }: Props) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-x-large mb-large">
|
||||
<div className="mb-large gap-x-large grid grid-cols-2">
|
||||
<Controller
|
||||
name={path("type")}
|
||||
control={control}
|
||||
@@ -73,6 +83,37 @@ const OrganizeForm = ({ form }: Props) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isFeatureEnabled(FeatureFlag.PRODUCT_CATEGORIES) && (
|
||||
<>
|
||||
<InputHeader label="Categories" className="mb-2" />
|
||||
<Controller
|
||||
name={path("categories")}
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
if (!categoriesOptions) {
|
||||
return null
|
||||
}
|
||||
|
||||
const initiallySelected = (value || []).reduce((acc, val) => {
|
||||
acc[val] = true
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<NestedMultiselect
|
||||
onSelect={onChange}
|
||||
options={categoriesOptions}
|
||||
initiallySelected={initiallySelected}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mb-large" />
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name={path("tags")}
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { useAdminCollections, useAdminProductTypes } from "medusa-react"
|
||||
import { useMemo } from "react"
|
||||
|
||||
import {
|
||||
useAdminCollections,
|
||||
useAdminProductCategories,
|
||||
useAdminProductTypes,
|
||||
} from "medusa-react"
|
||||
|
||||
import { NestedMultiselectOption } from "../../../categories/components/multiselect"
|
||||
import { transformCategoryToNestedFormOptions } from "../../../categories/utils/transform-response"
|
||||
|
||||
const useOrganizeData = () => {
|
||||
const { product_types } = useAdminProductTypes(undefined, {
|
||||
staleTime: 0,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
const { collections } = useAdminCollections()
|
||||
const { product_categories: categories } = useAdminProductCategories({
|
||||
parent_category_id: "null",
|
||||
include_descendants_tree: true,
|
||||
})
|
||||
|
||||
const productTypeOptions = useMemo(() => {
|
||||
return (
|
||||
@@ -26,9 +38,15 @@ const useOrganizeData = () => {
|
||||
)
|
||||
}, [collections])
|
||||
|
||||
const categoriesOptions: NestedMultiselectOption[] | undefined = useMemo(
|
||||
() => categories?.map(transformCategoryToNestedFormOptions),
|
||||
[categories]
|
||||
)
|
||||
|
||||
return {
|
||||
productTypeOptions,
|
||||
collectionOptions,
|
||||
categoriesOptions,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@ const GeneralModal = ({ product, open, onClose }: Props) => {
|
||||
tags: data.organize.tags
|
||||
? data.organize.tags.map((t) => ({ value: t }))
|
||||
: null,
|
||||
|
||||
categories: data.organize.categories?.map((id) => ({ id })),
|
||||
discountable: data.discountable.value,
|
||||
},
|
||||
onReset
|
||||
@@ -96,7 +98,7 @@ const GeneralModal = ({ product, open, onClose }: Props) => {
|
||||
<DiscountableForm form={nestedForm(form, "discountable")} />
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="flex gap-x-2 justify-end w-full">
|
||||
<div className="flex w-full justify-end gap-x-2">
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
@@ -139,6 +141,7 @@ const getDefaultValues = (product: Product): GeneralFormWrapper => {
|
||||
? { label: product.type.value, value: product.type.id }
|
||||
: null,
|
||||
tags: product.tags ? product.tags.map((t) => t.value) : null,
|
||||
categories: product.categories.map((c) => c.id),
|
||||
},
|
||||
discountable: {
|
||||
value: product.discountable,
|
||||
|
||||
@@ -8,8 +8,8 @@ import FeatureToggle from "../../../components/fundamentals/feature-toggle"
|
||||
import CrossIcon from "../../../components/fundamentals/icons/cross-icon"
|
||||
import FocusModal from "../../../components/molecules/modal/focus-modal"
|
||||
import Accordion from "../../../components/organisms/accordion"
|
||||
import useNotification from "../../../hooks/use-notification"
|
||||
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
|
||||
import useNotification from "../../../hooks/use-notification"
|
||||
import { FormImage, ProductStatus } from "../../../types/shared"
|
||||
import { getErrorMessage } from "../../../utils/error-messages"
|
||||
import { prepareImages } from "../../../utils/images"
|
||||
@@ -187,7 +187,7 @@ const NewProduct = ({ onClose }: Props) => {
|
||||
</div>
|
||||
</FocusModal.Header>
|
||||
<FocusModal.Main className="no-scrollbar flex w-full justify-center">
|
||||
<div className="medium:w-7/12 large:w-6/12 small:w-4/5 my-16 max-w-[700px]">
|
||||
<div className="small:w-4/5 medium:w-7/12 large:w-6/12 my-16 max-w-[700px]">
|
||||
<Accordion defaultValue={["general"]} type="multiple">
|
||||
<Accordion.Item
|
||||
value={"general"}
|
||||
@@ -226,7 +226,7 @@ const NewProduct = ({ onClose }: Props) => {
|
||||
</div>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item title="Variants" value="variants">
|
||||
<p className="text-grey-50 inter-base-regular">
|
||||
<p className="inter-base-regular text-grey-50">
|
||||
Add variations of this product.
|
||||
<br />
|
||||
Offer your customers different options for color, format,
|
||||
@@ -254,14 +254,14 @@ const NewProduct = ({ onClose }: Props) => {
|
||||
</div>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item title="Thumbnail" value="thumbnail">
|
||||
<p className="inter-base-regular text-grey-50 mb-large">
|
||||
<p className="inter-base-regular mb-large text-grey-50">
|
||||
Used to represent your product during checkout, social sharing
|
||||
and more.
|
||||
</p>
|
||||
<ThumbnailForm form={nestedForm(form, "thumbnail")} />
|
||||
</Accordion.Item>
|
||||
<Accordion.Item title="Media" value="media">
|
||||
<p className="inter-base-regular text-grey-50 mb-large">
|
||||
<p className="inter-base-regular mb-large text-grey-50">
|
||||
Add images to your product.
|
||||
</p>
|
||||
<MediaForm form={nestedForm(form, "media")} />
|
||||
@@ -305,6 +305,9 @@ const createPayload = (
|
||||
value: t,
|
||||
}))
|
||||
: undefined,
|
||||
categories: data.organize.categories?.length
|
||||
? data.organize.categories.map((id) => ({ id }))
|
||||
: undefined,
|
||||
origin_country: data.customs.origin_country?.value || undefined,
|
||||
options: data.variants.options.map((o) => ({
|
||||
title: o.title,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAdminCreateBatchJob, useAdminCreateCollection } from "medusa-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import React, { useContext, useEffect, useState } from "react"
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import Fade from "../../../components/atoms/fade-wrapper"
|
||||
import Button from "../../../components/fundamentals/button"
|
||||
@@ -14,10 +14,10 @@ import CollectionsTable from "../../../components/templates/collections-table"
|
||||
import ProductTable from "../../../components/templates/product-table"
|
||||
import useNotification from "../../../hooks/use-notification"
|
||||
import useToggleState from "../../../hooks/use-toggle-state"
|
||||
import { usePolling } from "../../../providers/polling-provider"
|
||||
import { getErrorMessage } from "../../../utils/error-messages"
|
||||
import ImportProducts from "../batch-job/import"
|
||||
import NewProduct from "../new"
|
||||
import { usePolling } from "../../../providers/polling-provider"
|
||||
|
||||
const VIEWS = ["products", "collections"]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react"
|
||||
|
||||
const useOutsideClick = (callback: () => void, ref: any) => {
|
||||
const useOutsideClick = (callback: () => void, ref: any, capture = false) => {
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (!ref.current.contains(e.target)) {
|
||||
@@ -8,12 +8,12 @@ const useOutsideClick = (callback: () => void, ref: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", handleClickOutside)
|
||||
document.addEventListener("click", handleClickOutside, capture)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside)
|
||||
document.removeEventListener("click", handleClickOutside, capture)
|
||||
}
|
||||
}, [ref])
|
||||
}, [callback, ref, capture])
|
||||
}
|
||||
|
||||
export default useOutsideClick
|
||||
|
||||
@@ -37,7 +37,11 @@ export const useAdminCreateProductCategory = (
|
||||
return useMutation(
|
||||
(payload: AdminPostProductCategoriesReq) =>
|
||||
client.admin.productCategories.create(payload),
|
||||
buildOptions(queryClient, [adminProductCategoryKeys.list()], options)
|
||||
buildOptions(
|
||||
queryClient,
|
||||
[adminProductCategoryKeys.list(), adminProductKeys.details()],
|
||||
options
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,7 +67,11 @@ export const useAdminUpdateProductCategory = (
|
||||
client.admin.productCategories.update(id, payload),
|
||||
buildOptions(
|
||||
queryClient,
|
||||
[adminProductCategoryKeys.lists(), adminProductCategoryKeys.detail(id)],
|
||||
[
|
||||
adminProductCategoryKeys.lists(),
|
||||
adminProductCategoryKeys.detail(id),
|
||||
adminProductKeys.details(),
|
||||
],
|
||||
options
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user