From 2f76fbc6ed5c4220e68fd8003784f931daa7447a Mon Sep 17 00:00:00 2001
From: Kasper Fabricius Kristensen
<45367945+kasperkristensen@users.noreply.github.com>
Date: Wed, 12 Jun 2024 13:15:12 +0200
Subject: [PATCH] feat(dashboard,types,sdk,medusa,ui): ProductCategory domain
(#7675)
**What**
- Add missing features to ProductCategory domain in admin
- Add types
- Add SDK
**UI**
- Moves the TooltipProvider from the component to an export. Users should now wrap their entire application in a TooltipProvider. This change was made to take advantage of the built-in features of Radix Tooltip, and allows us to skip the delayDuration when moving the cursor from one tooltip to another within 500ms.
- Fixes the layout of the Hint component, as the create form revealed that it was off.
- Fixes an issue where focus styles were missing from the dropdown menu.
**Note**
- ~~We currently don't have an endpoint for deleting categories, so I have disabled the button in the admin. See CORE--2286~~ PR has been opened to add delete endpoint, so I have re-enabled the delete button.
- The update category workflow seems to be broken, it's possible for the `mpath` of a category to reach an invalid state, that breaks `include_descendants_tree` from working. See CORE-2287.
- The ProductCategory model is incorrect. All fields are optional and it's not possible to set the description to null, which means the only way of unsetting it is to set it to `""`. See CORE-2276.
- The design for the Organize drag-n-drop form is not final. Ludvig will create a final design, and we can then update the form.
- Currently, all things related to Metadata is left out, as we need to update the flow for metadata according to the latest designs.
RESOLVES CORE-1960, CORE-2230
*except for the above mentioned issues.
---
packages/admin-next/dashboard/package.json | 2 +
packages/admin-next/dashboard/src/app.tsx | 6 +-
.../common/action-menu/action-menu.tsx | 18 +-
.../error-boundary/error-boundary.tsx | 8 +-
.../dashboard/src/hooks/api/categories.tsx | 131 ++++++++--
.../dashboard/src/hooks/api/products.tsx | 2 +-
.../dashboard/src/i18n/translations/en.json | 59 ++++-
.../providers/router-provider/route-map.tsx | 28 ++
.../category-create/category-create.tsx | 16 ++
.../create-category-details.tsx | 135 ++++++++++
.../create-category-form.tsx | 191 ++++++++++++++
.../create-category-nesting.tsx | 157 +++++++++++
.../components/create-category-form/index.ts | 1 +
.../components/create-category-form/schema.ts | 17 ++
.../categories/category-create/index.ts | 1 +
.../category-detail/category-detail.tsx | 17 +-
.../category-general-section.tsx | 81 +++++-
.../category-organization-section/index.ts | 1 -
.../category-organize-section.tsx} | 46 ++--
.../category-organize-section/index.ts | 1 +
.../category-product-section.tsx | 144 +++++++++-
.../category-edit/category-edit.tsx | 29 +++
.../edit-category-form/edit-category-form.tsx | 174 +++++++++++++
.../components/edit-category-form/index.ts | 1 +
.../routes/categories/category-edit/index.ts | 1 +
.../category-list-table.tsx | 19 +-
.../use-category-table-columns.tsx | 10 +-
.../use-category-table-query.tsx | 2 +-
.../category-organize/category-organize.tsx | 14 +
.../organize-category-form/index.ts | 1 +
.../organize-category-form.tsx | 93 +++++++
.../categories/category-organize/index.ts | 1 +
.../category-products/category-products.tsx | 32 +++
.../edit-category-products-form.tsx | 245 ++++++++++++++++++
.../edit-category-products-form/index.ts | 1 +
.../categories/category-products/index.ts | 1 +
.../category-tree/category-tree.tsx | 226 ++++++++++++++++
.../common/components/category-tree/index.ts | 2 +
.../components/category-tree/styles.css | 39 +++
.../common/components/category-tree/types.ts | 7 +
.../src/routes/categories/common/utils.ts | 8 +-
.../category-combobox/category-combobox.tsx | 23 +-
packages/core/js-sdk/src/admin/index.ts | 3 +
.../core/js-sdk/src/admin/product-category.ts | 97 +++++++
.../types/src/http/product-category/admin.ts | 10 -
.../http/product-category/admin/entities.ts | 12 +
.../src/http/product-category/admin/index.ts | 4 +
.../http/product-category/admin/payloads.ts | 26 ++
.../http/product-category/admin/queries.ts | 12 +
.../http/product-category/admin/responses.ts | 14 +
.../types/src/http/product-category/common.ts | 44 +++-
.../types/src/http/product-category/store.ts | 10 -
.../http/product-category/store/entities.ts | 6 +
.../src/http/product-category/store/index.ts | 3 +
.../http/product-category/store/queries.ts | 9 +
.../http/product-category/store/responses.ts | 11 +
.../types/src/http/product/admin/entitites.ts | 6 +-
.../types/src/http/product/admin/queries.ts | 2 -
.../core/types/src/http/product/common.ts | 30 +--
.../types/src/http/product/store/entitites.ts | 7 +-
.../types/src/http/product/store/queries.ts | 4 +-
.../ui/src/components/copy/copy.spec.tsx | 3 +-
.../ui/src/components/copy/copy.stories.tsx | 2 +
.../dropdown-menu/dropdown-menu.tsx | 10 +-
.../ui/src/components/hint/hint.tsx | 4 +-
.../src/components/tooltip/tooltip.spec.tsx | 18 +-
.../components/tooltip/tooltip.stories.tsx | 3 +-
.../ui/src/components/tooltip/tooltip.tsx | 15 +-
packages/design-system/ui/src/index.ts | 2 +-
.../admin/product-categories/validators.ts | 4 +-
yarn.lock | 33 +++
71 files changed, 2208 insertions(+), 187 deletions(-)
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-create/category-create.tsx
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-details.tsx
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-nesting.tsx
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/index.ts
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/schema.ts
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-create/index.ts
delete mode 100644 packages/admin-next/dashboard/src/routes/categories/category-detail/components/category-organization-section/index.ts
rename packages/admin-next/dashboard/src/routes/categories/category-detail/components/{category-organization-section/category-organization-section.tsx => category-organize-section/category-organize-section.tsx} (81%)
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-detail/components/category-organize-section/index.ts
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-edit/category-edit.tsx
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-edit/components/edit-category-form/edit-category-form.tsx
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-edit/components/edit-category-form/index.ts
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-edit/index.ts
rename packages/admin-next/dashboard/src/{hooks/table/columns => routes/categories/category-list/components/category-list-table}/use-category-table-columns.tsx (92%)
rename packages/admin-next/dashboard/src/routes/categories/{common/hooks => category-list/components/category-list-table}/use-category-table-query.tsx (83%)
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-organize/category-organize.tsx
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-organize/components/organize-category-form/index.ts
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-organize/components/organize-category-form/organize-category-form.tsx
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-organize/index.ts
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-products/category-products.tsx
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-products/components/edit-category-products-form/edit-category-products-form.tsx
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-products/components/edit-category-products-form/index.ts
create mode 100644 packages/admin-next/dashboard/src/routes/categories/category-products/index.ts
create mode 100644 packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/category-tree.tsx
create mode 100644 packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/index.ts
create mode 100644 packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/styles.css
create mode 100644 packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/types.ts
create mode 100644 packages/core/js-sdk/src/admin/product-category.ts
delete mode 100644 packages/core/types/src/http/product-category/admin.ts
create mode 100644 packages/core/types/src/http/product-category/admin/entities.ts
create mode 100644 packages/core/types/src/http/product-category/admin/index.ts
create mode 100644 packages/core/types/src/http/product-category/admin/payloads.ts
create mode 100644 packages/core/types/src/http/product-category/admin/queries.ts
create mode 100644 packages/core/types/src/http/product-category/admin/responses.ts
delete mode 100644 packages/core/types/src/http/product-category/store.ts
create mode 100644 packages/core/types/src/http/product-category/store/entities.ts
create mode 100644 packages/core/types/src/http/product-category/store/index.ts
create mode 100644 packages/core/types/src/http/product-category/store/queries.ts
create mode 100644 packages/core/types/src/http/product-category/store/responses.ts
diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json
index 4e06531359..5d6cb4ffb8 100644
--- a/packages/admin-next/dashboard/package.json
+++ b/packages/admin-next/dashboard/package.json
@@ -47,6 +47,7 @@
"i18next": "23.7.11",
"i18next-browser-languagedetector": "7.2.0",
"i18next-http-backend": "2.4.2",
+ "lodash": "^4.17.21",
"match-sorter": "^6.3.4",
"qs": "^6.12.0",
"react": "^18.2.0",
@@ -56,6 +57,7 @@
"react-hook-form": "7.49.1",
"react-i18next": "13.5.0",
"react-jwt": "^1.2.0",
+ "react-nestable": "^3.0.2",
"react-resizable-panels": "^2.0.16",
"react-router-dom": "6.20.1",
"zod": "3.22.4"
diff --git a/packages/admin-next/dashboard/src/app.tsx b/packages/admin-next/dashboard/src/app.tsx
index 78ac778de2..def2787f4b 100644
--- a/packages/admin-next/dashboard/src/app.tsx
+++ b/packages/admin-next/dashboard/src/app.tsx
@@ -1,4 +1,4 @@
-import { Toaster } from "@medusajs/ui"
+import { Toaster, TooltipProvider } from "@medusajs/ui"
import { QueryClientProvider } from "@tanstack/react-query"
import { I18n } from "./components/utilities/i18n"
@@ -13,7 +13,9 @@ function App() {
-
+
+
+
diff --git a/packages/admin-next/dashboard/src/components/common/action-menu/action-menu.tsx b/packages/admin-next/dashboard/src/components/common/action-menu/action-menu.tsx
index 32ea7e190a..388f93f3f0 100644
--- a/packages/admin-next/dashboard/src/components/common/action-menu/action-menu.tsx
+++ b/packages/admin-next/dashboard/src/components/common/action-menu/action-menu.tsx
@@ -1,8 +1,8 @@
-import { DropdownMenu, IconButton } from "@medusajs/ui"
+import { DropdownMenu, IconButton, clx } from "@medusajs/ui"
import { EllipsisHorizontal } from "@medusajs/icons"
-import { Link } from "react-router-dom"
import { ReactNode } from "react"
+import { Link } from "react-router-dom"
type Action = {
icon: ReactNode
@@ -55,7 +55,12 @@ export const ActionMenu = ({ groups }: ActionMenuProps) => {
e.stopPropagation()
action.onClick()
}}
- className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
+ className={clx(
+ "[&_svg]:text-ui-fg-subtle flex items-center gap-x-2",
+ {
+ "[&_svg]:text-ui-fg-disabled": action.disabled,
+ }
+ )}
>
{action.icon}
{action.label}
@@ -66,7 +71,12 @@ export const ActionMenu = ({ groups }: ActionMenuProps) => {
return (
diff --git a/packages/admin-next/dashboard/src/components/utilities/error-boundary/error-boundary.tsx b/packages/admin-next/dashboard/src/components/utilities/error-boundary/error-boundary.tsx
index 0f653099f9..8750412056 100644
--- a/packages/admin-next/dashboard/src/components/utilities/error-boundary/error-boundary.tsx
+++ b/packages/admin-next/dashboard/src/components/utilities/error-boundary/error-boundary.tsx
@@ -3,7 +3,7 @@ import { Navigate, useLocation, useRouteError } from "react-router-dom"
import { ExclamationCircle } from "@medusajs/icons"
import { Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
-import { isAxiosError } from "../../../lib/is-axios-error"
+import { isFetchError } from "../../../lib/is-fetch-error"
// WIP - Need to allow wrapping with ErrorBoundary for more granular error handling.
export const ErrorBoundary = () => {
@@ -13,12 +13,12 @@ export const ErrorBoundary = () => {
let code: number | null = null
- if (isAxiosError(error)) {
- if (error.response?.status === 401) {
+ if (isFetchError(error)) {
+ if (error.status === 401) {
return
}
- code = error.response?.status ?? null
+ code = error.status ?? null
}
let title: string
diff --git a/packages/admin-next/dashboard/src/hooks/api/categories.tsx b/packages/admin-next/dashboard/src/hooks/api/categories.tsx
index b0a606915c..6fb726909c 100644
--- a/packages/admin-next/dashboard/src/hooks/api/categories.tsx
+++ b/packages/admin-next/dashboard/src/hooks/api/categories.tsx
@@ -1,22 +1,28 @@
+import { FetchError } from "@medusajs/js-sdk"
+import { HttpTypes } from "@medusajs/types"
import {
- AdminProductCategoryListResponse,
- AdminProductCategoryResponse,
-} from "@medusajs/types"
-import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
-import { client } from "../../lib/client"
+ QueryKey,
+ UseMutationOptions,
+ UseQueryOptions,
+ useMutation,
+ useQuery,
+} from "@tanstack/react-query"
+import { sdk } from "../../lib/client"
+import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory"
+import { productsQueryKeys } from "./products"
const CATEGORIES_QUERY_KEY = "categories" as const
export const categoriesQueryKeys = queryKeysFactory(CATEGORIES_QUERY_KEY)
-export const useCategory = (
+export const useProductCategory = (
id: string,
- query?: Record,
+ query?: HttpTypes.AdminProductCategoryParams,
options?: Omit<
UseQueryOptions<
- AdminProductCategoryResponse,
- Error,
- AdminProductCategoryResponse,
+ HttpTypes.AdminProductCategoryResponse,
+ FetchError,
+ HttpTypes.AdminProductCategoryResponse,
QueryKey
>,
"queryFn" | "queryKey"
@@ -24,20 +30,20 @@ export const useCategory = (
) => {
const { data, ...rest } = useQuery({
queryKey: categoriesQueryKeys.detail(id, query),
- queryFn: async () => client.categories.retrieve(id, query),
+ queryFn: () => sdk.admin.productCategory.retrieve(id, query),
...options,
})
return { ...data, ...rest }
}
-export const useCategories = (
- query?: Record,
+export const useProductCategories = (
+ query?: HttpTypes.AdminProductCategoryListParams,
options?: Omit<
UseQueryOptions<
- AdminProductCategoryListResponse,
- Error,
- AdminProductCategoryListResponse,
+ HttpTypes.AdminProductCategoryListResponse,
+ FetchError,
+ HttpTypes.AdminProductCategoryListResponse,
QueryKey
>,
"queryFn" | "queryKey"
@@ -45,9 +51,100 @@ export const useCategories = (
) => {
const { data, ...rest } = useQuery({
queryKey: categoriesQueryKeys.list(query),
- queryFn: async () => client.categories.list(query),
+ queryFn: () => sdk.admin.productCategory.list(query),
...options,
})
return { ...data, ...rest }
}
+
+export const useCreateProductCategory = (
+ options?: UseMutationOptions<
+ HttpTypes.AdminProductCategoryResponse,
+ FetchError,
+ HttpTypes.AdminCreateProductCategory
+ >
+) => {
+ return useMutation({
+ mutationFn: (payload) => sdk.admin.productCategory.create(payload),
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: categoriesQueryKeys.lists() })
+
+ options?.onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
+
+export const useUpdateProductCategory = (
+ id: string,
+ options?: UseMutationOptions<
+ HttpTypes.AdminProductCategoryResponse,
+ FetchError,
+ HttpTypes.AdminUpdateProductCategory
+ >
+) => {
+ return useMutation({
+ mutationFn: (payload) => sdk.admin.productCategory.update(id, payload),
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: categoriesQueryKeys.lists() })
+ queryClient.invalidateQueries({
+ queryKey: categoriesQueryKeys.detail(id),
+ })
+
+ options?.onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
+
+export const useDeleteProductCategory = (
+ id: string,
+ options?: UseMutationOptions<
+ HttpTypes.AdminProductCategoryDeleteResponse,
+ FetchError,
+ void
+ >
+) => {
+ return useMutation({
+ mutationFn: () => sdk.admin.productCategory.delete(id),
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({
+ queryKey: categoriesQueryKeys.detail(id),
+ })
+ queryClient.invalidateQueries({ queryKey: categoriesQueryKeys.lists() })
+
+ options?.onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
+
+export const useUpdateProductCategoryProducts = (
+ id: string,
+ options?: UseMutationOptions<
+ HttpTypes.AdminProductCategoryResponse,
+ FetchError,
+ HttpTypes.AdminUpdateProductCategoryProducts
+ >
+) => {
+ return useMutation({
+ mutationFn: (payload) =>
+ sdk.admin.productCategory.updateProducts(id, payload),
+ onSuccess: (data, variables, context) => {
+ queryClient.invalidateQueries({ queryKey: categoriesQueryKeys.lists() })
+ queryClient.invalidateQueries({
+ queryKey: categoriesQueryKeys.detail(id),
+ })
+ /**
+ * Invalidate products list query to ensure that the products collections are updated.
+ */
+ queryClient.invalidateQueries({
+ queryKey: productsQueryKeys.lists(),
+ })
+
+ options?.onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
diff --git a/packages/admin-next/dashboard/src/hooks/api/products.tsx b/packages/admin-next/dashboard/src/hooks/api/products.tsx
index 5fd08df636..f25353eb22 100644
--- a/packages/admin-next/dashboard/src/hooks/api/products.tsx
+++ b/packages/admin-next/dashboard/src/hooks/api/products.tsx
@@ -1,3 +1,4 @@
+import { HttpTypes } from "@medusajs/types"
import {
QueryKey,
useMutation,
@@ -8,7 +9,6 @@ import {
import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory"
-import { HttpTypes } from "@medusajs/types"
const PRODUCTS_QUERY_KEY = "products" as const
export const productsQueryKeys = queryKeysFactory(PRODUCTS_QUERY_KEY)
diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json
index 3111409f95..b63f1bcbc7 100644
--- a/packages/admin-next/dashboard/src/i18n/translations/en.json
+++ b/packages/admin-next/dashboard/src/i18n/translations/en.json
@@ -381,18 +381,57 @@
},
"categories": {
"domain": "Categories",
- "organization": {
- "header": "Organization",
- "pathLabel": "Path",
- "pathExpandTooltip": "Show full path",
- "childrenLabel": "Children"
+ "create": {
+ "header": "Create Category",
+ "hint": "Create a new category to organize your products.",
+ "tabs": {
+ "details": "Details",
+ "organize": "Organize"
+ },
+ "successToast": "Category {{name}} was successfully created."
+ },
+ "delete": {
+ "confirmation": "You are about to delete the category {{name}}. This action cannot be undone.",
+ "successToast": "Category {{name}} was successfully deleted."
+ },
+ "products": {
+ "add": {
+ "disabledTooltip": "The product is already in this category.",
+ "successToast_one": "Added {{count}} product to the category.",
+ "successToast_other": "Added {{count}} products to the category."
+ },
+ "remove": {
+ "confirmation_one": "You are about to remove {{count}} product from the category. This action cannot be undone.",
+ "confirmation_other": "You are about to remove {{count}} products from the category. This action cannot be undone.",
+ "successToast_one": "Removed {{count}} product from the category.",
+ "successToast_other": "Removed {{count}} products from the category."
+ }
+ },
+ "organize": {
+ "header": "Organize",
+ "action": "Edit ranking"
},
"fields": {
- "visibility": "Visibility",
- "active": "Active",
- "inactive": "Inactive",
- "internal": "Internal",
- "public": "Public"
+ "visibility": {
+ "label": "Visibility",
+ "internal": "Internal",
+ "public": "Public"
+ },
+ "status": {
+ "label": "Status",
+ "active": "Active",
+ "inactive": "Inactive"
+ },
+ "path": {
+ "label": "Path",
+ "tooltip": "Show the full path of the category."
+ },
+ "children": {
+ "label": "Children"
+ },
+ "new": {
+ "label": "New"
+ }
}
},
"inventory": {
diff --git a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx
index 10af394346..3ac0520989 100644
--- a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx
+++ b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx
@@ -127,6 +127,18 @@ export const RouteMap: RouteObject[] = [
{
path: "",
lazy: () => import("../../routes/categories/category-list"),
+ children: [
+ {
+ path: "create",
+ lazy: () =>
+ import("../../routes/categories/category-create"),
+ },
+ {
+ path: "organize",
+ lazy: () =>
+ import("../../routes/categories/category-organize"),
+ },
+ ],
},
{
path: ":id",
@@ -135,6 +147,22 @@ export const RouteMap: RouteObject[] = [
crumb: (data: AdminProductCategoryResponse) =>
data.product_category.name,
},
+ children: [
+ {
+ path: "edit",
+ lazy: () => import("../../routes/categories/category-edit"),
+ },
+ {
+ path: "products",
+ lazy: () =>
+ import("../../routes/categories/category-products"),
+ },
+ {
+ path: "organize",
+ lazy: () =>
+ import("../../routes/categories/category-organize"),
+ },
+ ],
},
],
},
diff --git a/packages/admin-next/dashboard/src/routes/categories/category-create/category-create.tsx b/packages/admin-next/dashboard/src/routes/categories/category-create/category-create.tsx
new file mode 100644
index 0000000000..b6575299dc
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/categories/category-create/category-create.tsx
@@ -0,0 +1,16 @@
+import { useSearchParams } from "react-router-dom"
+
+import { RouteFocusModal } from "../../../components/route-modal"
+import { CreateCategoryForm } from "./components/create-category-form/create-category-form"
+
+export const CategoryCreate = () => {
+ const [searchParams] = useSearchParams()
+
+ const parentCategoryId = searchParams.get("parent_category_id")
+
+ return (
+
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-details.tsx b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-details.tsx
new file mode 100644
index 0000000000..6a78b578c8
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-details.tsx
@@ -0,0 +1,135 @@
+import { Heading, Input, Select, Text, Textarea } from "@medusajs/ui"
+import { UseFormReturn } from "react-hook-form"
+import { useTranslation } from "react-i18next"
+
+import { Form } from "../../../../../components/common/form"
+import { HandleInput } from "../../../../../components/inputs/handle-input"
+import { CreateCategorySchema } from "./schema"
+
+type CreateCategoryDetailsProps = {
+ form: UseFormReturn
+}
+
+export const CreateCategoryDetails = ({ form }: CreateCategoryDetailsProps) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+ {t("categories.create.header")}
+
+ {t("categories.create.hint")}
+
+
+
+
{
+ return (
+
+ {t("fields.title")}
+
+
+
+
+
+ )
+ }}
+ />
+ {
+ return (
+
+
+ {t("fields.handle")}
+
+
+
+
+
+
+ )
+ }}
+ />
+
+
{
+ return (
+
+ {t("fields.description")}
+
+
+
+
+
+ )
+ }}
+ />
+
+
{
+ return (
+
+ {t("categories.fields.status.label")}
+
+
+
+
+
+ )
+ }}
+ />
+ {
+ return (
+
+
+ {t("categories.fields.visibility.label")}
+
+
+
+
+
+
+ )
+ }}
+ />
+
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx
new file mode 100644
index 0000000000..0cf86d5dda
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx
@@ -0,0 +1,191 @@
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Button, ProgressStatus, ProgressTabs, toast } from "@medusajs/ui"
+import { useForm } from "react-hook-form"
+import { useTranslation } from "react-i18next"
+
+import { useState } from "react"
+import {
+ RouteFocusModal,
+ useRouteModal,
+} from "../../../../../components/route-modal"
+import { useCreateProductCategory } from "../../../../../hooks/api/categories"
+import { CreateCategoryDetails } from "./create-category-details"
+import { CreateCategoryNesting } from "./create-category-nesting"
+import { CreateCategoryDetailsSchema, CreateCategorySchema } from "./schema"
+
+type CreateCategoryFormProps = {
+ parentCategoryId: string | null
+}
+
+enum Tab {
+ DETAILS = "details",
+ ORGANIZE = "organize",
+}
+
+export const CreateCategoryForm = ({
+ parentCategoryId,
+}: CreateCategoryFormProps) => {
+ const { t } = useTranslation()
+ const { handleSuccess } = useRouteModal()
+
+ const [activeTab, setActiveTab] = useState(Tab.DETAILS)
+ const [validDetails, setValidDetails] = useState(false)
+
+ const form = useForm({
+ defaultValues: {
+ name: "",
+ description: "",
+ handle: "",
+ status: "active",
+ visibility: "public",
+ rank: parentCategoryId ? 0 : null,
+ parent_category_id: parentCategoryId,
+ },
+ resolver: zodResolver(CreateCategorySchema),
+ })
+
+ const handleTabChange = (tab: Tab) => {
+ if (tab === Tab.ORGANIZE) {
+ const { name, handle, description, status, visibility } = form.getValues()
+
+ const result = CreateCategoryDetailsSchema.safeParse({
+ name,
+ handle,
+ description,
+ status,
+ visibility,
+ })
+
+ if (!result.success) {
+ result.error.errors.forEach((error) => {
+ form.setError(error.path.join(".") as keyof CreateCategorySchema, {
+ type: "manual",
+ message: error.message,
+ })
+ })
+
+ return
+ }
+
+ form.clearErrors()
+ setValidDetails(true)
+ }
+
+ setActiveTab(tab)
+ }
+
+ const { mutateAsync, isPending } = useCreateProductCategory()
+
+ const handleSubmit = form.handleSubmit((data) => {
+ const { visibility, status, parent_category_id, rank, ...rest } = data
+
+ mutateAsync(
+ {
+ ...rest,
+ parent_category_id: parent_category_id ?? undefined,
+ rank: rank ?? undefined,
+ is_active: status === "active",
+ is_internal: visibility === "internal",
+ },
+ {
+ onSuccess: ({ product_category }) => {
+ toast.success(t("general.success"), {
+ description: t("categories.create.successToast", {
+ name: product_category.name,
+ }),
+ dismissable: true,
+ dismissLabel: t("actions.close"),
+ })
+
+ handleSuccess(`/categories/${product_category.id}`)
+ },
+ onError: (error) => {
+ toast.error(t("general.error"), {
+ description: error.message,
+ dismissable: true,
+ dismissLabel: t("actions.close"),
+ })
+ },
+ }
+ )
+ })
+
+ const nestingStatus: ProgressStatus =
+ form.getFieldState("parent_category_id")?.isDirty ||
+ form.getFieldState("rank")?.isDirty ||
+ activeTab === Tab.ORGANIZE
+ ? "in-progress"
+ : "not-started"
+
+ const detailsStatus: ProgressStatus = validDetails
+ ? "completed"
+ : "in-progress"
+
+ return (
+
+ handleTabChange(tab as Tab)}
+ >
+
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-nesting.tsx b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-nesting.tsx
new file mode 100644
index 0000000000..4c7dc8aeec
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-nesting.tsx
@@ -0,0 +1,157 @@
+import { useMemo } from "react"
+import { UseFormReturn, useWatch } from "react-hook-form"
+import { useProductCategories } from "../../../../../hooks/api/categories"
+import {
+ CategoryTree,
+ CategoryTreeItem,
+} from "../../../common/components/category-tree"
+import { CreateCategorySchema } from "./schema"
+
+type CreateCategoryNestingProps = {
+ form: UseFormReturn
+}
+
+const ID = "new-item"
+
+export const CreateCategoryNesting = ({ form }: CreateCategoryNestingProps) => {
+ const { product_categories, isPending, isError, error } =
+ useProductCategories(
+ {
+ parent_category_id: "null",
+ limit: 9999,
+ fields: "id,name,parent_category_id,rank,category_children",
+ include_descendants_tree: true,
+ },
+ {
+ refetchInterval: Infinity, // Once the data is loaded we don't need to refetch
+ }
+ )
+
+ const parentCategoryId = useWatch({
+ control: form.control,
+ name: "parent_category_id",
+ })
+
+ const watchedRank = useWatch({
+ control: form.control,
+ name: "rank",
+ })
+
+ const watchedName = useWatch({
+ control: form.control,
+ name: "name",
+ })
+
+ const value = useMemo(() => {
+ const temp = {
+ id: ID,
+ name: watchedName,
+ parent_category_id: parentCategoryId,
+ rank: watchedRank,
+ }
+
+ console.log("inserting", temp)
+
+ return insertCategoryTreeItem(product_categories ?? [], temp)
+ }, [product_categories, watchedName, parentCategoryId, watchedRank])
+
+ const handleChange = ({ parent_category_id, rank }: CategoryTreeItem) => {
+ form.setValue("parent_category_id", parent_category_id, {
+ shouldDirty: true,
+ shouldTouch: true,
+ })
+
+ console.log("rank", rank)
+ form.setValue("rank", rank, {
+ shouldDirty: true,
+ shouldTouch: true,
+ })
+ }
+
+ if (isError) {
+ throw error
+ }
+
+ return (
+ i.id === ID}
+ showBadge={(i) => i.id === ID}
+ isLoading={isPending || !product_categories}
+ />
+ )
+}
+
+/**
+ * Since we allow the user to go back and forth between the two steps of the form,
+ * we need to handle restoring the state of the tree when it re-renders.
+ */
+const insertCategoryTreeItem = (
+ categories: CategoryTreeItem[],
+ newItem: Omit
+): CategoryTreeItem[] => {
+ const seen = new Set()
+
+ const remove = (
+ items: CategoryTreeItem[],
+ id: string
+ ): CategoryTreeItem[] => {
+ const stack = [...items]
+ const result: CategoryTreeItem[] = []
+
+ while (stack.length > 0) {
+ const item = stack.pop()!
+ if (item.id !== id) {
+ if (item.category_children) {
+ item.category_children = remove(item.category_children, id)
+ }
+ result.push(item)
+ }
+ }
+
+ return result
+ }
+
+ const insert = (items: CategoryTreeItem[]): CategoryTreeItem[] => {
+ const stack = [...items]
+
+ while (stack.length > 0) {
+ const item = stack.pop()!
+ if (seen.has(item.id)) {
+ continue // Prevent revisiting the same node
+ }
+ seen.add(item.id)
+
+ if (item.id === newItem.parent_category_id) {
+ if (!item.category_children) {
+ item.category_children = []
+ }
+ item.category_children.push({ ...newItem, category_children: null })
+ item.category_children.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))
+ return categories
+ }
+ if (item.category_children) {
+ stack.push(...item.category_children)
+ }
+ }
+ return items
+ }
+
+ categories = remove(categories, newItem.id)
+
+ if (newItem.parent_category_id === null && newItem.rank === null) {
+ categories.unshift({ ...newItem, category_children: null })
+ } else if (newItem.parent_category_id === null && newItem.rank !== null) {
+ categories.splice(newItem.rank, 0, {
+ ...newItem,
+ category_children: null,
+ })
+ } else {
+ categories = insert(categories)
+ }
+
+ categories.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))
+
+ return categories
+}
diff --git a/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/index.ts b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/index.ts
new file mode 100644
index 0000000000..4eca73ac4e
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/index.ts
@@ -0,0 +1 @@
+export * from "./create-category-form"
diff --git a/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/schema.ts b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/schema.ts
new file mode 100644
index 0000000000..e3e7270e7f
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/schema.ts
@@ -0,0 +1,17 @@
+import { z } from "zod"
+
+export const CreateCategoryDetailsSchema = z.object({
+ name: z.string().min(1),
+ description: z.string().optional(),
+ handle: z.string().optional(),
+ status: z.enum(["active", "inactive"]),
+ visibility: z.enum(["public", "internal"]),
+})
+
+export type CreateCategorySchema = z.infer
+export const CreateCategorySchema = z
+ .object({
+ rank: z.number().nullable(),
+ parent_category_id: z.string().nullable(),
+ })
+ .merge(CreateCategoryDetailsSchema)
diff --git a/packages/admin-next/dashboard/src/routes/categories/category-create/index.ts b/packages/admin-next/dashboard/src/routes/categories/category-create/index.ts
new file mode 100644
index 0000000000..6147602afc
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/categories/category-create/index.ts
@@ -0,0 +1 @@
+export { CategoryCreate as Component } from "./category-create"
diff --git a/packages/admin-next/dashboard/src/routes/categories/category-detail/category-detail.tsx b/packages/admin-next/dashboard/src/routes/categories/category-detail/category-detail.tsx
index a35ad399cc..c37f26d2a2 100644
--- a/packages/admin-next/dashboard/src/routes/categories/category-detail/category-detail.tsx
+++ b/packages/admin-next/dashboard/src/routes/categories/category-detail/category-detail.tsx
@@ -1,8 +1,8 @@
-import { useLoaderData, useParams } from "react-router-dom"
+import { Outlet, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
-import { useCategory } from "../../../hooks/api/categories"
+import { useProductCategory } from "../../../hooks/api/categories"
import { CategoryGeneralSection } from "./components/category-general-section"
-import { CategoryOrganizationSection } from "./components/category-organization-section"
+import { CategoryOrganizeSection } from "./components/category-organize-section"
import { CategoryProductSection } from "./components/category-product-section"
import { categoryLoader } from "./loader"
@@ -18,7 +18,7 @@ export const CategoryDetail = () => {
ReturnType
>
- const { product_category, isLoading, isError, error } = useCategory(
+ const { product_category, isLoading, isError, error } = useProductCategory(
id!,
undefined,
{
@@ -35,7 +35,7 @@ export const CategoryDetail = () => {
}
return (
-
+
{before.widgets.map((w, i) => {
return (
@@ -43,7 +43,7 @@ export const CategoryDetail = () => {
)
})}
-
+
@@ -58,7 +58,7 @@ export const CategoryDetail = () => {
-
+
{sideBefore.widgets.map((w, i) => {
return (
@@ -66,7 +66,7 @@ export const CategoryDetail = () => {
)
})}
-
+
{sideAfter.widgets.map((w, i) => {
return (
@@ -79,6 +79,7 @@ export const CategoryDetail = () => {
+
)
}
diff --git a/packages/admin-next/dashboard/src/routes/categories/category-detail/components/category-general-section/category-general-section.tsx b/packages/admin-next/dashboard/src/routes/categories/category-detail/components/category-general-section/category-general-section.tsx
index a16315386f..861f13dee6 100644
--- a/packages/admin-next/dashboard/src/routes/categories/category-detail/components/category-general-section/category-general-section.tsx
+++ b/packages/admin-next/dashboard/src/routes/categories/category-detail/components/category-general-section/category-general-section.tsx
@@ -1,21 +1,73 @@
-import { AdminProductCategoryResponse } from "@medusajs/types"
-import { Container, Heading, StatusBadge, Text } from "@medusajs/ui"
+import { PencilSquare, Trash } from "@medusajs/icons"
+import { HttpTypes } from "@medusajs/types"
+import {
+ Container,
+ Heading,
+ StatusBadge,
+ Text,
+ toast,
+ usePrompt,
+} from "@medusajs/ui"
import { useTranslation } from "react-i18next"
+import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
+import { useDeleteProductCategory } from "../../../../../hooks/api/categories"
import { getIsActiveProps, getIsInternalProps } from "../../../common/utils"
type CategoryGeneralSectionProps = {
- category: AdminProductCategoryResponse["product_category"]
+ category: HttpTypes.AdminProductCategory
}
export const CategoryGeneralSection = ({
category,
}: CategoryGeneralSectionProps) => {
const { t } = useTranslation()
+ const navigate = useNavigate()
+ const prompt = usePrompt()
const activeProps = getIsActiveProps(category.is_active, t)
const internalProps = getIsInternalProps(category.is_internal, t)
+ const { mutateAsync } = useDeleteProductCategory(category.id)
+
+ const handleDelete = async () => {
+ const res = await prompt({
+ title: t("general.areYouSure"),
+ description: t("categories.delete.confirmation", {
+ name: category.name,
+ }),
+ confirmText: t("actions.delete"),
+ cancelText: t("actions.cancel"),
+ })
+
+ if (!res) {
+ return
+ }
+
+ await mutateAsync(undefined, {
+ onSuccess: () => {
+ toast.success(t("general.success"), {
+ description: t("categories.delete.successToast", {
+ name: category.name,
+ }),
+ dismissable: true,
+ dismissLabel: t("actions.close"),
+ })
+
+ navigate("/categories", {
+ replace: true,
+ })
+ },
+ onError: (e) => {
+ toast.error(t("general.error"), {
+ description: e.message,
+ dismissable: true,
+ dismissLabel: t("actions.close"),
+ })
+ },
+ })
+ }
+
return (
@@ -29,7 +81,28 @@ export const CategoryGeneralSection = ({
{internalProps.label}
-
+ ,
+ to: "edit",
+ },
+ ],
+ },
+ {
+ actions: [
+ {
+ label: t("actions.delete"),
+ icon: ,
+ onClick: handleDelete,
+ },
+ ],
+ },
+ ]}
+ />
diff --git a/packages/admin-next/dashboard/src/routes/categories/category-detail/components/category-organization-section/index.ts b/packages/admin-next/dashboard/src/routes/categories/category-detail/components/category-organization-section/index.ts
deleted file mode 100644
index 82e80f1664..0000000000
--- a/packages/admin-next/dashboard/src/routes/categories/category-detail/components/category-organization-section/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./category-organization-section"
diff --git a/packages/admin-next/dashboard/src/routes/categories/category-detail/components/category-organization-section/category-organization-section.tsx b/packages/admin-next/dashboard/src/routes/categories/category-detail/components/category-organize-section/category-organize-section.tsx
similarity index 81%
rename from packages/admin-next/dashboard/src/routes/categories/category-detail/components/category-organization-section/category-organization-section.tsx
rename to packages/admin-next/dashboard/src/routes/categories/category-detail/components/category-organize-section/category-organize-section.tsx
index 84b909a838..e34865fd9c 100644
--- a/packages/admin-next/dashboard/src/routes/categories/category-detail/components/category-organization-section/category-organization-section.tsx
+++ b/packages/admin-next/dashboard/src/routes/categories/category-detail/components/category-organize-section/category-organize-section.tsx
@@ -1,5 +1,9 @@
-import { FolderIllustration, TriangleRightMini } from "@medusajs/icons"
-import { AdminProductCategoryResponse } from "@medusajs/types"
+import {
+ FolderIllustration,
+ PencilSquare,
+ TriangleRightMini,
+} from "@medusajs/icons"
+import { AdminProductCategoryResponse, HttpTypes } from "@medusajs/types"
import { Badge, Container, Heading, Text, Tooltip } from "@medusajs/ui"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
@@ -7,33 +11,45 @@ import { Link } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { InlineLink } from "../../../../../components/common/inline-link"
import { Skeleton } from "../../../../../components/common/skeleton"
-import { useCategory } from "../../../../../hooks/api/categories"
+import { useProductCategory } from "../../../../../hooks/api/categories"
import { getCategoryChildren, getCategoryPath } from "../../../common/utils"
-type CategoryOrganizationSectionProps = {
- category: AdminProductCategoryResponse["product_category"]
+type CategoryOrganizeSectionProps = {
+ category: HttpTypes.AdminProductCategory
}
-export const CategoryOrganizationSection = ({
+export const CategoryOrganizeSection = ({
category,
-}: CategoryOrganizationSectionProps) => {
+}: CategoryOrganizeSectionProps) => {
const { t } = useTranslation()
return (
-
{t("categories.organization.header")}
-
+
{t("categories.organize.header")}
+
,
+ to: `organize`,
+ },
+ ],
+ },
+ ]}
+ />
- {t("categories.organization.pathLabel")}
+ {t("categories.fields.path.label")}
- {t("categories.organization.childrenLabel")}
+ {t("categories.fields.children.label")}
@@ -55,9 +71,9 @@ const PathDisplay = ({
isLoading,
isError,
error,
- } = useCategory(category.id, {
+ } = useProductCategory(category.id, {
include_ancestors_tree: true,
- fields: "id,name,parent_category",
+ fields: "id,name,*parent_category",
})
const chips = useMemo(() => getCategoryPath(withParents), [withParents])
@@ -83,7 +99,7 @@ const PathDisplay = ({
-
+