diff --git a/.changeset/wild-rabbits-travel.md b/.changeset/wild-rabbits-travel.md new file mode 100644 index 0000000000..6b7bc5b894 --- /dev/null +++ b/.changeset/wild-rabbits-travel.md @@ -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 diff --git a/packages/admin-ui/ui/src/components/molecules/delimited-list/index.tsx b/packages/admin-ui/ui/src/components/molecules/delimited-list/index.tsx index b025128f3f..9668975e79 100644 --- a/packages/admin-ui/ui/src/components/molecules/delimited-list/index.tsx +++ b/packages/admin-ui/ui/src/components/molecules/delimited-list/index.tsx @@ -26,7 +26,7 @@ const DelimitedList: React.FC = ({ list, delimit = 1 }) => { } return ( - + {itemsToDisplay} {showExtraItemsInTooltip && ( diff --git a/packages/admin-ui/ui/src/domain/categories/components/multiselect/index.tsx b/packages/admin-ui/ui/src/domain/categories/components/multiselect/index.tsx new file mode 100644 index 0000000000..d943de88f1 --- /dev/null +++ b/packages/admin-ui/ui/src/domain/categories/components/multiselect/index.tsx @@ -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 ( +
+ {props.list.map((listItem) => ( + {listItem} + ))} +
+ ) +} + +type InputProps = { + isOpen: boolean + selected: Record + 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 ( +
+
+ {!!selectedCount && ( + } + > + + {selectedCount} + + + + )} + Categories +
+ +
+ ) +} + +type CheckboxProps = { isSelected: boolean } + +/** + * List item checkbox + */ +const Checkbox = ({ isSelected }: CheckboxProps) => { + return ( +
+ + {isSelected && } + +
+ ) +} + +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 ( +
+
+
{ + e.stopPropagation() + onOptionCheckboxClick(option) + }} + > + +
+ {option.label} +
+ + {hasChildren && ( +
+ {!!selectedSubcategoriesCount && ( + + {selectedSubcategoriesCount} selected + + )} + +
+ )} +
+ ) +} + +type PopupProps = { + pop: () => void + selected: Record + activeOption: NestedMultiselectOption + selectedSubcategoriesCount: Record + 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 ( +
+ {showBack && ( +
{ + 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" + > + + {activeOption.label} +
+ )} + {activeOption.children!.map((o) => ( + + ))} +
+ ) +} + +type NestedMultiselectProps = { + options: NestedMultiselectOption[] + onSelect: (values: string[]) => void + initiallySelected?: Record +} + +/** + * Nested multiselect container + */ +function NestedMultiselect(props: NestedMultiselectProps) { + const { options, initiallySelected, onSelect } = props + const [isOpen, openPopup, closePopup] = useToggleState(false) + + const rootRef = React.useRef(null) + useOutsideClick(closePopup, rootRef, true) + + const [activeOption, setActiveOption] = useState({ + value: null, + label: null, + children: options, + }) + + const [selected, setSelected] = useState>( + 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 ( +
+ + {isOpen && ( + + )} +
+ ) +} + +export default NestedMultiselect diff --git a/packages/admin-ui/ui/src/domain/categories/utils/transform-response.ts b/packages/admin-ui/ui/src/domain/categories/utils/transform-response.ts new file mode 100644 index 0000000000..2835c9bd95 --- /dev/null +++ b/packages/admin-ui/ui/src/domain/categories/utils/transform-response.ts @@ -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 } +} diff --git a/packages/admin-ui/ui/src/domain/products/components/organize-form/index.tsx b/packages/admin-ui/ui/src/domain/products/components/organize-form/index.tsx index 5b5f0322fe..0afc9d3370 100644 --- a/packages/admin-ui/ui/src/domain/products/components/organize-form/index.tsx +++ b/packages/admin-ui/ui/src/domain/products/components/organize-form/index.tsx @@ -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 (
-
+
{ }} />
+ + {isFeatureEnabled(FeatureFlag.PRODUCT_CATEGORIES) && ( + <> + + { + if (!categoriesOptions) { + return null + } + + const initiallySelected = (value || []).reduce((acc, val) => { + acc[val] = true + return acc + }, {}) + + return ( + + ) + }} + /> + + )} + +
+ { 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, } } diff --git a/packages/admin-ui/ui/src/domain/products/edit/sections/general/general-modal.tsx b/packages/admin-ui/ui/src/domain/products/edit/sections/general/general-modal.tsx index 5190a4cc99..b40761380e 100644 --- a/packages/admin-ui/ui/src/domain/products/edit/sections/general/general-modal.tsx +++ b/packages/admin-ui/ui/src/domain/products/edit/sections/general/general-modal.tsx @@ -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) => { -
+
-
+
{
-

+

Add variations of this product.
Offer your customers different options for color, format, @@ -254,14 +254,14 @@ const NewProduct = ({ onClose }: Props) => {

-

+

Used to represent your product during checkout, social sharing and more.

-

+

Add images to your product.

@@ -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, diff --git a/packages/admin-ui/ui/src/domain/products/overview/index.tsx b/packages/admin-ui/ui/src/domain/products/overview/index.tsx index a3d5b57717..03d15cf503 100644 --- a/packages/admin-ui/ui/src/domain/products/overview/index.tsx +++ b/packages/admin-ui/ui/src/domain/products/overview/index.tsx @@ -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"] diff --git a/packages/admin-ui/ui/src/hooks/use-outside-click.ts b/packages/admin-ui/ui/src/hooks/use-outside-click.ts index a43f6b2a04..a9c888dc92 100644 --- a/packages/admin-ui/ui/src/hooks/use-outside-click.ts +++ b/packages/admin-ui/ui/src/hooks/use-outside-click.ts @@ -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 diff --git a/packages/medusa-react/src/hooks/admin/product-categories/mutations.ts b/packages/medusa-react/src/hooks/admin/product-categories/mutations.ts index cdc63d45c1..5355981780 100644 --- a/packages/medusa-react/src/hooks/admin/product-categories/mutations.ts +++ b/packages/medusa-react/src/hooks/admin/product-categories/mutations.ts @@ -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 ) )