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:
Kasper Fabricius Kristensen
2024-06-12 13:15:12 +02:00
committed by GitHub
parent 73ca358606
commit 2f76fbc6ed
71 changed files with 2208 additions and 187 deletions

View File

@@ -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"

View File

@@ -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>

View File

@@ -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}
>

View File

@@ -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

View File

@@ -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,
})
}

View File

@@ -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)

View File

@@ -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": {

View File

@@ -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"),
},
],
},
],
},

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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
}

View File

@@ -0,0 +1 @@
export * from "./create-category-form"

View File

@@ -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)

View File

@@ -0,0 +1 @@
export { CategoryCreate as Component } from "./category-create"

View File

@@ -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>
)
}

View File

@@ -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">

View File

@@ -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",
})

View File

@@ -0,0 +1 @@
export * from "./category-organize-section"

View File

@@ -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]
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./edit-category-form"

View File

@@ -0,0 +1 @@
export { CategoryEdit as Component } from "./category-edit"

View File

@@ -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}

View File

@@ -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)

View File

@@ -1,4 +1,4 @@
import { useQueryParams } from "../../../../hooks/use-query-params"
import { useQueryParams } from "../../../../../hooks/use-query-params"
export const useCategoryTableQuery = ({
pageSize = 20,

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export { OrganizeCategoryForm as Component } from "./organize-category-form"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export { CategoryOrganize as Component } from "./category-organize"

View File

@@ -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>
)
}

View File

@@ -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]
)
}

View File

@@ -0,0 +1 @@
export * from "./edit-category-products-form"

View File

@@ -0,0 +1 @@
export { CategoryProducts as Component } from "./category-products"

View File

@@ -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" />
)
}

View File

@@ -0,0 +1,2 @@
export * from "./category-tree"
export * from "./types"

View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
export type CategoryTreeItem = {
id: string
name: string
parent_category_id: string | null
category_children: CategoryTreeItem[] | null
rank: number | null
}

View File

@@ -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",
}
}

View File

@@ -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)

View File

@@ -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)

View 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,
}
)
}
}

View File

@@ -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[]
}>

View File

@@ -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[]
}

View File

@@ -0,0 +1,4 @@
export * from "./entities"
export * from "./payloads"
export * from "./queries"
export * from "./responses"

View File

@@ -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[]
}

View File

@@ -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 {}

View File

@@ -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"> {}

View File

@@ -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
}

View File

@@ -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[]
}>

View File

@@ -0,0 +1,6 @@
import { StoreProduct } from "../../product/store"
import { BaseProductCategory } from "../common"
export interface StoreProductCategory extends BaseProductCategory {
products?: StoreProduct[]
}

View File

@@ -0,0 +1,3 @@
export * from "./entities"
export * from "./queries"
export * from "./responses"

View File

@@ -0,0 +1,9 @@
import {
BaseProductCategoryListParams,
BaseProductCategoryParams,
} from "../common"
export interface StoreProductCategoryListParams
extends BaseProductCategoryListParams {}
export interface StoreProductCategoryParams extends BaseProductCategoryParams {}

View File

@@ -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[]
}> {}

View File

@@ -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
}

View File

@@ -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 {}

View File

@@ -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
}

View File

@@ -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 {}

View File

@@ -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 {}

View File

@@ -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()
})
})

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)

View File

@@ -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")

View File

@@ -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

View File

@@ -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 }

View File

@@ -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"

View File

@@ -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(),
})

View File

@@ -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"