From b5662d0c6ad5a26a49afcb75401790a25525aa09 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Tue, 7 Mar 2023 13:29:22 +0100 Subject: [PATCH] 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 --- .changeset/cyan-ghosts-flash.md | 5 + packages/admin-ui/package.json | 1 + .../icons/folder-open-icon/index.tsx | 29 ++++ .../fundamentals/icons/reorder-icon/index.tsx | 64 +++++++ .../icons/triangle-mini-icon/index.tsx | 28 ++++ .../components/molecules/modal/side-modal.tsx | 6 +- .../src/components/molecules/select/index.tsx | 13 +- .../components/organisms/sidebar/index.tsx | 1 + .../components/product-categories-list.tsx | 127 ++++++++++++++ .../product-category-list-item-details.tsx | 132 +++++++++++++++ .../modals/add-product-category.tsx | 145 ++++++++++++++++ .../modals/edit-product-category.tsx | 158 ++++++++++++++++++ .../domain/product-categories/pages/index.tsx | 74 +++++++- .../styles/product-categories.css | 23 +++ .../publishable-api-keys/pages/index.tsx | 15 +- yarn.lock | 74 +++++++- 16 files changed, 873 insertions(+), 22 deletions(-) create mode 100644 .changeset/cyan-ghosts-flash.md create mode 100644 packages/admin-ui/ui/src/components/fundamentals/icons/folder-open-icon/index.tsx create mode 100644 packages/admin-ui/ui/src/components/fundamentals/icons/reorder-icon/index.tsx create mode 100644 packages/admin-ui/ui/src/components/fundamentals/icons/triangle-mini-icon/index.tsx create mode 100644 packages/admin-ui/ui/src/domain/product-categories/components/product-categories-list.tsx create mode 100644 packages/admin-ui/ui/src/domain/product-categories/components/product-category-list-item-details.tsx create mode 100644 packages/admin-ui/ui/src/domain/product-categories/modals/add-product-category.tsx create mode 100644 packages/admin-ui/ui/src/domain/product-categories/modals/edit-product-category.tsx create mode 100644 packages/admin-ui/ui/src/domain/product-categories/styles/product-categories.css diff --git a/.changeset/cyan-ghosts-flash.md b/.changeset/cyan-ghosts-flash.md new file mode 100644 index 0000000000..9a4cec544d --- /dev/null +++ b/.changeset/cyan-ghosts-flash.md @@ -0,0 +1,5 @@ +--- +"@medusajs/admin-ui": patch +--- + +feat(admin-ui): adds category ui for tree/list, edit, create, delete diff --git a/packages/admin-ui/package.json b/packages/admin-ui/package.json index 86f6f1deac..9f67cf368e 100644 --- a/packages/admin-ui/package.json +++ b/packages/admin-ui/package.json @@ -63,6 +63,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.8.0", "react-select": "^5.5.4", "react-table": "^7.7.0", diff --git a/packages/admin-ui/ui/src/components/fundamentals/icons/folder-open-icon/index.tsx b/packages/admin-ui/ui/src/components/fundamentals/icons/folder-open-icon/index.tsx new file mode 100644 index 0000000000..59703296d1 --- /dev/null +++ b/packages/admin-ui/ui/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/packages/admin-ui/ui/src/components/fundamentals/icons/reorder-icon/index.tsx b/packages/admin-ui/ui/src/components/fundamentals/icons/reorder-icon/index.tsx new file mode 100644 index 0000000000..6c28116825 --- /dev/null +++ b/packages/admin-ui/ui/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/packages/admin-ui/ui/src/components/fundamentals/icons/triangle-mini-icon/index.tsx b/packages/admin-ui/ui/src/components/fundamentals/icons/triangle-mini-icon/index.tsx new file mode 100644 index 0000000000..f0a4c50908 --- /dev/null +++ b/packages/admin-ui/ui/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/packages/admin-ui/ui/src/components/molecules/modal/side-modal.tsx b/packages/admin-ui/ui/src/components/molecules/modal/side-modal.tsx index 14f4933403..9ecee2081d 100644 --- a/packages/admin-ui/ui/src/components/molecules/modal/side-modal.tsx +++ b/packages/admin-ui/ui/src/components/molecules/modal/side-modal.tsx @@ -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 }} > diff --git a/packages/admin-ui/ui/src/components/molecules/select/index.tsx b/packages/admin-ui/ui/src/components/molecules/select/index.tsx index 85cea7fbef..c32778a8f0 100644 --- a/packages/admin-ui/ui/src/components/molecules/select/index.tsx +++ b/packages/admin-ui/ui/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 ) => { @@ -135,10 +138,10 @@ const SSelect = React.forwardRef(
-
+
@@ -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 &&
} + {isFocussed && enableSearch &&
}
) diff --git a/packages/admin-ui/ui/src/components/organisms/sidebar/index.tsx b/packages/admin-ui/ui/src/components/organisms/sidebar/index.tsx index 14ee3a30ac..aff6957860 100644 --- a/packages/admin-ui/ui/src/components/organisms/sidebar/index.tsx +++ b/packages/admin-ui/ui/src/components/organisms/sidebar/index.tsx @@ -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" diff --git a/packages/admin-ui/ui/src/domain/product-categories/components/product-categories-list.tsx b/packages/admin-ui/ui/src/domain/product-categories/components/product-categories-list.tsx new file mode 100644 index 0000000000..18c513e6e7 --- /dev/null +++ b/packages/admin-ui/ui/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/packages/admin-ui/ui/src/domain/product-categories/components/product-category-list-item-details.tsx b/packages/admin-ui/ui/src/domain/product-categories/components/product-category-list-item-details.tsx new file mode 100644 index 0000000000..ff9e7b6995 --- /dev/null +++ b/packages/admin-ui/ui/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/packages/admin-ui/ui/src/domain/product-categories/modals/add-product-category.tsx b/packages/admin-ui/ui/src/domain/product-categories/modals/add-product-category.tsx new file mode 100644 index 0000000000..efca73d405 --- /dev/null +++ b/packages/admin-ui/ui/src/domain/product-categories/modals/add-product-category.tsx @@ -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 ( + + +
+ +
+ +
+
+
+ + +
+

