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.
This commit is contained in:
committed by
GitHub
parent
73ca358606
commit
2f76fbc6ed
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<I18n />
|
||||
<RouterProvider />
|
||||
<TooltipProvider>
|
||||
<RouterProvider />
|
||||
</TooltipProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -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}
|
||||
<span>{action.label}</span>
|
||||
@@ -66,7 +71,12 @@ export const ActionMenu = ({ groups }: ActionMenuProps) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
<DropdownMenu.Item
|
||||
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,
|
||||
}
|
||||
)}
|
||||
asChild
|
||||
disabled={action.disabled}
|
||||
>
|
||||
|
||||
@@ -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 <Outlet> 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 <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
code = error.response?.status ?? null
|
||||
code = error.status ?? null
|
||||
}
|
||||
|
||||
let title: string
|
||||
|
||||
@@ -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<string, any>,
|
||||
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<string, any>,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<RouteFocusModal>
|
||||
<CreateCategoryForm parentCategoryId={parentCategoryId} />
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
@@ -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<CreateCategorySchema>
|
||||
}
|
||||
|
||||
export const CreateCategoryDetails = ({ form }: CreateCategoryDetailsProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center p-16">
|
||||
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
|
||||
<div>
|
||||
<Heading>{t("categories.create.header")}</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{t("categories.create.hint")}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.title")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input autoComplete="off" {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="handle"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional tooltip={t("collections.handleTooltip")}>
|
||||
{t("fields.handle")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<HandleInput {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>{t("fields.description")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Textarea {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field: { ref, onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("categories.fields.status.label")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Select {...field} onValueChange={onChange}>
|
||||
<Select.Trigger ref={ref}>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="active">
|
||||
{t("categories.fields.status.active")}
|
||||
</Select.Item>
|
||||
<Select.Item value="inactive">
|
||||
{t("categories.fields.status.inactive")}
|
||||
</Select.Item>
|
||||
</Select.Content>
|
||||
</Select>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field: { ref, onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>
|
||||
{t("categories.fields.visibility.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Select {...field} onValueChange={onChange}>
|
||||
<Select.Trigger ref={ref}>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="public">
|
||||
{t("categories.fields.visibility.public")}
|
||||
</Select.Item>
|
||||
<Select.Item value="internal">
|
||||
{t("categories.fields.visibility.internal")}
|
||||
</Select.Item>
|
||||
</Select.Content>
|
||||
</Select>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>(Tab.DETAILS)
|
||||
const [validDetails, setValidDetails] = useState(false)
|
||||
|
||||
const form = useForm<CreateCategorySchema>({
|
||||
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 (
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<ProgressTabs
|
||||
value={activeTab}
|
||||
onValueChange={(tab) => handleTabChange(tab as Tab)}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<RouteFocusModal.Header>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="-my-2 w-fit border-l">
|
||||
<ProgressTabs.List className="grid w-full grid-cols-4">
|
||||
<ProgressTabs.Trigger
|
||||
value={Tab.DETAILS}
|
||||
status={detailsStatus}
|
||||
>
|
||||
{t("categories.create.tabs.details")}
|
||||
</ProgressTabs.Trigger>
|
||||
<ProgressTabs.Trigger
|
||||
value={Tab.ORGANIZE}
|
||||
status={nestingStatus}
|
||||
>
|
||||
{t("categories.create.tabs.organize")}
|
||||
</ProgressTabs.Trigger>
|
||||
</ProgressTabs.List>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button size="small" variant="secondary">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
{activeTab === Tab.ORGANIZE ? (
|
||||
<Button
|
||||
key="submit-btn"
|
||||
size="small"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isPending}
|
||||
>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key="continue-btn"
|
||||
size="small"
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={() => handleTabChange(Tab.ORGANIZE)}
|
||||
>
|
||||
{t("actions.continue")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body>
|
||||
<ProgressTabs.Content value={Tab.DETAILS}>
|
||||
<CreateCategoryDetails form={form} />
|
||||
</ProgressTabs.Content>
|
||||
<ProgressTabs.Content value={Tab.ORGANIZE}>
|
||||
<CreateCategoryNesting form={form} />
|
||||
</ProgressTabs.Content>
|
||||
</RouteFocusModal.Body>
|
||||
</form>
|
||||
</ProgressTabs>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
@@ -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<CreateCategorySchema>
|
||||
}
|
||||
|
||||
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 (
|
||||
<CategoryTree
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
enableDrag={(i) => 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, "category_children">
|
||||
): CategoryTreeItem[] => {
|
||||
const seen = new Set<string>()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./create-category-form"
|
||||
@@ -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<typeof CreateCategorySchema>
|
||||
export const CreateCategorySchema = z
|
||||
.object({
|
||||
rank: z.number().nullable(),
|
||||
parent_category_id: z.string().nullable(),
|
||||
})
|
||||
.merge(CreateCategoryDetailsSchema)
|
||||
@@ -0,0 +1 @@
|
||||
export { CategoryCreate as Component } from "./category-create"
|
||||
@@ -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<typeof categoryLoader>
|
||||
>
|
||||
|
||||
const { product_category, isLoading, isError, error } = useCategory(
|
||||
const { product_category, isLoading, isError, error } = useProductCategory(
|
||||
id!,
|
||||
undefined,
|
||||
{
|
||||
@@ -35,7 +35,7 @@ export const CategoryDetail = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="flex flex-col gap-y-3">
|
||||
{before.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
@@ -43,7 +43,7 @@ export const CategoryDetail = () => {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex flex-col gap-x-4 xl:flex-row xl:items-start">
|
||||
<div className="flex flex-col gap-x-4 gap-y-3 xl:flex-row xl:items-start">
|
||||
<div className="flex w-full flex-col gap-y-3">
|
||||
<CategoryGeneralSection category={product_category} />
|
||||
<CategoryProductSection category={product_category} />
|
||||
@@ -58,7 +58,7 @@ export const CategoryDetail = () => {
|
||||
<JsonViewSection data={product_category} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex w-full max-w-[100%] flex-col gap-y-2 xl:mt-0 xl:max-w-[400px]">
|
||||
<div className="flex w-full max-w-[100%] flex-col gap-y-3 xl:max-w-[400px]">
|
||||
{sideBefore.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
@@ -66,7 +66,7 @@ export const CategoryDetail = () => {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<CategoryOrganizationSection category={product_category} />
|
||||
<CategoryOrganizeSection category={product_category} />
|
||||
{sideAfter.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
@@ -79,6 +79,7 @@ export const CategoryDetail = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
@@ -29,7 +81,28 @@ export const CategoryGeneralSection = ({
|
||||
{internalProps.label}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<ActionMenu groups={[]} />
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.edit"),
|
||||
icon: <PencilSquare />,
|
||||
to: "edit",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.delete"),
|
||||
icon: <Trash />,
|
||||
onClick: handleDelete,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 gap-3 px-6 py-4">
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./category-organization-section"
|
||||
@@ -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 (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading level="h2">{t("categories.organization.header")}</Heading>
|
||||
<ActionMenu groups={[]} />
|
||||
<Heading level="h2">{t("categories.organize.header")}</Heading>
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("categories.organize.action"),
|
||||
icon: <PencilSquare />,
|
||||
to: `organize`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-start gap-3 px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("categories.organization.pathLabel")}
|
||||
{t("categories.fields.path.label")}
|
||||
</Text>
|
||||
<PathDisplay category={category} />
|
||||
</div>
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-start gap-3 px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("categories.organization.childrenLabel")}
|
||||
{t("categories.fields.children.label")}
|
||||
</Text>
|
||||
<ChildrenDisplay category={category} />
|
||||
</div>
|
||||
@@ -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 = ({
|
||||
<div className="grid grid-cols-[20px_1fr] items-start gap-x-2">
|
||||
<FolderIllustration />
|
||||
<div className="flex items-center gap-x-0.5">
|
||||
<Tooltip content={t("categories.organization.pathExpandTooltip")}>
|
||||
<Tooltip content={t("categories.fields.path.tooltip")}>
|
||||
<button
|
||||
className="outline-none"
|
||||
type="button"
|
||||
@@ -161,7 +177,7 @@ const ChildrenDisplay = ({
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useCategory(category.id, {
|
||||
} = useProductCategory(category.id, {
|
||||
include_descendants_tree: true,
|
||||
fields: "id,name,category_children",
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./category-organize-section"
|
||||
@@ -1,9 +1,21 @@
|
||||
import { AdminProductCategoryResponse } from "@medusajs/types"
|
||||
import { Container, Heading } from "@medusajs/ui"
|
||||
import { PlusMini } from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import {
|
||||
Checkbox,
|
||||
CommandBar,
|
||||
Container,
|
||||
Heading,
|
||||
toast,
|
||||
usePrompt,
|
||||
} from "@medusajs/ui"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { useUpdateProductCategoryProducts } from "../../../../../hooks/api/categories"
|
||||
import { useProducts } from "../../../../../hooks/api/products"
|
||||
import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns"
|
||||
import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters"
|
||||
@@ -11,7 +23,7 @@ import { useProductTableQuery } from "../../../../../hooks/table/query/use-produ
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
|
||||
type CategoryProductSectionProps = {
|
||||
category: AdminProductCategoryResponse["product_category"]
|
||||
category: HttpTypes.AdminProductCategory
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
@@ -20,6 +32,9 @@ export const CategoryProductSection = ({
|
||||
category,
|
||||
}: CategoryProductSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
|
||||
const [selection, setSelection] = useState<RowSelectionState>({})
|
||||
|
||||
const { raw, searchParams } = useProductTableQuery({ pageSize: PAGE_SIZE })
|
||||
const { products, count, isLoading, isError, error } = useProducts(
|
||||
@@ -32,7 +47,7 @@ export const CategoryProductSection = ({
|
||||
}
|
||||
)
|
||||
|
||||
const columns = useProductTableColumns()
|
||||
const columns = useColumns()
|
||||
const filters = useProductTableFilters(["categories"])
|
||||
|
||||
const { table } = useDataTable({
|
||||
@@ -43,8 +58,57 @@ export const CategoryProductSection = ({
|
||||
pageSize: PAGE_SIZE,
|
||||
enableRowSelection: true,
|
||||
enablePagination: true,
|
||||
rowSelection: {
|
||||
state: selection,
|
||||
updater: setSelection,
|
||||
},
|
||||
})
|
||||
|
||||
const { mutateAsync } = useUpdateProductCategoryProducts(category.id)
|
||||
|
||||
const handleRemove = async () => {
|
||||
const selected = Object.keys(selection)
|
||||
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("categories.products.remove.confirmation", {
|
||||
count: selected.length,
|
||||
}),
|
||||
confirmText: t("actions.remove"),
|
||||
cancelText: t("actions.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
remove: selected,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("general.success"), {
|
||||
description: t("categories.products.remove.successToast", {
|
||||
count: selected.length,
|
||||
}),
|
||||
dismissable: true,
|
||||
dismissLabel: t("actions.close"),
|
||||
})
|
||||
|
||||
setSelection({})
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(t("general.error"), {
|
||||
description: error.message,
|
||||
dismissable: true,
|
||||
dismissLabel: t("actions.close"),
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
@@ -53,7 +117,19 @@ export const CategoryProductSection = ({
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading level="h2">{t("products.domain")}</Heading>
|
||||
<ActionMenu groups={[]} />
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.add"),
|
||||
icon: <PlusMini />,
|
||||
to: "products",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<DataTable
|
||||
table={table}
|
||||
@@ -62,10 +138,66 @@ export const CategoryProductSection = ({
|
||||
orderBy={["title", "created_at", "updated_at"]}
|
||||
pageSize={PAGE_SIZE}
|
||||
count={count}
|
||||
navigateTo={(row) => row.id}
|
||||
navigateTo={(row) => `/products/${row.id}`}
|
||||
isLoading={isLoading}
|
||||
queryObject={raw}
|
||||
/>
|
||||
<CommandBar open={!!Object.keys(selection).length}>
|
||||
<CommandBar.Bar>
|
||||
<CommandBar.Value>
|
||||
{t("general.countSelected", {
|
||||
count: Object.keys(selection).length,
|
||||
})}
|
||||
</CommandBar.Value>
|
||||
<CommandBar.Seperator />
|
||||
<CommandBar.Command
|
||||
action={handleRemove}
|
||||
label={t("actions.remove")}
|
||||
shortcut="r"
|
||||
/>
|
||||
</CommandBar.Bar>
|
||||
</CommandBar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<HttpTypes.AdminProduct>()
|
||||
|
||||
const useColumns = () => {
|
||||
const base = useProductTableColumns()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
...base,
|
||||
],
|
||||
[base]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Heading } from "@medusajs/ui"
|
||||
|
||||
import { useParams } from "react-router-dom"
|
||||
import { RouteDrawer } from "../../../components/route-modal"
|
||||
import { useProductCategory } from "../../../hooks/api/categories"
|
||||
import { EditCategoryForm } from "./components/edit-category-form"
|
||||
|
||||
export const CategoryEdit = () => {
|
||||
const { id } = useParams()
|
||||
|
||||
const { product_category, isPending, isError, error } = useProductCategory(
|
||||
id!
|
||||
)
|
||||
|
||||
const ready = !isPending && !!product_category
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteDrawer>
|
||||
<RouteDrawer.Header>
|
||||
<Heading>Edit</Heading>
|
||||
</RouteDrawer.Header>
|
||||
{ready && <EditCategoryForm category={product_category} />}
|
||||
</RouteDrawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Button, Input, Select, Textarea } from "@medusajs/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { HandleInput } from "../../../../../components/inputs/handle-input"
|
||||
import { RouteDrawer } from "../../../../../components/route-modal"
|
||||
|
||||
const EditCategorySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
handle: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(["active", "inactive"]),
|
||||
visibility: z.enum(["public", "internal"]),
|
||||
})
|
||||
|
||||
type EditCategoryFormProps = {
|
||||
category: HttpTypes.AdminProductCategory
|
||||
}
|
||||
|
||||
export const EditCategoryForm = ({ category }: EditCategoryFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const form = useForm<z.infer<typeof EditCategorySchema>>({
|
||||
defaultValues: {
|
||||
name: category.name,
|
||||
handle: category.handle,
|
||||
description: category.description || "",
|
||||
status: category.is_active ? "active" : "inactive",
|
||||
visibility: category.is_internal ? "internal" : "public",
|
||||
},
|
||||
resolver: zodResolver(EditCategorySchema),
|
||||
})
|
||||
|
||||
const isPending = false
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => console.log(data))
|
||||
|
||||
return (
|
||||
<RouteDrawer.Form form={form}>
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col">
|
||||
<RouteDrawer.Body>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.title")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input autoComplete="off" {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="handle"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label
|
||||
optional
|
||||
tooltip={t("collections.handleTooltip")}
|
||||
>
|
||||
{t("fields.handle")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<HandleInput {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>{t("fields.description")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Textarea {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field: { ref, onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>
|
||||
{t("categories.fields.status.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Select {...field} onValueChange={onChange}>
|
||||
<Select.Trigger ref={ref}>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="active">
|
||||
{t("categories.fields.status.active")}
|
||||
</Select.Item>
|
||||
<Select.Item value="inactive">
|
||||
{t("categories.fields.status.inactive")}
|
||||
</Select.Item>
|
||||
</Select.Content>
|
||||
</Select>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field: { ref, onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>
|
||||
{t("categories.fields.visibility.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Select {...field} onValueChange={onChange}>
|
||||
<Select.Trigger ref={ref}>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="public">
|
||||
{t("categories.fields.visibility.public")}
|
||||
</Select.Item>
|
||||
<Select.Item value="internal">
|
||||
{t("categories.fields.visibility.internal")}
|
||||
</Select.Item>
|
||||
</Select.Content>
|
||||
</Select>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RouteDrawer.Body>
|
||||
<RouteDrawer.Footer>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<RouteDrawer.Close asChild>
|
||||
<Button size="small" variant="secondary">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteDrawer.Close>
|
||||
<Button size="small" type="submit" isLoading={isPending}>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteDrawer.Footer>
|
||||
</form>
|
||||
</RouteDrawer.Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./edit-category-form"
|
||||
@@ -0,0 +1 @@
|
||||
export { CategoryEdit as Component } from "./category-edit"
|
||||
@@ -1,17 +1,18 @@
|
||||
import { PencilSquare } from "@medusajs/icons"
|
||||
import { AdminProductCategoryResponse } from "@medusajs/types"
|
||||
import { Container, Heading } from "@medusajs/ui"
|
||||
import { Button, Container, Heading } from "@medusajs/ui"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Link } from "react-router-dom"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { useCategories } from "../../../../../hooks/api/categories"
|
||||
import { useCategoryTableColumns } from "../../../../../hooks/table/columns/use-category-table-columns"
|
||||
import { useProductCategories } from "../../../../../hooks/api/categories"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { useCategoryTableQuery } from "../../../common/hooks/use-category-table-query"
|
||||
import { useCategoryTableColumns } from "./use-category-table-columns"
|
||||
import { useCategoryTableQuery } from "./use-category-table-query"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@@ -34,7 +35,7 @@ export const CategoryListTable = () => {
|
||||
}
|
||||
|
||||
const { product_categories, count, isLoading, isError, error } =
|
||||
useCategories(
|
||||
useProductCategories(
|
||||
{
|
||||
...query,
|
||||
},
|
||||
@@ -63,6 +64,14 @@ export const CategoryListTable = () => {
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading>{t("categories.domain")}</Heading>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button size="small" variant="secondary" asChild>
|
||||
<Link to="organize">{t("categories.organize.action")}</Link>
|
||||
</Button>
|
||||
<Button size="small" variant="secondary" asChild>
|
||||
<Link to="create">{t("actions.create")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
table={table}
|
||||
|
||||
@@ -5,16 +5,16 @@ import { createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { StatusCell } from "../../../components/table/table-cells/common/status-cell"
|
||||
import { StatusCell } from "../../../../../components/table/table-cells/common/status-cell"
|
||||
import {
|
||||
TextCell,
|
||||
TextHeader,
|
||||
} from "../../../components/table/table-cells/common/text-cell"
|
||||
} from "../../../../../components/table/table-cells/common/text-cell"
|
||||
import {
|
||||
getCategoryPath,
|
||||
getIsActiveProps,
|
||||
getIsInternalProps,
|
||||
} from "../../../routes/categories/common/utils"
|
||||
} from "../../../common/utils"
|
||||
|
||||
const columnHelper =
|
||||
createColumnHelper<AdminProductCategoryResponse["product_category"]>()
|
||||
@@ -101,7 +101,9 @@ export const useCategoryTableColumns = () => {
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("is_internal", {
|
||||
header: () => <TextHeader text={t("categories.fields.visibility")} />,
|
||||
header: () => (
|
||||
<TextHeader text={t("categories.fields.visibility.label")} />
|
||||
),
|
||||
cell: ({ getValue }) => {
|
||||
const { color, label } = getIsInternalProps(getValue(), t)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQueryParams } from "../../../../hooks/use-query-params"
|
||||
import { useQueryParams } from "../../../../../hooks/use-query-params"
|
||||
|
||||
export const useCategoryTableQuery = ({
|
||||
pageSize = 20,
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useParams } from "react-router-dom"
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
import { OrganizeCategoryForm } from "./components/organize-category-form/organize-category-form"
|
||||
|
||||
// TODO: Something around the mpath of categories is bugged out, and using this form breaks your categories. See CORE-2287.
|
||||
export const CategoryOrganize = () => {
|
||||
const { id } = useParams()
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
<OrganizeCategoryForm categoryId={id} />
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { OrganizeCategoryForm as Component } from "./organize-category-form"
|
||||
@@ -0,0 +1,93 @@
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
|
||||
import { Spinner } from "@medusajs/icons"
|
||||
import { FetchError } from "@medusajs/js-sdk"
|
||||
import { useState } from "react"
|
||||
import { RouteFocusModal } from "../../../../../components/route-modal"
|
||||
import {
|
||||
categoriesQueryKeys,
|
||||
useProductCategories,
|
||||
} from "../../../../../hooks/api/categories"
|
||||
import { sdk } from "../../../../../lib/client"
|
||||
import { queryClient } from "../../../../../lib/query-client"
|
||||
import {
|
||||
CategoryTree,
|
||||
CategoryTreeItem,
|
||||
} from "../../../common/components/category-tree"
|
||||
|
||||
type OrganizeCategoryFormProps = {
|
||||
categoryId?: string
|
||||
}
|
||||
|
||||
// TODO: Add some focus/highlight state if we enter this form from a specific category. Awaiting design.
|
||||
export const OrganizeCategoryForm = ({
|
||||
categoryId,
|
||||
}: OrganizeCategoryFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
// TODO: Display error message to the user, might be in a toast or in the header. Awaiting design.
|
||||
const [error, setError] = useState<FetchError | null>(null)
|
||||
|
||||
const {
|
||||
product_categories,
|
||||
isPending,
|
||||
isError,
|
||||
error: fetchError,
|
||||
} = useProductCategories(
|
||||
{
|
||||
fields: "id,name,parent_category_id,rank,*category_children",
|
||||
parent_category_id: "null",
|
||||
include_descendants_tree: true,
|
||||
limit: 9999,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const handleRankChange = async (value: CategoryTreeItem) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
await sdk.admin.productCategory
|
||||
.update(value.id, {
|
||||
rank: value.rank ?? 0,
|
||||
parent_category_id: value.parent_category_id,
|
||||
})
|
||||
.then(async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: categoriesQueryKeys.lists(),
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: categoriesQueryKeys.detail(value.id),
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
setError(error)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const loading = isPending || isLoading
|
||||
|
||||
if (isError) {
|
||||
throw fetchError
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<RouteFocusModal.Header>
|
||||
<div className="flex items-center justify-end">
|
||||
{loading && <Spinner className="animate-spin" />}
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body className="bg-ui-bg-subtle flex flex-1 flex-col overflow-y-auto">
|
||||
<CategoryTree
|
||||
value={product_categories || []}
|
||||
onChange={handleRankChange}
|
||||
/>
|
||||
</RouteFocusModal.Body>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CategoryOrganize as Component } from "./category-organize"
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useParams } from "react-router-dom"
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
import { useProductCategory } from "../../../hooks/api/categories"
|
||||
import { EditCategoryProductsForm } from "./components/edit-category-products-form"
|
||||
|
||||
export const CategoryProducts = () => {
|
||||
const { id } = useParams()
|
||||
|
||||
const { product_category, isPending, isError, error } = useProductCategory(
|
||||
id!,
|
||||
{
|
||||
fields: "*products",
|
||||
}
|
||||
)
|
||||
|
||||
const ready = !isPending && !!product_category
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
{ready && (
|
||||
<EditCategoryProductsForm
|
||||
categoryId={product_category.id}
|
||||
products={product_category.products}
|
||||
/>
|
||||
)}
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Button, Checkbox, Hint, Tooltip, toast } from "@medusajs/ui"
|
||||
import {
|
||||
OnChangeFn,
|
||||
RowSelectionState,
|
||||
createColumnHelper,
|
||||
} from "@tanstack/react-table"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { useUpdateProductCategoryProducts } from "../../../../../hooks/api/categories"
|
||||
import { useProducts } from "../../../../../hooks/api/products"
|
||||
import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns"
|
||||
import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters"
|
||||
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
|
||||
type EditCategoryProductsFormProps = {
|
||||
categoryId: string
|
||||
products?: HttpTypes.AdminProduct[]
|
||||
}
|
||||
|
||||
const EditCategoryProductsSchema = z.object({
|
||||
product_ids: z.array(z.string()),
|
||||
})
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
const PREFIX = "p"
|
||||
|
||||
export const EditCategoryProductsForm = ({
|
||||
categoryId,
|
||||
products = [],
|
||||
}: EditCategoryProductsFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const [selection, setSelection] = useState<RowSelectionState>(
|
||||
products.reduce((acc, p) => {
|
||||
acc[p.id!] = true
|
||||
return acc
|
||||
}, {} as RowSelectionState)
|
||||
)
|
||||
|
||||
const form = useForm<z.infer<typeof EditCategoryProductsSchema>>({
|
||||
defaultValues: {
|
||||
product_ids: [],
|
||||
},
|
||||
resolver: zodResolver(EditCategoryProductsSchema),
|
||||
})
|
||||
|
||||
const updater: OnChangeFn<RowSelectionState> = (newSelection) => {
|
||||
const value =
|
||||
typeof newSelection === "function"
|
||||
? newSelection(selection)
|
||||
: newSelection
|
||||
|
||||
form.setValue("product_ids", Object.keys(value), {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
})
|
||||
|
||||
setSelection(value)
|
||||
}
|
||||
|
||||
const { searchParams, raw } = useProductTableQuery({
|
||||
pageSize: PAGE_SIZE,
|
||||
prefix: PREFIX,
|
||||
})
|
||||
|
||||
const {
|
||||
products: data,
|
||||
count,
|
||||
isPending,
|
||||
isError,
|
||||
error,
|
||||
} = useProducts({
|
||||
...searchParams,
|
||||
category_id: [categoryId],
|
||||
})
|
||||
|
||||
const columns = useColumns()
|
||||
const filters = useProductTableFilters(["categories"])
|
||||
|
||||
const { table } = useDataTable({
|
||||
data,
|
||||
columns,
|
||||
getRowId: (original) => original.id,
|
||||
count,
|
||||
pageSize: PAGE_SIZE,
|
||||
prefix: PREFIX,
|
||||
enableRowSelection: (row) => {
|
||||
return !products.some((p) => p.id === row.original.id)
|
||||
},
|
||||
enablePagination: true,
|
||||
rowSelection: {
|
||||
state: selection,
|
||||
updater,
|
||||
},
|
||||
})
|
||||
|
||||
const { mutateAsync, isPending: isMutating } =
|
||||
useUpdateProductCategoryProducts(categoryId)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
add: data.product_ids,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("general.success"), {
|
||||
description: t("categories.products.add.disabledTooltip", {
|
||||
count: data.product_ids.length,
|
||||
}),
|
||||
dismissable: true,
|
||||
dismissLabel: t("actions.close"),
|
||||
})
|
||||
|
||||
handleSuccess()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(t("general.error"), {
|
||||
description: error.message,
|
||||
dismissable: true,
|
||||
dismissLabel: t("actions.close"),
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
>
|
||||
<RouteFocusModal.Header>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
{form.formState.errors.product_ids && (
|
||||
<Hint variant="error">
|
||||
{form.formState.errors.product_ids.message}
|
||||
</Hint>
|
||||
)}
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button size="small" variant="secondary">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button size="small" type="submit" isLoading={isMutating}>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body className="size-full overflow-hidden">
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
pageSize={PAGE_SIZE}
|
||||
count={count}
|
||||
queryObject={raw}
|
||||
filters={filters}
|
||||
orderBy={["title", "created_at", "updated_at"]}
|
||||
prefix={PREFIX}
|
||||
isLoading={isPending}
|
||||
layout="fill"
|
||||
pagination
|
||||
search
|
||||
/>
|
||||
</RouteFocusModal.Body>
|
||||
</form>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<HttpTypes.AdminProduct>()
|
||||
|
||||
const useColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
const base = useProductTableColumns()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const isPreSelected = !row.getCanSelect()
|
||||
const isSelected = row.getIsSelected() || isPreSelected
|
||||
|
||||
const Component = (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={isPreSelected}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isPreSelected) {
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("categories.products.add.disabledTooltip")}
|
||||
side="right"
|
||||
>
|
||||
{Component}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return Component
|
||||
},
|
||||
}),
|
||||
...base,
|
||||
],
|
||||
[t, base]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./edit-category-products-form"
|
||||
@@ -0,0 +1 @@
|
||||
export { CategoryProducts as Component } from "./category-products"
|
||||
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
DotsSix,
|
||||
FolderIllustration,
|
||||
FolderOpenIllustration,
|
||||
Swatch,
|
||||
TriangleRightMini,
|
||||
} from "@medusajs/icons"
|
||||
import { Badge, IconButton, Text, clx } from "@medusajs/ui"
|
||||
import dropRight from "lodash/dropRight"
|
||||
import flatMap from "lodash/flatMap"
|
||||
import get from "lodash/get"
|
||||
import { ReactNode } from "react"
|
||||
import Nestable from "react-nestable"
|
||||
|
||||
import { useTranslation } from "react-i18next"
|
||||
import "react-nestable/dist/styles/index.css"
|
||||
import "./styles.css"
|
||||
import { CategoryTreeItem } from "./types"
|
||||
|
||||
type CategoryTreeProps = {
|
||||
value: CategoryTreeItem[]
|
||||
onChange: (value: CategoryTreeItem, items: CategoryTreeItem[]) => void
|
||||
enableDrag?: boolean | ((item: CategoryTreeItem) => boolean)
|
||||
showBadge?: (item: CategoryTreeItem) => boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export const CategoryTree = ({
|
||||
value,
|
||||
onChange,
|
||||
enableDrag = true,
|
||||
showBadge,
|
||||
isLoading = false,
|
||||
}: CategoryTreeProps) => {
|
||||
const handleDrag = ({
|
||||
dragItem,
|
||||
items,
|
||||
targetPath,
|
||||
}: {
|
||||
dragItem: CategoryTreeItem
|
||||
items: CategoryTreeItem[]
|
||||
targetPath: number[]
|
||||
}) => {
|
||||
let parentId = null
|
||||
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) as CategoryTreeItem
|
||||
parentId = newParent.id
|
||||
}
|
||||
|
||||
onChange(
|
||||
{
|
||||
...dragItem,
|
||||
parent_category_id: parentId,
|
||||
rank,
|
||||
},
|
||||
items
|
||||
)
|
||||
|
||||
return {
|
||||
...dragItem,
|
||||
parent_category_id: parentId,
|
||||
rank,
|
||||
}
|
||||
}
|
||||
|
||||
const getIsEnabled = (item: CategoryTreeItem) => {
|
||||
if (typeof enableDrag === "function") {
|
||||
return enableDrag(item)
|
||||
}
|
||||
|
||||
return enableDrag
|
||||
}
|
||||
|
||||
const getShowBadge = (item: CategoryTreeItem) => {
|
||||
if (typeof showBadge === "function") {
|
||||
return showBadge(item)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="txt-compact-small relative flex-1 overflow-y-auto">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<CategoryLeafPlaceholder key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="txt-compact-small relative flex-1 overflow-y-auto">
|
||||
<Nestable
|
||||
items={value}
|
||||
childrenProp="category_children"
|
||||
onChange={({ dragItem, items, targetPath }) =>
|
||||
handleDrag({
|
||||
dragItem: dragItem as CategoryTreeItem,
|
||||
items: items as CategoryTreeItem[],
|
||||
targetPath,
|
||||
})
|
||||
}
|
||||
disableDrag={({ item }) => getIsEnabled(item as CategoryTreeItem)}
|
||||
renderItem={({ index, item, ...props }) => {
|
||||
return (
|
||||
<CategoryBranch
|
||||
key={index}
|
||||
item={item as CategoryTreeItem}
|
||||
isEnabled={getIsEnabled(item as CategoryTreeItem)}
|
||||
isNew={getShowBadge(item as CategoryTreeItem)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
handler={<DragHandle />}
|
||||
renderCollapseIcon={({ isCollapsed }) => {
|
||||
return <CollapseHandler isCollapsed={isCollapsed} />
|
||||
}}
|
||||
threshold={10}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CollapseHandler = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-x-3">
|
||||
<IconButton size="small" variant="transparent" type="button">
|
||||
<TriangleRightMini
|
||||
className={clx({
|
||||
"rotate-90 transform transition-transform": !isCollapsed,
|
||||
})}
|
||||
/>
|
||||
</IconButton>
|
||||
<div
|
||||
className="text-ui-fg-muted flex h-7 w-7 items-center justify-center"
|
||||
role="presentation"
|
||||
>
|
||||
{isCollapsed ? <FolderIllustration /> : <FolderOpenIllustration />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CategoryBranchProps = {
|
||||
item: CategoryTreeItem
|
||||
depth: number
|
||||
isEnabled: boolean
|
||||
isNew?: boolean
|
||||
collapseIcon: ReactNode
|
||||
handler: ReactNode
|
||||
}
|
||||
|
||||
export const CategoryBranch = ({
|
||||
item,
|
||||
depth,
|
||||
isEnabled,
|
||||
isNew = false,
|
||||
collapseIcon,
|
||||
handler,
|
||||
}: CategoryBranchProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const isLeaf = !collapseIcon
|
||||
|
||||
const Component = (
|
||||
<div
|
||||
data-disabled={!isEnabled}
|
||||
className={clx(
|
||||
"bg-ui-bg-base hover:bg-ui-bg-base-hover transition-fg group group flex h-12 items-center gap-x-3 border-b px-6 py-2.5",
|
||||
{
|
||||
"bg-ui-bg-subtle hover:bg-ui-bg-subtle": !isEnabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div>{handler}</div>
|
||||
{Array.from({ length: depth }).map((_, i) => (
|
||||
<div key={`offset_${i}`} role="presentation" className="h-7 w-7" />
|
||||
))}
|
||||
<div>{collapseIcon}</div>
|
||||
{isLeaf && (
|
||||
<div role="presentation" className="flex items-center">
|
||||
<div className="size-7" />
|
||||
<div className="text-ui-fg-muted flex h-7 w-7 items-center justify-center">
|
||||
<Swatch />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Text size="small" leading="compact">
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text>{item.rank}</Text>
|
||||
{isNew && (
|
||||
<Badge size="2xsmall" color="blue">
|
||||
{t("categories.fields.new.label")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return Component
|
||||
}
|
||||
|
||||
const DragHandle = () => {
|
||||
return (
|
||||
<div className="flex h-7 w-7 cursor-grab items-center justify-center active:cursor-grabbing group-data-[disabled=true]:cursor-not-allowed">
|
||||
<DotsSix className="text-ui-fg-subtle" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CategoryLeafPlaceholder = () => {
|
||||
return (
|
||||
<div className="bg-ui-bg-base flex h-12 animate-pulse items-center border-b px-6 py-2.5" />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./category-tree"
|
||||
export * from "./types"
|
||||
@@ -0,0 +1,39 @@
|
||||
.nestable-item {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.nestable-item.is-dragging:before {
|
||||
content: "" !important;
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
|
||||
transition: 0.3s all !important;
|
||||
border: 1px dashed var(--border-interactive) !important;
|
||||
}
|
||||
|
||||
.nestable-item.is-dragging * {
|
||||
height: 48px !important;
|
||||
min-height: 48px !important;
|
||||
max-height: 48px !important;
|
||||
border-radius: 6 !important;
|
||||
overflow: hidden !important;
|
||||
background: var(--bg-base) !important;
|
||||
box-shadow: var(--elevation-card-hover) !important;
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
.nestable-drag-layer > .nestable-list > .nestable-item-copy-new-item {
|
||||
box-shadow: var(--elevation-card-hover) !important;
|
||||
border-radius: 6 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.nestable-list {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type CategoryTreeItem = {
|
||||
id: string
|
||||
name: string
|
||||
parent_category_id: string | null
|
||||
category_children: CategoryTreeItem[] | null
|
||||
rank: number | null
|
||||
}
|
||||
@@ -8,12 +8,12 @@ export function getIsActiveProps(
|
||||
switch (isActive) {
|
||||
case true:
|
||||
return {
|
||||
label: t("categories.fields.active"),
|
||||
label: t("categories.fields.status.active"),
|
||||
color: "green",
|
||||
}
|
||||
case false:
|
||||
return {
|
||||
label: t("categories.fields.inactive"),
|
||||
label: t("categories.fields.status.inactive"),
|
||||
color: "red",
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,12 @@ export function getIsInternalProps(
|
||||
switch (isInternal) {
|
||||
case true:
|
||||
return {
|
||||
label: t("categories.fields.internal"),
|
||||
label: t("categories.fields.visibility.internal"),
|
||||
color: "blue",
|
||||
}
|
||||
case false:
|
||||
return {
|
||||
label: t("categories.fields.public"),
|
||||
label: t("categories.fields.visibility.public"),
|
||||
color: "green",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
import { Divider } from "../../../../../components/common/divider"
|
||||
import { TextSkeleton } from "../../../../../components/common/skeleton"
|
||||
import { useCategories } from "../../../../../hooks/api/categories"
|
||||
import { useProductCategories } from "../../../../../hooks/api/categories"
|
||||
import { useDebouncedSearch } from "../../../../../hooks/use-debounced-search"
|
||||
|
||||
interface CategoryComboboxProps
|
||||
@@ -57,16 +57,17 @@ export const CategoryCombobox = forwardRef<
|
||||
const [level, setLevel] = useState<Level[]>([])
|
||||
const { searchValue, onSearchValueChange, query } = useDebouncedSearch()
|
||||
|
||||
const { product_categories, isPending, isError, error } = useCategories(
|
||||
{
|
||||
q: query,
|
||||
parent_category_id: !searchValue ? getParentId(level) : undefined,
|
||||
include_descendants_tree: !searchValue ? true : false,
|
||||
},
|
||||
{
|
||||
enabled: open,
|
||||
}
|
||||
)
|
||||
const { product_categories, isPending, isError, error } =
|
||||
useProductCategories(
|
||||
{
|
||||
q: query,
|
||||
parent_category_id: !searchValue ? getParentId(level) : undefined,
|
||||
include_descendants_tree: !searchValue ? true : false,
|
||||
},
|
||||
{
|
||||
enabled: open,
|
||||
}
|
||||
)
|
||||
|
||||
const [showLoading, setShowLoading] = useState(false)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FulfillmentSet } from "./fulfillment-set"
|
||||
import { Invite } from "./invite"
|
||||
import { Order } from "./order"
|
||||
import { Product } from "./product"
|
||||
import { ProductCategory } from "./product-category"
|
||||
import { ProductCollection } from "./product-collection"
|
||||
import { Region } from "./region"
|
||||
import { SalesChannel } from "./sales-channel"
|
||||
@@ -20,6 +21,7 @@ export class Admin {
|
||||
public invite: Invite
|
||||
public customer: Customer
|
||||
public productCollection: ProductCollection
|
||||
public productCategory: ProductCategory
|
||||
public product: Product
|
||||
public upload: Upload
|
||||
public region: Region
|
||||
@@ -38,6 +40,7 @@ export class Admin {
|
||||
this.invite = new Invite(client)
|
||||
this.customer = new Customer(client)
|
||||
this.productCollection = new ProductCollection(client)
|
||||
this.productCategory = new ProductCategory(client)
|
||||
this.product = new Product(client)
|
||||
this.upload = new Upload(client)
|
||||
this.region = new Region(client)
|
||||
|
||||
97
packages/core/js-sdk/src/admin/product-category.ts
Normal file
97
packages/core/js-sdk/src/admin/product-category.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Client } from "../client"
|
||||
import { ClientHeaders } from "../types"
|
||||
|
||||
export class ProductCategory {
|
||||
private client: Client
|
||||
constructor(client: Client) {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
async create(
|
||||
body: HttpTypes.AdminCreateProductCategory,
|
||||
query?: HttpTypes.AdminProductCategoryParams,
|
||||
headers?: ClientHeaders
|
||||
) {
|
||||
return this.client.fetch<HttpTypes.AdminProductCategoryResponse>(
|
||||
`/admin/product-categories`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
query,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
body: HttpTypes.AdminUpdateProductCategory,
|
||||
query?: HttpTypes.AdminProductCategoryParams,
|
||||
headers?: ClientHeaders
|
||||
) {
|
||||
return this.client.fetch<HttpTypes.AdminProductCategoryResponse>(
|
||||
`/admin/product-categories/${id}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
query,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async list(
|
||||
query?: HttpTypes.AdminProductCategoryListParams,
|
||||
headers?: ClientHeaders
|
||||
) {
|
||||
return this.client.fetch<HttpTypes.AdminProductCategoryListResponse>(
|
||||
`/admin/product-categories`,
|
||||
{
|
||||
headers,
|
||||
query: query,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async retrieve(
|
||||
id: string,
|
||||
query?: HttpTypes.AdminProductCategoryParams,
|
||||
headers?: ClientHeaders
|
||||
) {
|
||||
return this.client.fetch<HttpTypes.AdminProductCategoryResponse>(
|
||||
`/admin/product-categories/${id}`,
|
||||
{
|
||||
query,
|
||||
headers,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async delete(id: string, headers?: ClientHeaders) {
|
||||
return this.client.fetch<HttpTypes.AdminProductCategoryDeleteResponse>(
|
||||
`/admin/product-categories/${id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async updateProducts(
|
||||
id: string,
|
||||
body: HttpTypes.AdminUpdateProductCategoryProducts,
|
||||
query?: HttpTypes.AdminProductCategoryParams,
|
||||
headers?: ClientHeaders
|
||||
) {
|
||||
return this.client.fetch<HttpTypes.AdminProductCategoryResponse>(
|
||||
`/admin/product-categories/${id}/products`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
query,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { PaginatedResponse } from "../common"
|
||||
import { ProductCategoryResponse } from "./common"
|
||||
|
||||
export interface AdminProductCategoryResponse {
|
||||
product_category: ProductCategoryResponse
|
||||
}
|
||||
|
||||
export type AdminProductCategoryListResponse = PaginatedResponse<{
|
||||
product_categories: ProductCategoryResponse[]
|
||||
}>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { AdminProduct } from "../../product"
|
||||
import { BaseProductCategory } from "../common"
|
||||
|
||||
export interface AdminProductCategory
|
||||
extends Omit<
|
||||
BaseProductCategory,
|
||||
"products" | "category_children" | "parent_category"
|
||||
> {
|
||||
category_children: AdminProductCategory[]
|
||||
parent_category: AdminProductCategory | null
|
||||
products?: AdminProduct[]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./entities"
|
||||
export * from "./payloads"
|
||||
export * from "./queries"
|
||||
export * from "./responses"
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface AdminCreateProductCategory {
|
||||
name: string
|
||||
description?: string
|
||||
handle?: string
|
||||
is_internal?: boolean
|
||||
is_active?: boolean
|
||||
parent_category_id?: string
|
||||
rank?: number
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AdminUpdateProductCategory {
|
||||
name?: string
|
||||
description?: string
|
||||
handle?: string
|
||||
is_internal?: boolean
|
||||
is_active?: boolean
|
||||
parent_category_id?: string | null
|
||||
rank?: number
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AdminUpdateProductCategoryProducts {
|
||||
add?: string[]
|
||||
remove?: string[]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
BaseProductCategoryListParams,
|
||||
BaseProductCategoryParams,
|
||||
} from "../common"
|
||||
|
||||
export interface AdminProductCategoryListParams
|
||||
extends BaseProductCategoryListParams {
|
||||
is_internal?: boolean
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface AdminProductCategoryParams extends BaseProductCategoryParams {}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { DeleteResponse, PaginatedResponse } from "../../common"
|
||||
import { AdminProductCategory } from "./entities"
|
||||
|
||||
export interface AdminProductCategoryResponse {
|
||||
product_category: AdminProductCategory
|
||||
}
|
||||
|
||||
export interface AdminProductCategoryListResponse
|
||||
extends PaginatedResponse<{
|
||||
product_categories: AdminProductCategory[]
|
||||
}> {}
|
||||
|
||||
export interface AdminProductCategoryDeleteResponse
|
||||
extends DeleteResponse<"product_category"> {}
|
||||
@@ -1,15 +1,43 @@
|
||||
export interface ProductCategoryResponse {
|
||||
import { BaseFilterable, OperatorMap } from "../../dal"
|
||||
import { FindParams, SelectParams } from "../common"
|
||||
import { BaseProduct } from "../product/common"
|
||||
|
||||
export interface BaseProductCategory {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
handle: string | null
|
||||
description: string
|
||||
handle: string
|
||||
is_active: boolean
|
||||
is_internal: boolean
|
||||
rank: number | null
|
||||
parent_category_id: string | null
|
||||
created_at: string | Date
|
||||
updated_at: string | Date
|
||||
|
||||
parent_category: ProductCategoryResponse
|
||||
category_children: ProductCategoryResponse[]
|
||||
parent_category: BaseProductCategory | null
|
||||
category_children: BaseProductCategory[]
|
||||
products?: BaseProduct[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
deleted_at: string | null
|
||||
}
|
||||
|
||||
export interface BaseProductCategoryListParams
|
||||
extends FindParams,
|
||||
BaseFilterable<BaseProductCategoryListParams> {
|
||||
q?: string
|
||||
id?: string | string[]
|
||||
name?: string | string[]
|
||||
description?: string | string[]
|
||||
parent_category_id?: string | string[] | null
|
||||
handle?: string | string[]
|
||||
is_active?: boolean
|
||||
is_internal?: boolean
|
||||
include_descendants_tree?: boolean
|
||||
include_ancestors_tree?: boolean
|
||||
created_at?: OperatorMap<string>
|
||||
updated_at?: OperatorMap<string>
|
||||
deleted_at?: OperatorMap<string>
|
||||
}
|
||||
|
||||
export interface BaseProductCategoryParams extends SelectParams {
|
||||
include_ancestors_tree?: boolean
|
||||
include_descendants_tree?: boolean
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { PaginatedResponse } from "../common"
|
||||
import { ProductCategoryResponse } from "./common"
|
||||
|
||||
export interface StoreProductCategoryResponse {
|
||||
product_category: ProductCategoryResponse
|
||||
}
|
||||
|
||||
export type StoreProductCategoryListResponse = PaginatedResponse<{
|
||||
product_categories: ProductCategoryResponse[]
|
||||
}>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { StoreProduct } from "../../product/store"
|
||||
import { BaseProductCategory } from "../common"
|
||||
|
||||
export interface StoreProductCategory extends BaseProductCategory {
|
||||
products?: StoreProduct[]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./entities"
|
||||
export * from "./queries"
|
||||
export * from "./responses"
|
||||
@@ -0,0 +1,9 @@
|
||||
import {
|
||||
BaseProductCategoryListParams,
|
||||
BaseProductCategoryParams,
|
||||
} from "../common"
|
||||
|
||||
export interface StoreProductCategoryListParams
|
||||
extends BaseProductCategoryListParams {}
|
||||
|
||||
export interface StoreProductCategoryParams extends BaseProductCategoryParams {}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PaginatedResponse } from "../../common"
|
||||
import { StoreProductCategory } from "./entities"
|
||||
|
||||
export interface StoreProductCategoryResponse {
|
||||
product_category: StoreProductCategory
|
||||
}
|
||||
|
||||
export interface StoreProductCategoryListResponse
|
||||
extends PaginatedResponse<{
|
||||
product_categories: StoreProductCategory[]
|
||||
}> {}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AdminPrice } from "../../pricing"
|
||||
import { AdminProductCategory } from "../../product-category"
|
||||
import { AdminSalesChannel } from "../../sales-channel"
|
||||
import {
|
||||
BaseProduct,
|
||||
BaseProductCategory,
|
||||
BaseProductImage,
|
||||
BaseProductOption,
|
||||
BaseProductOptionValue,
|
||||
@@ -12,10 +12,6 @@ import {
|
||||
ProductStatus,
|
||||
} from "../common"
|
||||
|
||||
export interface AdminProductCategory extends BaseProductCategory {
|
||||
is_active?: boolean
|
||||
is_internal?: boolean
|
||||
}
|
||||
export interface AdminProductVariant extends BaseProductVariant {
|
||||
prices: AdminPrice[] | null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
BaseProductCategoryParams,
|
||||
BaseProductOptionParams,
|
||||
BaseProductParams,
|
||||
BaseProductTagParams,
|
||||
@@ -15,4 +14,3 @@ export interface AdminProductParams extends BaseProductParams {
|
||||
price_list_id?: string | string[]
|
||||
variants?: AdminProductVariantParams
|
||||
}
|
||||
export interface AdminProductCategoryParams extends BaseProductCategoryParams {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BaseFilterable, OperatorMap } from "../../dal"
|
||||
import { BaseCollection } from "../collection/common"
|
||||
import { FindParams } from "../common"
|
||||
import { BaseProductCategory } from "../product-category/common"
|
||||
|
||||
export type ProductStatus = "draft" | "proposed" | "published" | "rejected"
|
||||
export interface BaseProduct {
|
||||
@@ -64,21 +65,6 @@ export interface BaseProductVariant {
|
||||
metadata?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface BaseProductCategory {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
handle: string
|
||||
rank?: number
|
||||
parent_category?: BaseProductCategory | null
|
||||
parent_category_id?: string | null
|
||||
category_children?: BaseProductCategory[]
|
||||
products?: BaseProduct[]
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
metadata?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface BaseProductTag {
|
||||
id: string
|
||||
value: string
|
||||
@@ -183,17 +169,3 @@ export interface BaseProductVariantParams
|
||||
product_id?: string | string[]
|
||||
options?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface BaseProductCategoryParams
|
||||
extends FindParams,
|
||||
BaseFilterable<BaseProductCategoryParams> {
|
||||
q?: string
|
||||
id?: string | string[]
|
||||
name?: string | string[]
|
||||
parent_category_id?: string | string[] | null
|
||||
handle?: string | string[]
|
||||
is_active?: boolean
|
||||
is_internal?: boolean
|
||||
include_descendants_tree?: boolean
|
||||
include_ancestors_tree?: boolean
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { StoreProductCategory } from "../../product-category"
|
||||
import {
|
||||
BaseProduct,
|
||||
BaseProductCategory,
|
||||
BaseProductImage,
|
||||
BaseProductOption,
|
||||
BaseProductOptionValue,
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
ProductStatus,
|
||||
} from "../common"
|
||||
|
||||
export interface StoreProduct extends BaseProduct {}
|
||||
export interface StoreProductCategory extends BaseProductCategory {}
|
||||
export interface StoreProduct extends Omit<BaseProduct, "categories"> {
|
||||
categories?: StoreProductCategory[] | null
|
||||
}
|
||||
export interface StoreProductVariant extends BaseProductVariant {}
|
||||
export interface StoreProductTag extends BaseProductTag {}
|
||||
export interface StoreProductType extends BaseProductType {}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
BaseProductCategoryParams,
|
||||
BaseProductParams,
|
||||
BaseProductOptionParams,
|
||||
BaseProductParams,
|
||||
BaseProductTagParams,
|
||||
BaseProductTypeParams,
|
||||
BaseProductVariantParams,
|
||||
@@ -17,4 +16,3 @@ export interface StoreProductParams extends BaseProductParams {
|
||||
currency_code?: string
|
||||
variants?: StoreProductVariantParams
|
||||
}
|
||||
export interface StoreProductCategoryParams extends BaseProductCategoryParams {}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import * as React from "react"
|
||||
|
||||
import { TooltipProvider } from "../tooltip"
|
||||
import { Copy } from "./copy"
|
||||
|
||||
describe("Copy", () => {
|
||||
it("should render", () => {
|
||||
render(<Copy content="Hello world" />)
|
||||
render(<TooltipProvider><Copy content="Hello world" /></TooltipProvider>)
|
||||
expect(screen.getByRole("button")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react"
|
||||
import * as React from "react"
|
||||
|
||||
import { Button } from "@/components/button"
|
||||
import { TooltipProvider } from "@/components/tooltip"
|
||||
import { Copy } from "./copy"
|
||||
|
||||
const meta: Meta<typeof Copy> = {
|
||||
@@ -10,6 +11,7 @@ const meta: Meta<typeof Copy> = {
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
render: (args) => <TooltipProvider><Copy {...args} /></TooltipProvider>,
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
@@ -46,7 +46,11 @@ const SubMenuTrigger = React.forwardRef<
|
||||
<Primitives.SubTrigger
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"focus-visible:bg-ui-bg-base-pressed data-[state=open]:bg-ui-bg-base-pressed txt-compact-small flex cursor-default select-none items-center rounded-sm px-2 py-1.5 outline-none",
|
||||
"bg-ui-bg-component text-ui-fg-base txt-compact-small relative flex cursor-pointer select-none items-center rounded-md px-2 py-1.5 outline-none transition-colors",
|
||||
"focus-visible:bg-ui-bg-component-hover focus:bg-ui-bg-component-hover",
|
||||
"active:bg-ui-bg-component-pressed",
|
||||
"data-[disabled]:text-ui-fg-disabled data-[disabled]:pointer-events-none",
|
||||
"data-[state=open]:bg-ui-bg-base-hover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -69,7 +73,7 @@ const SubMenuContent = React.forwardRef<
|
||||
ref={ref}
|
||||
collisionPadding={collisionPadding}
|
||||
className={clx(
|
||||
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout max-h-[var(--radix-popper-available-height)] min-w-[8rem] overflow-hidden rounded-lg p-1",
|
||||
"bg-ui-bg-component text-ui-fg-base shadow-elevation-flyout max-h-[var(--radix-popper-available-height)] min-w-[220px] overflow-hidden rounded-lg p-1",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
@@ -125,7 +129,7 @@ const Item = React.forwardRef<
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"bg-ui-bg-component text-ui-fg-base txt-compact-small relative flex cursor-pointer select-none items-center rounded-md px-2 py-1.5 outline-none transition-colors",
|
||||
"focus-visible:bg-ui-bg-component-hover",
|
||||
"focus-visible:bg-ui-bg-component-hover focus:bg-ui-bg-component-hover",
|
||||
"active:bg-ui-bg-component-pressed",
|
||||
"data-[disabled]:text-ui-fg-disabled data-[disabled]:pointer-events-none",
|
||||
className
|
||||
|
||||
@@ -9,7 +9,7 @@ const hintVariants = cva({
|
||||
variants: {
|
||||
variant: {
|
||||
info: "text-ui-fg-subtle",
|
||||
error: "text-ui-fg-error grid grid-cols-[20px_1fr] gap-2 items-start",
|
||||
error: "text-ui-fg-error grid grid-cols-[20px_1fr] gap-1 items-start",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -40,7 +40,7 @@ const Hint = React.forwardRef<HTMLSpanElement, HintProps>(
|
||||
className={clx(hintVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
{variant === "error" && <ExclamationCircleSolid />}
|
||||
{variant === "error" && <div className="size-5 flex items-center justify-center"><ExclamationCircleSolid /></div>}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { Tooltip, TooltipProvider } from "./tooltip"
|
||||
|
||||
test("Tooltip renders trigger element", () => {
|
||||
render(
|
||||
<Tooltip content="Tooltip text">
|
||||
<div>Hover me</div>
|
||||
</Tooltip>
|
||||
<TooltipProvider>
|
||||
<Tooltip content="Tooltip text">
|
||||
<div>Hover me</div>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
const triggerElement = screen.getByText("Hover me")
|
||||
@@ -16,9 +18,11 @@ test("Tooltip renders trigger element", () => {
|
||||
|
||||
test("Tooltip shows on hover", async () => {
|
||||
render(
|
||||
<Tooltip content="Tooltip text" data-testid="tooltip">
|
||||
<div>Hover me</div>
|
||||
</Tooltip>
|
||||
<TooltipProvider>
|
||||
<Tooltip content="Tooltip text" data-testid="tooltip">
|
||||
<div>Hover me</div>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
const triggerElement = screen.getByText("Hover me")
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react"
|
||||
import * as React from "react"
|
||||
|
||||
import { InformationCircleSolid } from "@medusajs/icons"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { Tooltip, TooltipProvider } from "./tooltip"
|
||||
|
||||
const meta: Meta<typeof Tooltip> = {
|
||||
title: "Components/Tooltip",
|
||||
@@ -21,6 +21,7 @@ const meta: Meta<typeof Tooltip> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (args) => <TooltipProvider><Tooltip {...args} /></TooltipProvider>,
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
@@ -40,7 +40,6 @@ const Tooltip = ({
|
||||
...props
|
||||
}: TooltipProps) => {
|
||||
return (
|
||||
<Primitives.Provider delayDuration={100}>
|
||||
<Primitives.Root
|
||||
open={open}
|
||||
defaultOpen={defaultOpen}
|
||||
@@ -67,8 +66,18 @@ const Tooltip = ({
|
||||
</Primitives.Content>
|
||||
</Primitives.Portal>
|
||||
</Primitives.Root>
|
||||
</Primitives.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip }
|
||||
interface TooltipProviderProps extends Primitives.TooltipProviderProps {}
|
||||
|
||||
const TooltipProvider = ({ children, delayDuration = 100, skipDelayDuration = 300, ...props }: TooltipProviderProps) => {
|
||||
return (
|
||||
<Primitives.TooltipProvider delayDuration={delayDuration} skipDelayDuration={skipDelayDuration} {...props}>
|
||||
{children}
|
||||
</Primitives.TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export { Tooltip, TooltipProvider }
|
||||
|
||||
@@ -36,7 +36,7 @@ export { Text } from "./components/text"
|
||||
export { Textarea } from "./components/textarea"
|
||||
export { Toast } from "./components/toast"
|
||||
export { Toaster } from "./components/toaster"
|
||||
export { Tooltip } from "./components/tooltip"
|
||||
export { Tooltip, TooltipProvider } from "./components/tooltip"
|
||||
|
||||
// Hooks
|
||||
export { usePrompt } from "./hooks/use-prompt"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { z } from "zod"
|
||||
import { OptionalBooleanValidator } from "../../utils/common-validators"
|
||||
import {
|
||||
createFindParams,
|
||||
createOperatorMap,
|
||||
createSelectParams,
|
||||
} from "../../utils/validators"
|
||||
import { OptionalBooleanValidator } from "../../utils/common-validators"
|
||||
|
||||
export type AdminProductCategoryParamsType = z.infer<
|
||||
typeof AdminProductCategoryParams
|
||||
@@ -65,7 +65,7 @@ export const AdminUpdateProductCategory = z
|
||||
handle: z.string().optional(),
|
||||
is_internal: z.boolean().optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
parent_category_id: z.string().optional(),
|
||||
parent_category_id: z.string().nullable().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
rank: z.number().nonnegative().optional(),
|
||||
})
|
||||
|
||||
33
yarn.lock
33
yarn.lock
@@ -4870,6 +4870,7 @@ __metadata:
|
||||
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
|
||||
postcss: ^8.4.33
|
||||
prettier: ^3.1.1
|
||||
@@ -4881,6 +4882,7 @@ __metadata:
|
||||
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
|
||||
tailwindcss: ^3.4.1
|
||||
@@ -26685,6 +26687,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-addons-shallow-compare@npm:^15.6.3":
|
||||
version: 15.6.3
|
||||
resolution: "react-addons-shallow-compare@npm:15.6.3"
|
||||
dependencies:
|
||||
object-assign: ^4.1.0
|
||||
checksum: ad1a2ef7adf1a307b55de58a99ccf40998a1f7c84dcaadb1a2dd40c0e21e911b84fe04ab5f3494a1118846fc7537043287d54bcaffc2daf819b6c45eef5635ce
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-addons-update@npm:^15.6.3":
|
||||
version: 15.6.3
|
||||
resolution: "react-addons-update@npm:15.6.3"
|
||||
dependencies:
|
||||
object-assign: ^4.1.0
|
||||
checksum: b6d98b459eb37393b8103309090b36649c0a0e95c7dbe010e692616eb025baf62b53cb187925ecce0c3ed0377357ce03c3a45a8ec2d91e6886f8d2eb55b0dfd1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-colorful@npm:^5.1.2":
|
||||
version: 5.6.1
|
||||
resolution: "react-colorful@npm:5.6.1"
|
||||
@@ -26858,6 +26878,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-nestable@npm:^3.0.2":
|
||||
version: 3.0.2
|
||||
resolution: "react-nestable@npm:3.0.2"
|
||||
dependencies:
|
||||
classnames: ^2.3.2
|
||||
react: ^18.2.0
|
||||
react-addons-shallow-compare: ^15.6.3
|
||||
react-addons-update: ^15.6.3
|
||||
react-dom: ^18.2.0
|
||||
checksum: e2a7947382ca28e12048c534a2ad6e544dff60a56e712b7a1b00838434c5b5444b4c50c1fd652e032710b567674b4eff36ee4c3a5021c02f933d11c7b88ce7b9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-refresh@npm:^0.14.0":
|
||||
version: 0.14.2
|
||||
resolution: "react-refresh@npm:0.14.2"
|
||||
|
||||
Reference in New Issue
Block a user