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:
Riqwan Thamir
2023-03-07 19:42:01 +01:00
committed by GitHub
parent 2d2727f753
commit 47d3440766
11 changed files with 480 additions and 18 deletions

View 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

View File

@@ -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 && (

View File

@@ -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

View File

@@ -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 }
}

View File

@@ -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")}

View File

@@ -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,
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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"]

View File

@@ -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

View File

@@ -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
)
)