+ 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/packages/admin-ui/ui/src/domain/product-categories/modals/edit-product-category.tsx b/packages/admin-ui/ui/src/domain/product-categories/modals/edit-product-category.tsx new file mode 100644 index 0000000000..bdf7d22911 --- /dev/null +++ b/packages/admin-ui/ui/src/domain/product-categories/modals/edit-product-category.tsx @@ -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 ( + +
+ {/* === 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/packages/admin-ui/ui/src/domain/product-categories/pages/index.tsx b/packages/admin-ui/ui/src/domain/product-categories/pages/index.tsx index ecb26804d3..2fa5fcc59d 100644 --- a/packages/admin-ui/ui/src/domain/product-categories/pages/index.tsx +++ b/packages/admin-ui/ui/src/domain/product-categories/pages/index.tsx @@ -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() + + 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 ( @@ -43,8 +88,27 @@ function ProductCategoryPage() { footerMinHeight={40} setBorders > - + {showPlaceholder ? ( + + ) : isLoading ? null : ( + + )} + {isCreateModalVisible && ( + { + hideCreateModal() + setActiveCategory(undefined) + }} + /> + )} + +
diff --git a/packages/admin-ui/ui/src/domain/product-categories/styles/product-categories.css b/packages/admin-ui/ui/src/domain/product-categories/styles/product-categories.css new file mode 100644 index 0000000000..8cf84d431b --- /dev/null +++ b/packages/admin-ui/ui/src/domain/product-categories/styles/product-categories.css @@ -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; +} diff --git a/packages/admin-ui/ui/src/domain/publishable-api-keys/pages/index.tsx b/packages/admin-ui/ui/src/domain/publishable-api-keys/pages/index.tsx index 16908bc1db..168b9f5f13 100644 --- a/packages/admin-ui/ui/src/domain/publishable-api-keys/pages/index.tsx +++ b/packages/admin-ui/ui/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" @@ -40,7 +39,7 @@ function AddSalesChannelsSection(props: AddSalesChannelsSectionProps) { return (
-
+
Sales channels @@ -63,8 +62,8 @@ function AddSalesChannelsSection(props: AddSalesChannelsSectionProps) { {hasSelectedChannels && (
-
-
+
+
@@ -151,7 +150,7 @@ function CreatePublishableKey(props: CreatePublishableKeyProps) { return ( -
+
@@ -169,8 +168,8 @@ function CreatePublishableKey(props: CreatePublishableKeyProps) {
- -
+ +

Create API Key

@@ -191,7 +190,7 @@ function CreatePublishableKey(props: CreatePublishableKeyProps) { onChange={(ev) => setName(ev.target.value)} /> -
+