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")} + +