diff --git a/package.json b/package.json index 2bea3683c6..78de3d6135 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "react-hotkeys-hook": "^3.4.7", "react-json-tree": "^0.17.0", "react-jwt": "^1.1.4", + "react-nestable": "^2.0.0", "react-router-dom": "^6.4.2", "react-select": "^5.5.4", "react-table": "^7.7.0", diff --git a/src/components/fundamentals/icons/folder-open-icon/index.tsx b/src/components/fundamentals/icons/folder-open-icon/index.tsx new file mode 100644 index 0000000000..59703296d1 --- /dev/null +++ b/src/components/fundamentals/icons/folder-open-icon/index.tsx @@ -0,0 +1,29 @@ +import React from "react" +import IconProps from "../types/icon-type" + +const FolderOpenIcon: React.FC = ({ + size = "20px", + color = "currentColor", + ...attributes +}) => { + return ( + + + + ) +} + +export default FolderOpenIcon diff --git a/src/components/fundamentals/icons/reorder-icon/index.tsx b/src/components/fundamentals/icons/reorder-icon/index.tsx new file mode 100644 index 0000000000..6c28116825 --- /dev/null +++ b/src/components/fundamentals/icons/reorder-icon/index.tsx @@ -0,0 +1,64 @@ +import React from "react" +import IconProps from "../types/icon-type" + +const ReorderIcon: React.FC = ({ + size = "24px", + color = "currentColor", + ...attributes +}) => { + return ( + + + + + + + + + ) +} + +export default ReorderIcon diff --git a/src/components/fundamentals/icons/triangle-mini-icon/index.tsx b/src/components/fundamentals/icons/triangle-mini-icon/index.tsx new file mode 100644 index 0000000000..f0a4c50908 --- /dev/null +++ b/src/components/fundamentals/icons/triangle-mini-icon/index.tsx @@ -0,0 +1,28 @@ +import React from "react" +import IconProps from "../types/icon-type" + +const TriangleDownIcon: React.FC = ({ + size = "20", + color = "currentColor", + ...attributes +}) => { + return ( + + + + ) +} + +export default TriangleDownIcon diff --git a/src/components/molecules/modal/side-modal.tsx b/src/components/molecules/modal/side-modal.tsx index 14f4933403..f0fd25462a 100644 --- a/src/components/molecules/modal/side-modal.tsx +++ b/src/components/molecules/modal/side-modal.tsx @@ -43,7 +43,7 @@ function SideModal(props: SideModalProps) { background: "white", right: 0, top: 0, - zIndex: 9999, + zIndex: 200, }} className="rounded border overflow-hidden" animate={{ right: 0 }} diff --git a/src/components/molecules/select/index.tsx b/src/components/molecules/select/index.tsx index 85cea7fbef..3e7f59a084 100644 --- a/src/components/molecules/select/index.tsx +++ b/src/components/molecules/select/index.tsx @@ -1,5 +1,6 @@ import clsx from "clsx" import React, { + CSSProperties, useContext, useEffect, useImperativeHandle, @@ -31,6 +32,7 @@ type MultiSelectProps = InputHeaderProps & { placeholder?: string isMultiSelect?: boolean labelledBy?: string + menuPortalStyles?: CSSProperties options: { label: string; value: string | null; disabled?: boolean }[] value: | { label: string; value: string }[] @@ -69,6 +71,7 @@ const SSelect = React.forwardRef( placeholder = "Search...", options, onCreateOption, + menuPortalStyles = {}, }: MultiSelectProps, ref ) => { @@ -176,7 +179,9 @@ const SSelect = React.forwardRef( }} closeMenuOnSelect={!isMultiSelect} blurInputOnSelect={!isMultiSelect} - styles={{ menuPortal: (base) => ({ ...base, zIndex: 60 }) }} + styles={{ + menuPortal: (base) => ({ ...base, ...menuPortalStyles }), + }} hideSelectedOptions={false} menuPortalTarget={portalRef?.current?.lastChild || document.body} menuPlacement="auto" diff --git a/src/components/organisms/sidebar/index.tsx b/src/components/organisms/sidebar/index.tsx index d0e6c34afc..5049e3eda1 100644 --- a/src/components/organisms/sidebar/index.tsx +++ b/src/components/organisms/sidebar/index.tsx @@ -1,5 +1,6 @@ import { useAdminStore } from "medusa-react" import React, { useState } from "react" + import { useFeatureFlag } from "../../../context/feature-flag" import BuildingsIcon from "../../fundamentals/icons/buildings-icon" import CartIcon from "../../fundamentals/icons/cart-icon" @@ -67,7 +68,7 @@ const Sidebar: React.FC = () => { } - text={"Product Categories"} + text={"Categories"} triggerHandler={triggerHandler} /> )} diff --git a/src/domain/product-categories/components/product-categories-list.tsx b/src/domain/product-categories/components/product-categories-list.tsx new file mode 100644 index 0000000000..18c513e6e7 --- /dev/null +++ b/src/domain/product-categories/components/product-categories-list.tsx @@ -0,0 +1,127 @@ +import React, { useCallback, useMemo, useState } from "react" +import Nestable from "react-nestable" +import { dropRight, get, flatMap } from "lodash" + +import "react-nestable/dist/styles/index.css" +import "../styles/product-categories.css" + +import { ProductCategory } from "@medusajs/medusa" +import { adminProductCategoryKeys, useMedusa } from "medusa-react" + +import TriangleMiniIcon from "../../../components/fundamentals/icons/triangle-mini-icon" +import ProductCategoryListItemDetails from "./product-category-list-item-details" +import ReorderIcon from "../../../components/fundamentals/icons/reorder-icon" +import { useQueryClient } from "@tanstack/react-query" +import useNotification from "../../../hooks/use-notification" + +type ProductCategoriesListProps = { + categories: ProductCategory[] +} + +/** + * Draggable list that renders product categories tree view. + */ +function ProductCategoriesList(props: ProductCategoriesListProps) { + const { client } = useMedusa() + const queryClient = useQueryClient() + const notification = useNotification() + const [isUpdating, setIsUpdating] = useState(false) + + const { categories } = props + + const onItemDrop = useCallback( + async (params: { + item: ProductCategory + items: ProductCategory[] + path: number[] + }) => { + setIsUpdating(true) + let parentId = null + const { dragItem, items, targetPath } = params + const [rank] = targetPath.slice(-1) + + if (targetPath.length > 1) { + const path = dropRight( + flatMap(targetPath.slice(0, -1), (item) => [ + item, + "category_children", + ]) + ) + + const newParent = get(items, path) + parentId = newParent.id + } + + try { + await client.admin.productCategories.update(dragItem.id, { + parent_category_id: parentId, + rank, + }) + notification("Success", "New order saved", "success") + await queryClient.invalidateQueries(adminProductCategoryKeys.lists()) + } catch (e) { + notification("Error", "Failed to save new order", "error") + } finally { + setIsUpdating(false) + } + }, + [] + ) + + const NestableList = useMemo( + () => ( + ( + + )} + handler={} + renderCollapseIcon={({ isCollapsed }) => ( + + )} + /> + ), + [categories] + ) + + return ( +
+ {NestableList} + {isUpdating && ( +
+ )} +
+ ) +} + +export default React.memo(ProductCategoriesList) // Memo prevents list flicker on reorder diff --git a/src/domain/product-categories/components/product-category-list-item-details.tsx b/src/domain/product-categories/components/product-category-list-item-details.tsx new file mode 100644 index 0000000000..b8fc2bc715 --- /dev/null +++ b/src/domain/product-categories/components/product-category-list-item-details.tsx @@ -0,0 +1,132 @@ +import React, { useContext } from "react" +import clsx from "clsx" + +import { ProductCategory } from "@medusajs/medusa" +import { useAdminDeleteProductCategory } from "medusa-react" + +import { ProductCategoriesContext } from "../pages" +import Tooltip from "../../../components/atoms/tooltip" +import Button from "../../../components/fundamentals/button" +import Actionables from "../../../components/molecules/actionables" +import TrashIcon from "../../../components/fundamentals/icons/trash-icon" +import EditIcon from "../../../components/fundamentals/icons/edit-icon" +import PlusIcon from "../../../components/fundamentals/icons/plus-icon" +import FolderOpenIcon from "../../../components/fundamentals/icons/folder-open-icon" +import TagIcon from "../../../components/fundamentals/icons/tag-icon" +import MoreHorizontalIcon from "../../../components/fundamentals/icons/more-horizontal-icon" +import useNotification from "../../../hooks/use-notification" + +type ProductCategoryListItemDetailsProps = { + depth: number + item: ProductCategory + handler: React.ReactNode + collapseIcon: React.ReactNode +} + +function ProductCategoryListItemDetails( + props: ProductCategoryListItemDetailsProps +) { + const { item } = props + const notification = useNotification() + + const hasChildren = !!item.category_children?.length + + const productCategoriesPageContext = useContext(ProductCategoriesContext) + + const { mutateAsync: deleteCategory } = useAdminDeleteProductCategory(item.id) + + const actions = [ + { + label: "Edit", + onClick: () => productCategoriesPageContext.editCategory(item), + icon: , + }, + { + label: "Delete", + variant: "danger", + onClick: async () => { + try { + await deleteCategory() + notification("Success", "Category deleted", "success") + } catch (e) { + notification("Error", "Category deletion failed", "error") + } + }, + icon: , + disabled: !!item.category_children.length, + }, + ] + + return ( +
+
+
+ {props.handler} +
+ +
+
+ {hasChildren && ( +
+ {props.collapseIcon} +
+ )} +
+ {hasChildren && } + {!hasChildren && } +
+ + {item.name} + +
+ +
+ + Add category item to{" "} + + "{item.name}" + + + } + > + + + + + + } + /> +
+
+
+
+ ) +} + +export default ProductCategoryListItemDetails diff --git a/src/domain/product-categories/index.tsx b/src/domain/product-categories/index.tsx index d10155f600..a9da57f318 100644 --- a/src/domain/product-categories/index.tsx +++ b/src/domain/product-categories/index.tsx @@ -1,36 +1,6 @@ -import BodyCard from "../../components/organisms/body-card" import { Route, Routes } from "react-router-dom" -const ProductCategoryIndex = () => { - const actions = [ - { - label: "Add category", - onClick: () => {}, - }, - ] - - return ( -
-
- -
-

- No product categories yet, use the above button to create your - first category. -

-
-
-
-
- ) -} +import ProductCategoryIndex from "./pages" const ProductCategories = () => { return ( diff --git a/src/domain/product-categories/modals/add-product-category.tsx b/src/domain/product-categories/modals/add-product-category.tsx new file mode 100644 index 0000000000..3267478851 --- /dev/null +++ b/src/domain/product-categories/modals/add-product-category.tsx @@ -0,0 +1,145 @@ +import React, { useState } from "react" + +import { ProductCategory } from "@medusajs/medusa" +import { + adminProductCategoryKeys, + useAdminCreateProductCategory, +} from "medusa-react" + +import useNotification from "../../../hooks/use-notification" +import FocusModal from "../../../components/molecules/modal/focus-modal" +import Button from "../../../components/fundamentals/button" +import CrossIcon from "../../../components/fundamentals/icons/cross-icon" +import InputField from "../../../components/molecules/input" +import Select from "../../../components/molecules/select" +import { useQueryClient } from "@tanstack/react-query" + +const visibilityOptions = [ + { + label: "Public", + value: "public", + }, + { label: "Private", value: "private" }, +] + +const statusOptions = [ + { label: "Active", value: "active" }, + { label: "Inactive", value: "inactive" }, +] + +type CreateProductCategoryProps = { + closeModal: () => void + parentCategory?: ProductCategory +} + +/** + * Focus modal container for creating Publishable Keys. + */ +function CreateProductCategory(props: CreateProductCategoryProps) { + const { closeModal, parentCategory } = props + const notification = useNotification() + const queryClient = useQueryClient() + + const [name, setName] = useState("") + const [handle, setHandle] = useState("") + const [isActive, setIsActive] = useState(true) + const [isPublic, setIsPublic] = useState(true) + + const { mutateAsync: createProductCategory } = useAdminCreateProductCategory() + + const onSubmit = async () => { + try { + await createProductCategory({ + name, + handle, + is_active: isActive, + is_internal: !isPublic, + parent_category_id: parentCategory?.id ?? null, + }) + // TODO: temporary here, investigate why `useAdminCreateProductCategory` doesn't invalidate this + await queryClient.invalidateQueries(adminProductCategoryKeys.lists()) + closeModal() + notification("Success", "Created a new product category", "success") + } catch (e) { + notification("Error", "Failed to create a new product category", "error") + } + } + + return ( + + +
+ +
+ +
+
+
+ + +
+

+ Add category {parentCategory && `to ${parentCategory.name}`} +

+

Details

+ +
+ setName(ev.target.value)} + /> + + setHandle(ev.target.value)} + /> +
+ +
+
+ setIsPublic(o.value === "public")} + /> +
+
+
+
+
+ ) +} + +export default CreateProductCategory diff --git a/src/domain/product-categories/modals/edit-product-category.tsx b/src/domain/product-categories/modals/edit-product-category.tsx new file mode 100644 index 0000000000..166d38ad6d --- /dev/null +++ b/src/domain/product-categories/modals/edit-product-category.tsx @@ -0,0 +1,165 @@ +import React, { useEffect, useState } from "react" + +import { ProductCategory } from "@medusajs/medusa" +import { useAdminUpdateProductCategory } from "medusa-react" + +import SideModal from "../../../components/molecules/modal/side-modal" +import Button from "../../../components/fundamentals/button" +import CrossIcon from "../../../components/fundamentals/icons/cross-icon" +import InputField from "../../../components/molecules/input" +import Select from "../../../components/molecules/select" +import useNotification from "../../../hooks/use-notification" + +const visibilityOptions = [ + { + label: "Public", + value: "public", + }, + { label: "Private", value: "private" }, +] + +const statusOptions = [ + { label: "Active", value: "active" }, + { label: "Inactive", value: "inactive" }, +] + +type EditProductCategoriesSideModalProps = { + activeCategory: ProductCategory + close: () => void + isVisible: boolean +} + +/** + * Modal for editing product categories + */ +function EditProductCategoriesSideModal( + props: EditProductCategoriesSideModalProps +) { + const { isVisible, close, activeCategory } = props + + const [name, setName] = useState("") + const [handle, setHandle] = useState("") + const [isActive, setIsActive] = useState(true) + const [isPublic, setIsPublic] = useState(true) + + const notification = useNotification() + + const { mutateAsync: updateCategory } = useAdminUpdateProductCategory( + activeCategory?.id + ) + + useEffect(() => { + if (activeCategory) { + setName(activeCategory.name) + setHandle(activeCategory.handle) + setIsActive(activeCategory.is_active) + setIsPublic(!activeCategory.is_internal) + } + }, [activeCategory]) + + const onSave = async () => { + try { + await updateCategory({ + name, + handle, + is_active: isActive, + is_internal: !isPublic, + }) + + // TODO: check on the BD, when we send update partial children of the category are lost + + notification("Success", "Product category updated", "success") + } catch (e) { + notification("Error", "Failed to update the category", "error") + } + close() + } + + const onClose = () => { + close() + } + + return ( + +
+ {/* === HEADER === */} + +
+

+ Edit product category +

+ +
+ {/* === DIVIDER === */} + +
+ setName(ev.target.value)} + /> + + setHandle(ev.target.value)} + /> + + setIsPublic(o.value === "public")} + /> +
+ {/* === DIVIDER === */} + +
+ {/* === FOOTER === */} + +
+ + +
+
+ + ) +} + +export default EditProductCategoriesSideModal diff --git a/src/domain/product-categories/pages/index.tsx b/src/domain/product-categories/pages/index.tsx new file mode 100644 index 0000000000..2fa5fcc59d --- /dev/null +++ b/src/domain/product-categories/pages/index.tsx @@ -0,0 +1,118 @@ +import { createContext, useState } from "react" + +import { ProductCategory } from "@medusajs/medusa" +import { useAdminProductCategories } from "medusa-react" + +import useToggleState from "../../../hooks/use-toggle-state" +import BodyCard from "../../../components/organisms/body-card" +import CreateProductCategory from "../modals/add-product-category" +import ProductCategoriesList from "../components/product-categories-list" +import EditProductCategoriesSideModal from "../modals/edit-product-category" + +/** + * Product categories empty state placeholder. + */ +function ProductCategoriesEmptyState() { + return ( +
+

+ No product categories yet, use the above button to create your first + category. +

+
+ ) +} + +export const ProductCategoriesContext = createContext<{ + editCategory: (category: ProductCategory) => void + createSubCategory: (category: ProductCategory) => void +}>({} as any) + +/** + * Product category index page container. + */ +function ProductCategoryPage() { + const { + state: isCreateModalVisible, + open: showCreateModal, + close: hideCreateModal, + } = useToggleState() + + const { + state: isEditModalVisible, + open: showEditModal, + close: hideEditModal, + } = useToggleState() + + const [activeCategory, setActiveCategory] = useState() + + const { product_categories: categories, isLoading } = + useAdminProductCategories({ + parent_category_id: "null", + include_descendants_tree: true, + }) + + const actions = [ + { + label: "Add category", + onClick: showCreateModal, + }, + ] + + const showPlaceholder = !isLoading && !categories?.length + + const editCategory = (category: ProductCategory) => { + setActiveCategory(category) + showEditModal() + } + + const createSubCategory = (category: ProductCategory) => { + setActiveCategory(category) + showCreateModal() + } + + const context = { + editCategory, + createSubCategory, + } + + return ( + +
+
+ + {showPlaceholder ? ( + + ) : isLoading ? null : ( + + )} + + {isCreateModalVisible && ( + { + hideCreateModal() + setActiveCategory(undefined) + }} + /> + )} + + +
+
+
+ ) +} + +export default ProductCategoryPage diff --git a/src/domain/product-categories/styles/product-categories.css b/src/domain/product-categories/styles/product-categories.css new file mode 100644 index 0000000000..d863e891d7 --- /dev/null +++ b/src/domain/product-categories/styles/product-categories.css @@ -0,0 +1,25 @@ + + +.nestable-item.is-dragging:before { + content: ' '; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: transparent; + + transition: .3s all; + + border-radius: 0; + border: 1px dashed #6E56CF; + z-index: 1000; + + +} + +.nestable-item.is-dragging * { + height: 40px!important; + min-height: 40px!important; + max-height: 40px!important; +} diff --git a/src/domain/publishable-api-keys/pages/index.tsx b/src/domain/publishable-api-keys/pages/index.tsx index 16908bc1db..45aa06fd24 100644 --- a/src/domain/publishable-api-keys/pages/index.tsx +++ b/src/domain/publishable-api-keys/pages/index.tsx @@ -4,7 +4,6 @@ import { PublishableApiKey, SalesChannel } from "@medusajs/medusa" import { useAdminAddPublishableKeySalesChannelsBatch, useAdminCreatePublishableApiKey, - useAdminPublishableApiKeySalesChannels, } from "medusa-react" import Breadcrumb from "../../../components/molecules/breadcrumb"