feat(admin-ui): adds category ui for tree/list, edit, create, delete (#3399)
* chore: adds category ui for tree/list, edit, create, delete * fix: address feedback --------- Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Co-authored-by: fPolic <frane@medusajs.com>
This commit is contained in:
co-authored by
Oliver Windall Juhl
fPolic
parent
06f567f446
commit
b5662d0c6a
@@ -0,0 +1,29 @@
|
||||
import React from "react"
|
||||
import IconProps from "../types/icon-type"
|
||||
|
||||
const FolderOpenIcon: React.FC<IconProps> = ({
|
||||
size = "20px",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M3.49557 8.24663C3.58387 8.23322 3.67454 8.22613 3.76678 8.22613H16.2328C16.325 8.22613 16.4157 8.23322 16.504 8.24663M3.49557 8.24663C3.03418 8.31808 2.61956 8.56856 2.34165 8.94373C2.06373 9.31891 1.94494 9.78852 2.01103 10.2507L2.68668 14.9811C2.74703 15.4037 2.95781 15.7905 3.28032 16.0703C3.60284 16.3501 4.01546 16.5041 4.44243 16.5042H15.5579C15.9849 16.5041 16.3975 16.3501 16.72 16.0703C17.0426 15.7905 17.2533 15.4037 17.3137 14.9811L17.9893 10.2507C18.0554 9.78852 17.9366 9.31891 17.6587 8.94373C17.3808 8.56856 16.9654 8.31808 16.504 8.24663M3.49557 8.24663L3.49636 5.26967C3.49636 4.79934 3.68314 4.34827 4.01564 4.01562C4.34813 3.68298 4.79912 3.496 5.26945 3.49579H8.32761C8.64115 3.49606 8.94174 3.62084 9.16331 3.84268L10.8363 5.51407C11.0578 5.73591 11.3584 5.86068 11.672 5.86096H14.7301C15.2006 5.86096 15.6518 6.04785 15.9844 6.38051C16.3171 6.71318 16.504 7.16437 16.504 7.63484V8.24663"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default FolderOpenIcon
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from "react"
|
||||
import IconProps from "../types/icon-type"
|
||||
|
||||
const ReorderIcon: React.FC<IconProps> = ({
|
||||
size = "24px",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
d="M7.5 10.75C7.91421 10.75 8.25 10.4142 8.25 10C8.25 9.58579 7.91421 9.25 7.5 9.25C7.08579 9.25 6.75 9.58579 6.75 10C6.75 10.4142 7.08579 10.75 7.5 10.75Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.5 5.75C7.91421 5.75 8.25 5.41421 8.25 5C8.25 4.58579 7.91421 4.25 7.5 4.25C7.08579 4.25 6.75 4.58579 6.75 5C6.75 5.41421 7.08579 5.75 7.5 5.75Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.5 15.75C7.91421 15.75 8.25 15.4142 8.25 15C8.25 14.5858 7.91421 14.25 7.5 14.25C7.08579 14.25 6.75 14.5858 6.75 15C6.75 15.4142 7.08579 15.75 7.5 15.75Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.5 10.75C12.9142 10.75 13.25 10.4142 13.25 10C13.25 9.58579 12.9142 9.25 12.5 9.25C12.0858 9.25 11.75 9.58579 11.75 10C11.75 10.4142 12.0858 10.75 12.5 10.75Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.5 5.75C12.9142 5.75 13.25 5.41421 13.25 5C13.25 4.58579 12.9142 4.25 12.5 4.25C12.0858 4.25 11.75 4.58579 11.75 5C11.75 5.41421 12.0858 5.75 12.5 5.75Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.5 15.75C12.9142 15.75 13.25 15.4142 13.25 15C13.25 14.5858 12.9142 14.25 12.5 14.25C12.0858 14.25 11.75 14.5858 11.75 15C11.75 15.4142 12.0858 15.75 12.5 15.75Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReorderIcon
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from "react"
|
||||
import IconProps from "../types/icon-type"
|
||||
|
||||
const TriangleDownIcon: React.FC<IconProps> = ({
|
||||
size = "20",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...attributes}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M13.0869 6.25006C13.7804 6.25006 14.22 7.00005 13.8859 7.61318L10.7988 13.2737C10.4525 13.9089 9.54748 13.9089 9.20123 13.2737L6.1141 7.61368C5.78 7.00054 6.21963 6.25055 6.91312 6.25055L13.0869 6.25006Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default TriangleDownIcon
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { PropsWithChildren } from "react"
|
||||
import { PropsWithChildren } from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
|
||||
const MODAL_WIDTH = 560
|
||||
@@ -43,9 +43,9 @@ function SideModal(props: SideModalProps) {
|
||||
background: "white",
|
||||
right: 0,
|
||||
top: 0,
|
||||
zIndex: 9999,
|
||||
zIndex: 200,
|
||||
}}
|
||||
className="rounded border overflow-hidden"
|
||||
className="overflow-hidden rounded border"
|
||||
animate={{ right: 0 }}
|
||||
exit={{ right: -MODAL_WIDTH }}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
) => {
|
||||
@@ -135,10 +138,10 @@ const SSelect = React.forwardRef(
|
||||
<div
|
||||
key={name}
|
||||
className={clsx(className, {
|
||||
"bg-white rounded-t-rounded": isFocussed,
|
||||
"rounded-t-rounded bg-white": isFocussed,
|
||||
})}
|
||||
>
|
||||
<div className="w-full flex text-grey-50 pr-0.5 justify-between pointer-events-none cursor-pointer mb-2">
|
||||
<div className="text-grey-50 pointer-events-none mb-2 flex w-full cursor-pointer justify-between pr-0.5">
|
||||
<InputHeader {...{ label, required, tooltip, tooltipContent }} />
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
@@ -188,7 +193,7 @@ const SSelect = React.forwardRef(
|
||||
components={SelectComponents}
|
||||
/>
|
||||
}
|
||||
{isFocussed && enableSearch && <div className="w-full h-5" />}
|
||||
{isFocussed && enableSearch && <div className="h-5 w-full" />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useAdminStore } from "medusa-react"
|
||||
import React, { useState } from "react"
|
||||
|
||||
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
|
||||
import BuildingsIcon from "../../fundamentals/icons/buildings-icon"
|
||||
import CartIcon from "../../fundamentals/icons/cart-icon"
|
||||
|
||||
+127
@@ -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(
|
||||
() => (
|
||||
<Nestable
|
||||
collapsed
|
||||
items={categories}
|
||||
onChange={onItemDrop}
|
||||
childrenProp="category_children"
|
||||
renderItem={({ item, depth, handler, collapseIcon }) => (
|
||||
<ProductCategoryListItemDetails
|
||||
item={item}
|
||||
depth={depth}
|
||||
handler={handler}
|
||||
collapseIcon={collapseIcon}
|
||||
/>
|
||||
)}
|
||||
handler={<ReorderIcon className="cursor-grab" color="#889096" />}
|
||||
renderCollapseIcon={({ isCollapsed }) => (
|
||||
<TriangleMiniIcon
|
||||
style={{
|
||||
top: -2,
|
||||
width: 32,
|
||||
left: -12,
|
||||
transform: !isCollapsed ? "" : "rotate(270deg)",
|
||||
}}
|
||||
color="#889096"
|
||||
size={18}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
[categories]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
pointerEvents: isUpdating ? "none" : "initial",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{NestableList}
|
||||
{isUpdating && (
|
||||
<div
|
||||
style={{
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "100%",
|
||||
cursor: "progress",
|
||||
position: "absolute",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ProductCategoriesList) // Memo prevents list flicker on reorder
|
||||
+132
@@ -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: <EditIcon size={20} />,
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
variant: "danger",
|
||||
onClick: async () => {
|
||||
try {
|
||||
await deleteCategory()
|
||||
notification("Success", "Category deleted", "success")
|
||||
} catch (e) {
|
||||
notification("Error", "Category deletion failed", "error")
|
||||
}
|
||||
},
|
||||
icon: <TrashIcon size={20} />,
|
||||
disabled: !!item.category_children.length,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<div
|
||||
style={{ marginLeft: props.depth * -8 }}
|
||||
className="flex h-[40px] items-center"
|
||||
>
|
||||
<div className="flex w-[32px] items-center justify-center">
|
||||
{props.handler}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{hasChildren && (
|
||||
<div className="flex w-[32px] items-center justify-center">
|
||||
{props.collapseIcon}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-[32px] items-center justify-center">
|
||||
{hasChildren && <FolderOpenIcon color="#889096" size={18} />}
|
||||
{!hasChildren && <TagIcon color="#889096" size={18} />}
|
||||
</div>
|
||||
<span
|
||||
className={clsx("ml-2 select-none text-xs font-medium", {
|
||||
"font-normal text-gray-400": !hasChildren,
|
||||
})}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip
|
||||
style={{ zIndex: 1 }}
|
||||
content={
|
||||
<>
|
||||
Add category item to{" "}
|
||||
<span className="text-grey-80 font-semibold">
|
||||
{`"${item.name}"`}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
productCategoriesPageContext.createSubCategory(item)
|
||||
}
|
||||
>
|
||||
<PlusIcon color="#687076" size={18} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Actionables
|
||||
forceDropdown
|
||||
actions={actions}
|
||||
customTrigger={
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
className="h-xlarge w-xlarge focus-visible:border-violet-60 focus-visible:shadow-input focus:shadow-none focus-visible:outline-none"
|
||||
>
|
||||
<MoreHorizontalIcon color="#687076" size={20} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductCategoryListItemDetails
|
||||
@@ -0,0 +1,145 @@
|
||||
import { 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 (
|
||||
<FocusModal>
|
||||
<FocusModal.Header>
|
||||
<div className="medium:w-8/12 flex w-full justify-between px-8">
|
||||
<Button size="small" variant="ghost" onClick={closeModal}>
|
||||
<CrossIcon size={20} />
|
||||
</Button>
|
||||
<div className="gap-x-small flex">
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
onClick={onSubmit}
|
||||
disabled={!name}
|
||||
className="rounded-rounded"
|
||||
>
|
||||
Save category
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FocusModal.Header>
|
||||
|
||||
<FocusModal.Main className="no-scrollbar flex w-full justify-center">
|
||||
<div className="small:w-4/5 medium:w-7/12 large:w-6/12 my-16 max-w-[700px]">
|
||||
<h1 className="inter-xlarge-semibold text-grey-90 pb-8">
|
||||
Add category {parentCategory && `to ${parentCategory.name}`}
|
||||
</h1>
|
||||
<h4 className="inter-large-semibold text-grey-90 pb-1">Details</h4>
|
||||
|
||||
<div className="mb-8 flex justify-between gap-6">
|
||||
<InputField
|
||||
label="Name"
|
||||
type="string"
|
||||
name="name"
|
||||
value={name}
|
||||
className="w-[338px]"
|
||||
placeholder="Give this category a name"
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Handle"
|
||||
type="string"
|
||||
name="handle"
|
||||
value={handle}
|
||||
className="w-[338px]"
|
||||
placeholder="Custom handle"
|
||||
onChange={(ev) => setHandle(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 flex justify-between gap-6">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
label="Status"
|
||||
options={statusOptions}
|
||||
menuPortalStyles={{ zIndex: 300 }}
|
||||
value={statusOptions[isActive ? 0 : 1]}
|
||||
onChange={(o) => setIsActive(o.value === "active")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
label="Visibility"
|
||||
options={visibilityOptions}
|
||||
menuPortalStyles={{ zIndex: 300 }}
|
||||
value={visibilityOptions[isPublic ? 0 : 1]}
|
||||
onChange={(o) => setIsPublic(o.value === "public")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusModal.Main>
|
||||
</FocusModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateProductCategory
|
||||
@@ -0,0 +1,158 @@
|
||||
import { 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,
|
||||
})
|
||||
|
||||
notification("Success", "Product category updated", "success")
|
||||
} catch (e) {
|
||||
notification("Error", "Failed to update the category", "error")
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
close()
|
||||
}
|
||||
|
||||
return (
|
||||
<SideModal close={onClose} isVisible={!!isVisible}>
|
||||
<div className="flex h-full flex-col justify-between p-6">
|
||||
{/* === HEADER === */}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="inter-large-semibold flex items-center gap-2 text-xl text-gray-900">
|
||||
Edit product category
|
||||
</h3>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-8 w-8 p-2"
|
||||
onClick={props.close}
|
||||
>
|
||||
<CrossIcon size={20} className="text-grey-50" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* === DIVIDER === */}
|
||||
|
||||
<div className="flex-grow">
|
||||
<InputField
|
||||
label="Name"
|
||||
type="string"
|
||||
name="name"
|
||||
value={name}
|
||||
className="my-6"
|
||||
placeholder="Give this category a name"
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Handle"
|
||||
type="string"
|
||||
name="handle"
|
||||
value={handle}
|
||||
className="my-6"
|
||||
placeholder="Custom handle"
|
||||
onChange={(ev) => setHandle(ev.target.value)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Status"
|
||||
options={statusOptions}
|
||||
menuPortalStyles={{ zIndex: 300 }}
|
||||
value={statusOptions[isActive ? 0 : 1]}
|
||||
onChange={(o) => setIsActive(o.value === "active")}
|
||||
/>
|
||||
|
||||
<Select
|
||||
className="my-6"
|
||||
label="Visibility"
|
||||
options={visibilityOptions}
|
||||
menuPortalStyles={{ zIndex: 300 }}
|
||||
value={visibilityOptions[isPublic ? 0 : 1]}
|
||||
onChange={(o) => setIsPublic(o.value === "public")}
|
||||
/>
|
||||
</div>
|
||||
{/* === DIVIDER === */}
|
||||
|
||||
<div
|
||||
className="block h-[1px] bg-gray-200"
|
||||
style={{ margin: "24px -24px" }}
|
||||
/>
|
||||
{/* === FOOTER === */}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="small" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="small" variant="primary" onClick={onSave}>
|
||||
Save and close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SideModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditProductCategoriesSideModal
|
||||
@@ -1,6 +1,13 @@
|
||||
import { createContext } from "react"
|
||||
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.
|
||||
@@ -16,20 +23,58 @@ function ProductCategoriesEmptyState() {
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductCategoriesContext = createContext<{}>({} as any)
|
||||
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<ProductCategory>()
|
||||
|
||||
const { product_categories: categories, isLoading } =
|
||||
useAdminProductCategories({
|
||||
parent_category_id: "null",
|
||||
include_descendants_tree: true,
|
||||
})
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: "Add category",
|
||||
onClick: () => {},
|
||||
onClick: showCreateModal,
|
||||
},
|
||||
]
|
||||
|
||||
const context = {}
|
||||
const showPlaceholder = !isLoading && !categories?.length
|
||||
|
||||
const editCategory = (category: ProductCategory) => {
|
||||
setActiveCategory(category)
|
||||
showEditModal()
|
||||
}
|
||||
|
||||
const createSubCategory = (category: ProductCategory) => {
|
||||
setActiveCategory(category)
|
||||
showCreateModal()
|
||||
}
|
||||
|
||||
const context = {
|
||||
editCategory,
|
||||
createSubCategory,
|
||||
}
|
||||
|
||||
return (
|
||||
<ProductCategoriesContext.Provider value={context}>
|
||||
@@ -43,8 +88,27 @@ function ProductCategoryPage() {
|
||||
footerMinHeight={40}
|
||||
setBorders
|
||||
>
|
||||
<ProductCategoriesEmptyState />
|
||||
{showPlaceholder ? (
|
||||
<ProductCategoriesEmptyState />
|
||||
) : isLoading ? null : (
|
||||
<ProductCategoriesList categories={categories!} />
|
||||
)}
|
||||
</BodyCard>
|
||||
{isCreateModalVisible && (
|
||||
<CreateProductCategory
|
||||
parentCategory={activeCategory}
|
||||
closeModal={() => {
|
||||
hideCreateModal()
|
||||
setActiveCategory(undefined)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditProductCategoriesSideModal
|
||||
close={hideEditModal}
|
||||
activeCategory={activeCategory}
|
||||
isVisible={!!activeCategory && isEditModalVisible}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ProductCategoriesContext.Provider>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
.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;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { PublishableApiKey, SalesChannel } from "@medusajs/medusa"
|
||||
import {
|
||||
useAdminAddPublishableKeySalesChannelsBatch,
|
||||
useAdminCreatePublishableApiKey,
|
||||
useAdminPublishableApiKeySalesChannels,
|
||||
} from "medusa-react"
|
||||
|
||||
import Breadcrumb from "../../../components/molecules/breadcrumb"
|
||||
@@ -40,7 +39,7 @@ function AddSalesChannelsSection(props: AddSalesChannelsSectionProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="inter-base-semibold text-grey-90 pb-1">
|
||||
Sales channels
|
||||
@@ -63,8 +62,8 @@ function AddSalesChannelsSection(props: AddSalesChannelsSectionProps) {
|
||||
{hasSelectedChannels && (
|
||||
<div className="mt-10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded p-1 border">
|
||||
<div className="bg-gray-100 p-2 rounded">
|
||||
<div className="rounded border p-1">
|
||||
<div className="rounded bg-gray-100 p-2">
|
||||
<ChannelsIcon />
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,7 +150,7 @@ function CreatePublishableKey(props: CreatePublishableKeyProps) {
|
||||
return (
|
||||
<FocusModal>
|
||||
<FocusModal.Header>
|
||||
<div className="medium:w-8/12 w-full px-8 flex justify-between">
|
||||
<div className="medium:w-8/12 flex w-full justify-between px-8">
|
||||
<Button size="small" variant="ghost" onClick={closeModal}>
|
||||
<CrossIcon size={20} />
|
||||
</Button>
|
||||
@@ -169,8 +168,8 @@ function CreatePublishableKey(props: CreatePublishableKeyProps) {
|
||||
</div>
|
||||
</FocusModal.Header>
|
||||
|
||||
<FocusModal.Main className="w-full no-scrollbar flex justify-center">
|
||||
<div className="medium:w-7/12 large:w-6/12 small:w-4/5 max-w-[700px] my-16">
|
||||
<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]">
|
||||
<h1 className="inter-xlarge-semibold text-grey-90 pb-8">
|
||||
Create API Key
|
||||
</h1>
|
||||
@@ -191,7 +190,7 @@ function CreatePublishableKey(props: CreatePublishableKeyProps) {
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
/>
|
||||
|
||||
<div className="w-[100%] h-[1px] bg-gray-200 mt-16 mb-8" />
|
||||
<div className="mt-16 mb-8 h-[1px] w-[100%] bg-gray-200" />
|
||||
|
||||
<AddSalesChannelsSection
|
||||
selectedChannels={selectedChannels}
|
||||
|
||||
Reference in New Issue
Block a user