feat: Categories retrieve + list API (#7009)
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { medusaIntegrationTestRunner } from "medusa-test-utils"
|
||||
import { createAdminUser } from "../../../helpers/create-admin-user"
|
||||
|
||||
jest.setTimeout(50000)
|
||||
|
||||
const env = { MEDUSA_FF_MEDUSA_V2: true }
|
||||
const adminHeaders = {
|
||||
headers: { "x-medusa-access-token": "test_token" },
|
||||
}
|
||||
|
||||
medusaIntegrationTestRunner({
|
||||
env,
|
||||
testSuite: ({ dbConnection, getContainer, api }) => {
|
||||
describe("Product categories - Admin", () => {
|
||||
let container
|
||||
|
||||
beforeAll(async () => {
|
||||
container = getContainer()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await createAdminUser(dbConnection, adminHeaders, container)
|
||||
})
|
||||
|
||||
it("should correctly query categories by q", async () => {
|
||||
const productService = container.resolve("productModuleService")
|
||||
const categoryOne = await productService.createCategory({
|
||||
name: "Category One",
|
||||
})
|
||||
const categoryTwo = await productService.createCategory({
|
||||
name: "Category Two",
|
||||
parent_category_id: categoryOne.id,
|
||||
})
|
||||
const categoryThree = await productService.createCategory({
|
||||
name: "Category Three",
|
||||
parent_category_id: categoryTwo.id,
|
||||
})
|
||||
|
||||
const response = await api.get(
|
||||
"/admin/product-categories?q=Category",
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.product_categories).toHaveLength(3)
|
||||
expect(response.data.product_categories).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: categoryOne.id,
|
||||
name: "Category One",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: categoryTwo.id,
|
||||
name: "Category Two",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: categoryThree.id,
|
||||
name: "Category Three",
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
const responseTwo = await api.get(
|
||||
"/admin/product-categories?q=three",
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(responseTwo.status).toEqual(200)
|
||||
expect(responseTwo.data.product_categories).toHaveLength(1)
|
||||
expect(responseTwo.data.product_categories).toEqual([
|
||||
expect.objectContaining({
|
||||
id: categoryThree.id,
|
||||
name: "Category Three",
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -293,7 +293,20 @@
|
||||
"removeProductsWarning_other": "You are about to remove {{count}} products from the collection. This action cannot be undone."
|
||||
},
|
||||
"categories": {
|
||||
"domain": "Categories"
|
||||
"domain": "Categories",
|
||||
"organization": {
|
||||
"header": "Organization",
|
||||
"pathLabel": "Path",
|
||||
"pathExpandTooltip": "Show full path",
|
||||
"childrenLabel": "Children"
|
||||
},
|
||||
"fields": {
|
||||
"visibility": "Visibility",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"internal": "Internal",
|
||||
"public": "Public"
|
||||
}
|
||||
},
|
||||
"inventory": {
|
||||
"domain": "Inventory",
|
||||
|
||||
@@ -182,6 +182,7 @@ export const DataTableRoot = <TData,>({
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const to = navigateTo ? navigateTo(row) : undefined
|
||||
const isRowDisabled = hasSelect && !row.getCanSelect()
|
||||
|
||||
return (
|
||||
<Table.Row
|
||||
key={row.id}
|
||||
@@ -192,7 +193,8 @@ export const DataTableRoot = <TData,>({
|
||||
"cursor-pointer": !!to,
|
||||
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||
row.getIsSelected(),
|
||||
"bg-ui-bg-subtle hover:bg-ui-bg-subtle": isRowDisabled,
|
||||
"bg-ui-bg-disabled hover:bg-ui-bg-disabled":
|
||||
isRowDisabled,
|
||||
}
|
||||
)}
|
||||
onClick={to ? () => navigate(to) : undefined}
|
||||
@@ -211,6 +213,15 @@ export const DataTableRoot = <TData,>({
|
||||
|
||||
const isStickyCell = isSelectCell || isFirstCell
|
||||
|
||||
/**
|
||||
* If the table has nested rows, we need to offset the cell padding
|
||||
* to indicate the depth of the row.
|
||||
*/
|
||||
const depthOffset =
|
||||
row.depth > 0 && isFirstCell
|
||||
? row.depth * 14 + 24
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Table.Cell
|
||||
key={cell.id}
|
||||
@@ -221,9 +232,14 @@ export const DataTableRoot = <TData,>({
|
||||
isStickyCell && hasSelect && !isSelectCell,
|
||||
"after:bg-ui-border-base":
|
||||
showStickyBorder && isStickyCell && !isSelectCell,
|
||||
"bg-ui-bg-subtle hover:bg-ui-bg-subtle":
|
||||
"!bg-ui-bg-disabled !hover:bg-ui-bg-disabled":
|
||||
isRowDisabled,
|
||||
})}
|
||||
style={{
|
||||
paddingLeft: depthOffset
|
||||
? `${depthOffset}px`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import {
|
||||
AdminProductCategoryListResponse,
|
||||
AdminProductCategoryResponse,
|
||||
} from "@medusajs/types"
|
||||
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
|
||||
import { client } from "../../lib/client"
|
||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||
import { CategoriesListRes, CategoryRes } from "../../types/api-responses"
|
||||
|
||||
const CATEGORIES_QUERY_KEY = "categories" as const
|
||||
export const categoriesQueryKeys = queryKeysFactory(CATEGORIES_QUERY_KEY)
|
||||
|
||||
export const useCategory = (
|
||||
id: string,
|
||||
query?: Record<string, any>,
|
||||
options?: Omit<
|
||||
UseQueryOptions<CategoryRes, Error, CategoryRes, QueryKey>,
|
||||
UseQueryOptions<
|
||||
AdminProductCategoryResponse,
|
||||
Error,
|
||||
AdminProductCategoryResponse,
|
||||
QueryKey
|
||||
>,
|
||||
"queryFn" | "queryKey"
|
||||
>
|
||||
) => {
|
||||
const { data, ...rest } = useQuery({
|
||||
queryKey: categoriesQueryKeys.detail(id),
|
||||
queryFn: async () => client.categories.retrieve(id),
|
||||
queryKey: categoriesQueryKeys.detail(id, query),
|
||||
queryFn: async () => client.categories.retrieve(id, query),
|
||||
...options,
|
||||
})
|
||||
|
||||
@@ -25,7 +34,12 @@ export const useCategory = (
|
||||
export const useCategories = (
|
||||
query?: Record<string, any>,
|
||||
options?: Omit<
|
||||
UseQueryOptions<CategoriesListRes, Error, CategoriesListRes, QueryKey>,
|
||||
UseQueryOptions<
|
||||
AdminProductCategoryListResponse,
|
||||
Error,
|
||||
AdminProductCategoryListResponse,
|
||||
QueryKey
|
||||
>,
|
||||
"queryFn" | "queryKey"
|
||||
>
|
||||
) => {
|
||||
|
||||
@@ -3,7 +3,11 @@ import { Filter } from "../../../components/table/data-table"
|
||||
import { useProductTypes } from "../../api/product-types"
|
||||
import { useSalesChannels } from "../../api/sales-channels"
|
||||
|
||||
const excludeableFields = ["sales_channel_id", "collections"] as const
|
||||
const excludeableFields = [
|
||||
"sales_channel_id",
|
||||
"collections",
|
||||
"categories",
|
||||
] as const
|
||||
|
||||
export const useProductTableFilters = (
|
||||
exclude?: (typeof excludeableFields)[number][]
|
||||
@@ -33,11 +37,15 @@ export const useProductTableFilters = (
|
||||
}
|
||||
)
|
||||
|
||||
const isCategoryExcluded = exclude?.includes("categories")
|
||||
|
||||
// const { product_categories } = useAdminProductCategories({
|
||||
// limit: 1000,
|
||||
// offset: 0,
|
||||
// fields: "id,name",
|
||||
// expand: "",
|
||||
// }, {
|
||||
// enabled: !isCategoryExcluded,
|
||||
// })
|
||||
|
||||
const isCollectionExcluded = exclude?.includes("collections")
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Row,
|
||||
RowSelectionState,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
@@ -22,7 +23,9 @@ type UseDataTableProps<TData> = {
|
||||
updater: OnChangeFn<RowSelectionState>
|
||||
}
|
||||
enablePagination?: boolean
|
||||
enableExpandableRows?: boolean
|
||||
getRowId?: (original: TData, index: number) => string
|
||||
getSubRows?: (original: TData) => TData[]
|
||||
meta?: Record<string, unknown>
|
||||
prefix?: string
|
||||
}
|
||||
@@ -34,7 +37,9 @@ export const useDataTable = <TData,>({
|
||||
pageSize: _pageSize = 20,
|
||||
enablePagination = true,
|
||||
enableRowSelection = false,
|
||||
enableExpandableRows = false,
|
||||
rowSelection: _rowSelection,
|
||||
getSubRows,
|
||||
getRowId,
|
||||
meta,
|
||||
prefix,
|
||||
@@ -107,6 +112,7 @@ export const useDataTable = <TData,>({
|
||||
pageCount: Math.ceil((count ?? 0) / pageSize),
|
||||
enableRowSelection,
|
||||
getRowId,
|
||||
getSubRows,
|
||||
onRowSelectionChange: enableRowSelection ? setRowSelection : undefined,
|
||||
onPaginationChange: enablePagination
|
||||
? (onPaginationChange as OnChangeFn<PaginationState>)
|
||||
@@ -115,6 +121,9 @@ export const useDataTable = <TData,>({
|
||||
getPaginationRowModel: enablePagination
|
||||
? getPaginationRowModel()
|
||||
: undefined,
|
||||
getExpandedRowModel: enableExpandableRows
|
||||
? getExpandedRowModel()
|
||||
: undefined,
|
||||
manualPagination: enablePagination ? true : undefined,
|
||||
meta,
|
||||
})
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import {
|
||||
ProductCollectionListRes,
|
||||
ProductCollectionRes,
|
||||
} from "../../types/api-responses"
|
||||
AdminProductCategoryListResponse,
|
||||
AdminProductCategoryResponse,
|
||||
} from "@medusajs/types"
|
||||
import { getRequest } from "./common"
|
||||
|
||||
async function listProductCategories(query?: Record<string, any>) {
|
||||
return getRequest<ProductCollectionListRes>(`/admin/categories`, query)
|
||||
return getRequest<AdminProductCategoryListResponse>(
|
||||
`/admin/product-categories`,
|
||||
query
|
||||
)
|
||||
}
|
||||
|
||||
async function retrieveProductCategory(
|
||||
id: string,
|
||||
query?: Record<string, any>
|
||||
) {
|
||||
return getRequest<ProductCollectionRes>(`/admin/categories/${id}`, query)
|
||||
return getRequest<AdminProductCategoryResponse>(
|
||||
`/admin/product-categories/${id}`,
|
||||
query
|
||||
)
|
||||
}
|
||||
|
||||
export const categories = {
|
||||
|
||||
@@ -11,8 +11,13 @@ type TQueryKey<TKey, TListQuery = any, TDetailQuery = string> = {
|
||||
]
|
||||
details: () => readonly [...TQueryKey<TKey>["all"], "detail"]
|
||||
detail: (
|
||||
id: TDetailQuery
|
||||
) => readonly [...ReturnType<TQueryKey<TKey>["details"]>, TDetailQuery]
|
||||
id: TDetailQuery,
|
||||
query?: TListQuery
|
||||
) => readonly [
|
||||
...ReturnType<TQueryKey<TKey>["details"]>,
|
||||
TDetailQuery,
|
||||
{ query: TListQuery | undefined },
|
||||
]
|
||||
}
|
||||
|
||||
export type UseQueryOptionsWrapper<
|
||||
@@ -39,7 +44,11 @@ export const queryKeysFactory = <
|
||||
lists: () => [...queryKeyFactory.all, "list"],
|
||||
list: (query?: TListQueryType) => [...queryKeyFactory.lists(), { query }],
|
||||
details: () => [...queryKeyFactory.all, "detail"],
|
||||
detail: (id: TDetailQueryType) => [...queryKeyFactory.details(), id],
|
||||
detail: (id: TDetailQueryType, query?: TListQueryType) => [
|
||||
...queryKeyFactory.details(),
|
||||
id,
|
||||
{ query },
|
||||
],
|
||||
}
|
||||
return queryKeyFactory
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SalesChannelDTO, UserDTO } from "@medusajs/types"
|
||||
import { Navigate, Outlet, RouteObject, useLocation } from "react-router-dom"
|
||||
import { AdminCustomersRes } from "@medusajs/client-types"
|
||||
import { Spinner } from "@medusajs/icons"
|
||||
import {
|
||||
AdminCollectionsRes,
|
||||
@@ -7,14 +6,19 @@ import {
|
||||
AdminPromotionRes,
|
||||
AdminRegionsRes,
|
||||
} from "@medusajs/medusa"
|
||||
import {
|
||||
AdminApiKeyResponse,
|
||||
AdminProductCategoryResponse,
|
||||
SalesChannelDTO,
|
||||
UserDTO,
|
||||
} from "@medusajs/types"
|
||||
import { Navigate, Outlet, RouteObject, useLocation } from "react-router-dom"
|
||||
import { ErrorBoundary } from "../../components/error/error-boundary"
|
||||
import { MainLayout } from "../../components/layout-v2/main-layout"
|
||||
import { SettingsLayout } from "../../components/layout/settings-layout"
|
||||
import { useMe } from "../../hooks/api/users"
|
||||
import { AdminApiKeyResponse } from "@medusajs/types"
|
||||
import { SearchProvider } from "../search-provider"
|
||||
import { SidebarProvider } from "../sidebar-provider"
|
||||
import { AdminCustomersRes } from "@medusajs/client-types"
|
||||
|
||||
export const ProtectedRoute = () => {
|
||||
const { user, isLoading } = useMe()
|
||||
@@ -143,6 +147,27 @@ export const v2Routes: RouteObject[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/categories",
|
||||
handle: {
|
||||
crumb: () => "Categories",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
lazy: () => import("../../v2-routes/categories/category-list"),
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () =>
|
||||
import("../../v2-routes/categories/category-detail"),
|
||||
handle: {
|
||||
crumb: (data: AdminProductCategoryResponse) =>
|
||||
data.product_category.name,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/orders",
|
||||
handle: {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useLoaderData, useParams } from "react-router-dom"
|
||||
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||
import { useCategory } from "../../../hooks/api/categories"
|
||||
import { CategoryGeneralSection } from "./components/category-general-section"
|
||||
import { CategoryOrganizationSection } from "./components/category-organization-section"
|
||||
import { CategoryProductSection } from "./components/category-product-section"
|
||||
import { categoryLoader } from "./loader"
|
||||
|
||||
export const CategoryDetail = () => {
|
||||
const { id } = useParams()
|
||||
|
||||
const initialData = useLoaderData() as Awaited<
|
||||
ReturnType<typeof categoryLoader>
|
||||
>
|
||||
|
||||
const { product_category, isLoading, isError, error } = useCategory(
|
||||
id!,
|
||||
undefined,
|
||||
{
|
||||
initialData,
|
||||
}
|
||||
)
|
||||
|
||||
if (isLoading || !product_category) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="flex flex-col gap-x-4 lg:flex-row lg:items-start">
|
||||
<div className="flex w-full flex-col gap-y-2">
|
||||
<CategoryGeneralSection category={product_category} />
|
||||
<CategoryProductSection category={product_category} />
|
||||
<div className="hidden lg:block">
|
||||
<JsonViewSection data={product_category} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex w-full max-w-[100%] flex-col gap-y-2 lg:mt-0 lg:max-w-[400px]">
|
||||
<CategoryOrganizationSection category={product_category} />
|
||||
<div className="lg:hidden">
|
||||
<JsonViewSection data={product_category} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AdminProductCategoryResponse } from "@medusajs/types"
|
||||
import { Container, Heading, StatusBadge, Text } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { getIsActiveProps, getIsInternalProps } from "../../../common/utils"
|
||||
|
||||
type CategoryGeneralSectionProps = {
|
||||
category: AdminProductCategoryResponse["product_category"]
|
||||
}
|
||||
|
||||
export const CategoryGeneralSection = ({
|
||||
category,
|
||||
}: CategoryGeneralSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const activeProps = getIsActiveProps(category.is_active, t)
|
||||
const internalProps = getIsInternalProps(category.is_internal, t)
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading>{category.name}</Heading>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<StatusBadge color={activeProps.color}>
|
||||
{activeProps.label}
|
||||
</StatusBadge>
|
||||
<StatusBadge color={internalProps.color}>
|
||||
{internalProps.label}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<ActionMenu groups={[]} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 gap-3 px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("fields.description")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact">
|
||||
{category.description || "-"}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 gap-3 px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("fields.handle")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact">
|
||||
/{category.handle}
|
||||
</Text>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./category-general-section"
|
||||
@@ -0,0 +1,196 @@
|
||||
import { FolderIllustration, TriangleRightMini } from "@medusajs/icons"
|
||||
import { AdminProductCategoryResponse } from "@medusajs/types"
|
||||
import { Badge, Container, Heading, Text, Tooltip } from "@medusajs/ui"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
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 { getCategoryChildren, getCategoryPath } from "../../../common/utils"
|
||||
|
||||
type CategoryOrganizationSectionProps = {
|
||||
category: AdminProductCategoryResponse["product_category"]
|
||||
}
|
||||
|
||||
export const CategoryOrganizationSection = ({
|
||||
category,
|
||||
}: CategoryOrganizationSectionProps) => {
|
||||
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={[]} />
|
||||
</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")}
|
||||
</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")}
|
||||
</Text>
|
||||
<ChildrenDisplay category={category} />
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const PathDisplay = ({
|
||||
category,
|
||||
}: {
|
||||
category: AdminProductCategoryResponse["product_category"]
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
product_category: withParents,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useCategory(category.id, {
|
||||
include_ancestors_tree: true,
|
||||
fields: "id,name,parent_category",
|
||||
})
|
||||
|
||||
const chips = useMemo(() => getCategoryPath(withParents), [withParents])
|
||||
|
||||
if (isLoading || !withParents) {
|
||||
return <Skeleton className="h-5 w-16" />
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!chips.length) {
|
||||
return (
|
||||
<Text size="small" leading="compact">
|
||||
-
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (chips.length > 1 && !expanded) {
|
||||
return (
|
||||
<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")}>
|
||||
<button
|
||||
className="outline-none"
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<Text size="xsmall" leading="compact" weight="plus">
|
||||
...
|
||||
</Text>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<TriangleRightMini />
|
||||
<Text
|
||||
size="xsmall"
|
||||
leading="compact"
|
||||
weight="plus"
|
||||
className="truncate"
|
||||
>
|
||||
{chips[chips.length - 1].name}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (chips.length > 1 && expanded) {
|
||||
return (
|
||||
<div className="grid grid-cols-[20px_1fr] items-start gap-x-2">
|
||||
<FolderIllustration />
|
||||
<div className="gap- flex flex-wrap items-center gap-x-0.5 gap-y-1">
|
||||
{chips.map((chip, index) => {
|
||||
return (
|
||||
<div key={chip.id} className="flex items-center gap-x-0.5">
|
||||
{index === chips.length - 1 ? (
|
||||
<Text size="xsmall" leading="compact" weight="plus">
|
||||
{chip.name}
|
||||
</Text>
|
||||
) : (
|
||||
<InlineLink
|
||||
to={`/categories/${chip.id}`}
|
||||
className="txt-compact-xsmall-plus text-ui-fg-subtle hover:text-ui-fg-base focus-visible:text-ui-fg-base"
|
||||
>
|
||||
{chip.name}
|
||||
</InlineLink>
|
||||
)}
|
||||
{index < chips.length - 1 && <TriangleRightMini />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 items-start gap-x-2">
|
||||
{chips.map((chip, index) => (
|
||||
<div key={chip.id} className="flex items-center gap-x-0.5">
|
||||
<Text size="xsmall" leading="compact" weight="plus">
|
||||
{chip.name}
|
||||
</Text>
|
||||
{index < chips.length - 1 && <TriangleRightMini />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChildrenDisplay = ({
|
||||
category,
|
||||
}: {
|
||||
category: AdminProductCategoryResponse["product_category"]
|
||||
}) => {
|
||||
const {
|
||||
product_category: withChildren,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useCategory(category.id, {
|
||||
include_descendants_tree: true,
|
||||
fields: "id,name,category_children",
|
||||
})
|
||||
|
||||
const chips = useMemo(() => getCategoryChildren(withChildren), [withChildren])
|
||||
|
||||
if (isLoading || !withChildren) {
|
||||
return <Skeleton className="h-5 w-16" />
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!chips.length) {
|
||||
return (
|
||||
<Text size="small" leading="compact">
|
||||
-
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{chips.map((chip) => (
|
||||
<Badge key={chip.id} size="2xsmall" asChild>
|
||||
<Link to={`/categories/${chip.id}`}>{chip.name}</Link>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./category-organization-section"
|
||||
@@ -0,0 +1,71 @@
|
||||
import { AdminProductCategoryResponse } from "@medusajs/types"
|
||||
import { Container, Heading } from "@medusajs/ui"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
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 CategoryProductSectionProps = {
|
||||
category: AdminProductCategoryResponse["product_category"]
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export const CategoryProductSection = ({
|
||||
category,
|
||||
}: CategoryProductSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { raw, searchParams } = useProductTableQuery({ pageSize: PAGE_SIZE })
|
||||
const { products, count, isLoading, isError, error } = useProducts(
|
||||
{
|
||||
...searchParams,
|
||||
category_id: [category.id],
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const columns = useProductTableColumns()
|
||||
const filters = useProductTableFilters(["categories"])
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: products || [],
|
||||
columns,
|
||||
count,
|
||||
getRowId: (original) => original.id,
|
||||
pageSize: PAGE_SIZE,
|
||||
enableRowSelection: true,
|
||||
enablePagination: true,
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<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={[]} />
|
||||
</div>
|
||||
<DataTable
|
||||
table={table}
|
||||
filters={filters}
|
||||
columns={columns}
|
||||
orderBy={["title", "created_at", "updated_at"]}
|
||||
pageSize={PAGE_SIZE}
|
||||
count={count}
|
||||
navigateTo={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
queryObject={raw}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./category-product-section"
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CategoryDetail as Component } from "./category-detail"
|
||||
export { categoryLoader as loader } from "./loader"
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AdminProductCategoryResponse } from "@medusajs/types"
|
||||
import { LoaderFunctionArgs } from "react-router-dom"
|
||||
|
||||
import { categoriesQueryKeys } from "../../../hooks/api/categories"
|
||||
import { client } from "../../../lib/client"
|
||||
import { queryClient } from "../../../lib/medusa"
|
||||
|
||||
const categoryDetailQuery = (id: string) => ({
|
||||
queryKey: categoriesQueryKeys.detail(id),
|
||||
queryFn: async () => client.categories.retrieve(id),
|
||||
})
|
||||
|
||||
export const categoryLoader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const id = params.id
|
||||
const query = categoryDetailQuery(id!)
|
||||
|
||||
return (
|
||||
queryClient.getQueryData<AdminProductCategoryResponse>(query.queryKey) ??
|
||||
(await queryClient.fetchQuery(query))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { CategoryListTable } from "./components/category-list-table"
|
||||
|
||||
export const CategoryList = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<CategoryListTable />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { PencilSquare, TriangleRightMini } from "@medusajs/icons"
|
||||
import { AdminProductCategoryResponse } from "@medusajs/types"
|
||||
import { Container, Heading, IconButton, Text, clx } 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 { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { StatusCell } from "../../../../../components/table/table-cells/common/status-cell"
|
||||
import {
|
||||
TextCell,
|
||||
TextHeader,
|
||||
} from "../../../../../components/table/table-cells/common/text-cell"
|
||||
import { useCategories } from "../../../../../hooks/api/categories"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { useCategoryTableQuery } from "../../../common/hooks/use-category-table-query"
|
||||
import {
|
||||
getCategoryPath,
|
||||
getIsActiveProps,
|
||||
getIsInternalProps,
|
||||
} from "../../../common/utils"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export const CategoryListTable = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { raw, searchParams } = useCategoryTableQuery({ pageSize: PAGE_SIZE })
|
||||
|
||||
const query = raw.q
|
||||
? {
|
||||
include_ancestors_tree: true,
|
||||
fields: "id,name,handle,is_active,is_internal,parent_category",
|
||||
...searchParams,
|
||||
}
|
||||
: {
|
||||
include_descendants_tree: true,
|
||||
parent_category_id: "null",
|
||||
fields: "id,name,category_children,handle,is_internal,is_active",
|
||||
...searchParams,
|
||||
}
|
||||
|
||||
const { product_categories, count, isLoading, isError, error } =
|
||||
useCategories(
|
||||
{
|
||||
...query,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const columns = useCategoryTableColumns()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: product_categories || [],
|
||||
columns,
|
||||
count,
|
||||
getRowId: (original) => original.id,
|
||||
getSubRows: (original) => original.category_children,
|
||||
enableExpandableRows: true,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading>{t("categories.domain")}</Heading>
|
||||
</div>
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
count={count}
|
||||
pageSize={PAGE_SIZE}
|
||||
isLoading={isLoading}
|
||||
navigateTo={(row) => row.id}
|
||||
queryObject={raw}
|
||||
search
|
||||
pagination
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const CategoryRowActions = ({
|
||||
category,
|
||||
}: {
|
||||
category: AdminProductCategoryResponse["product_category"]
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.edit"),
|
||||
icon: <PencilSquare />,
|
||||
to: `${category.id}/edit`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper =
|
||||
createColumnHelper<AdminProductCategoryResponse["product_category"]>()
|
||||
|
||||
const useCategoryTableColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("name", {
|
||||
header: () => <TextHeader text={t("fields.name")} />,
|
||||
cell: ({ getValue, row }) => {
|
||||
const expandHandler = row.getToggleExpandedHandler()
|
||||
|
||||
console.log(row.original)
|
||||
|
||||
if (row.original.parent_category !== undefined) {
|
||||
const path = getCategoryPath(row.original)
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center">
|
||||
{path.map((chip) => (
|
||||
<div key={chip.id}>
|
||||
<Text>{chip.name}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center gap-x-3 overflow-hidden">
|
||||
<div className="flex size-7 items-center justify-center">
|
||||
{row.getCanExpand() ? (
|
||||
<IconButton
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
expandHandler()
|
||||
}}
|
||||
size="small"
|
||||
variant="transparent"
|
||||
>
|
||||
<TriangleRightMini
|
||||
className={clx({
|
||||
"rotate-90 transition-transform will-change-transform":
|
||||
row.getIsExpanded(),
|
||||
})}
|
||||
/>
|
||||
</IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="truncate">{getValue()}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("handle", {
|
||||
header: () => <TextHeader text={t("fields.handle")} />,
|
||||
cell: ({ getValue }) => {
|
||||
return <TextCell text={`/${getValue()}`} />
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("is_active", {
|
||||
header: () => <TextHeader text={t("fields.status")} />,
|
||||
cell: ({ getValue }) => {
|
||||
const { color, label } = getIsActiveProps(getValue(), t)
|
||||
|
||||
return <StatusCell color={color}>{label}</StatusCell>
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("is_internal", {
|
||||
header: () => <TextHeader text={t("categories.fields.visibility")} />,
|
||||
cell: ({ getValue }) => {
|
||||
const { color, label } = getIsInternalProps(getValue(), t)
|
||||
|
||||
return <StatusCell color={color}>{label}</StatusCell>
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
return <CategoryRowActions category={row.original} />
|
||||
},
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./category-list-table"
|
||||
@@ -0,0 +1 @@
|
||||
export { CategoryList as Component } from "./category-list"
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useQueryParams } from "../../../../hooks/use-query-params"
|
||||
|
||||
export const useCategoryTableQuery = ({
|
||||
pageSize = 20,
|
||||
prefix,
|
||||
}: {
|
||||
pageSize?: number
|
||||
prefix?: string
|
||||
}) => {
|
||||
const raw = useQueryParams(["q", "offset", "order"], prefix)
|
||||
|
||||
const searchParams = {
|
||||
q: raw.q,
|
||||
limit: pageSize,
|
||||
offset: raw.offset ? Number(raw.offset) : 0,
|
||||
order: raw.order,
|
||||
}
|
||||
|
||||
return {
|
||||
raw,
|
||||
searchParams,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { AdminProductCategoryResponse } from "@medusajs/types"
|
||||
import { TFunction } from "i18next"
|
||||
|
||||
export function getIsActiveProps(
|
||||
isActive: boolean,
|
||||
t: TFunction
|
||||
): { color: "green" | "red"; label: string } {
|
||||
switch (isActive) {
|
||||
case true:
|
||||
return {
|
||||
label: t("categories.fields.active"),
|
||||
color: "green",
|
||||
}
|
||||
case false:
|
||||
return {
|
||||
label: t("categories.fields.inactive"),
|
||||
color: "red",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getIsInternalProps(
|
||||
isInternal: boolean,
|
||||
t: TFunction
|
||||
): { color: "blue" | "green"; label: string } {
|
||||
switch (isInternal) {
|
||||
case true:
|
||||
return {
|
||||
label: t("categories.fields.internal"),
|
||||
color: "blue",
|
||||
}
|
||||
case false:
|
||||
return {
|
||||
label: t("categories.fields.public"),
|
||||
color: "green",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ChipProps = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function getCategoryPath(
|
||||
category?: AdminProductCategoryResponse["product_category"]
|
||||
): ChipProps[] {
|
||||
if (!category) {
|
||||
return []
|
||||
}
|
||||
|
||||
const path = category.parent_category
|
||||
? getCategoryPath(category.parent_category)
|
||||
: []
|
||||
path.push({ id: category.id, name: category.name })
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
export function getCategoryChildren(
|
||||
category?: AdminProductCategoryResponse["product_category"]
|
||||
): ChipProps[] {
|
||||
if (!category || !category.category_children) {
|
||||
return []
|
||||
}
|
||||
|
||||
return category.category_children.map((child) => ({
|
||||
id: child.id,
|
||||
name: child.name,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { AdminProductCategoryResponse } from "@medusajs/types"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
remoteQueryObjectFromString,
|
||||
} from "@medusajs/utils"
|
||||
import {
|
||||
AuthenticatedMedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "../../../../types/routing"
|
||||
import { AdminProductCategoryParamsType } from "../validators"
|
||||
|
||||
export const GET = async (
|
||||
req: AuthenticatedMedusaRequest<AdminProductCategoryParamsType>,
|
||||
res: MedusaResponse<AdminProductCategoryResponse>
|
||||
) => {
|
||||
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||
|
||||
const queryObject = remoteQueryObjectFromString({
|
||||
entryPoint: "product_category",
|
||||
variables: {
|
||||
filters: req.filterableFields,
|
||||
id: req.params.id,
|
||||
},
|
||||
fields: req.remoteQueryConfig.fields,
|
||||
})
|
||||
|
||||
const [product_category] = await remoteQuery(queryObject)
|
||||
|
||||
res.json({ product_category })
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
|
||||
import { authenticate } from "../../../utils/authenticate-middleware"
|
||||
import { validateAndTransformQuery } from "../../utils/validate-query"
|
||||
import * as QueryConfig from "./query-config"
|
||||
import {
|
||||
AdminProductCategoriesParams,
|
||||
AdminProductCategoryParams,
|
||||
} from "./validators"
|
||||
|
||||
export const adminProductCategoryRoutesMiddlewares: MiddlewareRoute[] = [
|
||||
{
|
||||
method: ["ALL"],
|
||||
matcher: "/admin/product-categories*",
|
||||
middlewares: [authenticate("admin", ["bearer", "session", "api-key"])],
|
||||
},
|
||||
{
|
||||
method: ["GET"],
|
||||
matcher: "/admin/product-categories",
|
||||
middlewares: [
|
||||
validateAndTransformQuery(
|
||||
AdminProductCategoriesParams,
|
||||
QueryConfig.listProductCategoryConfig
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
method: ["GET"],
|
||||
matcher: "/admin/product-categories/:id",
|
||||
middlewares: [
|
||||
validateAndTransformQuery(
|
||||
AdminProductCategoryParams,
|
||||
QueryConfig.retrieveProductCategoryConfig
|
||||
),
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
export const defaults = [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"handle",
|
||||
"is_active",
|
||||
"is_internal",
|
||||
"rank",
|
||||
"parent_category_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"metadata",
|
||||
|
||||
"parent_category.id",
|
||||
"parent_category.name",
|
||||
"category_children.id",
|
||||
"category_children.name",
|
||||
]
|
||||
|
||||
export const retrieveProductCategoryConfig = {
|
||||
defaults,
|
||||
isList: false,
|
||||
}
|
||||
|
||||
export const listProductCategoryConfig = {
|
||||
defaults,
|
||||
defaultLimit: 50,
|
||||
isList: true,
|
||||
}
|
||||
35
packages/medusa/src/api-v2/admin/product-categories/route.ts
Normal file
35
packages/medusa/src/api-v2/admin/product-categories/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { AdminProductCategoryListResponse } from "@medusajs/types"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
remoteQueryObjectFromString,
|
||||
} from "@medusajs/utils"
|
||||
import {
|
||||
AuthenticatedMedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "../../../types/routing"
|
||||
import { AdminProductCategoriesParamsType } from "./validators"
|
||||
|
||||
export const GET = async (
|
||||
req: AuthenticatedMedusaRequest<AdminProductCategoriesParamsType>,
|
||||
res: MedusaResponse<AdminProductCategoryListResponse>
|
||||
) => {
|
||||
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||
|
||||
const queryObject = remoteQueryObjectFromString({
|
||||
entryPoint: "product_category",
|
||||
variables: {
|
||||
filters: req.filterableFields,
|
||||
...req.remoteQueryConfig.pagination,
|
||||
},
|
||||
fields: req.remoteQueryConfig.fields,
|
||||
})
|
||||
|
||||
const { rows: product_categories, metadata } = await remoteQuery(queryObject)
|
||||
|
||||
res.json({
|
||||
product_categories,
|
||||
count: metadata.count,
|
||||
offset: metadata.skip,
|
||||
limit: metadata.take,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { z } from "zod"
|
||||
import { optionalBooleanMapper } from "../../../utils/validators/is-boolean"
|
||||
import {
|
||||
createFindParams,
|
||||
createOperatorMap,
|
||||
createSelectParams,
|
||||
} from "../../utils/validators"
|
||||
|
||||
export type AdminProductCategoryParamsType = z.infer<
|
||||
typeof AdminProductCategoryParams
|
||||
>
|
||||
export const AdminProductCategoryParams = createSelectParams().merge(
|
||||
z.object({
|
||||
include_ancestors_tree: z.preprocess(
|
||||
(val: any) => optionalBooleanMapper.get(val?.toLowerCase()),
|
||||
z.boolean().optional()
|
||||
),
|
||||
include_descendants_tree: z.preprocess(
|
||||
(val: any) => optionalBooleanMapper.get(val?.toLowerCase()),
|
||||
z.boolean().optional()
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
export type AdminProductCategoriesParamsType = z.infer<
|
||||
typeof AdminProductCategoriesParams
|
||||
>
|
||||
export const AdminProductCategoriesParams = createFindParams({
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
}).merge(
|
||||
z.object({
|
||||
q: z.string().optional(),
|
||||
id: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
description: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
handle: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
parent_category_id: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
include_ancestors_tree: z.preprocess(
|
||||
(val: any) => optionalBooleanMapper.get(val?.toLowerCase()),
|
||||
z.boolean().optional()
|
||||
),
|
||||
include_descendants_tree: z.preprocess(
|
||||
(val: any) => optionalBooleanMapper.get(val?.toLowerCase()),
|
||||
z.boolean().optional()
|
||||
),
|
||||
created_at: createOperatorMap().optional(),
|
||||
updated_at: createOperatorMap().optional(),
|
||||
deleted_at: createOperatorMap().optional(),
|
||||
$and: z.lazy(() => AdminProductCategoriesParams.array()).optional(),
|
||||
$or: z.lazy(() => AdminProductCategoriesParams.array()).optional(),
|
||||
})
|
||||
)
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ProductStatus } from "@medusajs/utils"
|
||||
import { z } from "zod"
|
||||
import { optionalBooleanMapper } from "../../../utils/validators/is-boolean"
|
||||
import {
|
||||
createFindParams,
|
||||
createOperatorMap,
|
||||
createSelectParams,
|
||||
} from "../../utils/validators"
|
||||
import { optionalBooleanMapper } from "../../../utils/validators/is-boolean"
|
||||
|
||||
const statusEnum = z.nativeEnum(ProductStatus)
|
||||
|
||||
@@ -28,6 +28,7 @@ export const AdminGetProductsParams = createFindParams({
|
||||
(val: any) => optionalBooleanMapper.get(val?.toLowerCase()),
|
||||
z.boolean().optional()
|
||||
),
|
||||
category_id: z.string().array().optional(),
|
||||
price_list_id: z.string().array().optional(),
|
||||
sales_channel_id: z.string().array().optional(),
|
||||
collection_id: z.string().array().optional(),
|
||||
|
||||
@@ -12,6 +12,7 @@ import { adminInviteRoutesMiddlewares } from "./admin/invites/middlewares"
|
||||
import { adminPaymentRoutesMiddlewares } from "./admin/payments/middlewares"
|
||||
import { adminPriceListsRoutesMiddlewares } from "./admin/price-lists/middlewares"
|
||||
import { adminPricingRoutesMiddlewares } from "./admin/pricing/middlewares"
|
||||
import { adminProductCategoryRoutesMiddlewares } from "./admin/product-categories/middlewares"
|
||||
import { adminProductTypeRoutesMiddlewares } from "./admin/product-types/middlewares"
|
||||
import { adminProductRoutesMiddlewares } from "./admin/products/middlewares"
|
||||
import { adminPromotionRoutesMiddlewares } from "./admin/promotions/middlewares"
|
||||
@@ -67,5 +68,6 @@ export const config: MiddlewaresConfig = {
|
||||
...adminProductTypeRoutesMiddlewares,
|
||||
...adminUploadRoutesMiddlewares,
|
||||
...adminFulfillmentSetsRoutesMiddlewares,
|
||||
...adminProductCategoryRoutesMiddlewares,
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { NextFunction } from "express"
|
||||
import { MedusaRequest, MedusaResponse } from "../../types/routing"
|
||||
import { zodValidator } from "./validate-body"
|
||||
import { z } from "zod"
|
||||
import { removeUndefinedProperties } from "../../utils"
|
||||
import { omit } from "lodash"
|
||||
import { BaseEntity, QueryConfig, RequestQueryFields } from "@medusajs/types"
|
||||
import { NextFunction } from "express"
|
||||
import { omit } from "lodash"
|
||||
import { z } from "zod"
|
||||
import { MedusaRequest, MedusaResponse } from "../../types/routing"
|
||||
import { removeUndefinedProperties } from "../../utils"
|
||||
import {
|
||||
prepareListQuery,
|
||||
prepareRetrieveQuery,
|
||||
} from "../../utils/get-query-config"
|
||||
import { FindConfig } from "@medusajs/types"
|
||||
import { zodValidator } from "./validate-body"
|
||||
/**
|
||||
* Normalize an input query, especially from array like query params to an array type
|
||||
* e.g: /admin/orders/?fields[]=id,status,cart_id becomes { fields: ["id", "status", "cart_id"] }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
DALUtils,
|
||||
Searchable,
|
||||
createPsqlIndexStatementHelper,
|
||||
generateEntityId,
|
||||
kebabCase,
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
PrimaryKey,
|
||||
Property,
|
||||
} from "@mikro-orm/core"
|
||||
|
||||
import Product from "./product"
|
||||
|
||||
const categoryHandleIndexName = "IDX_category_handle_unique"
|
||||
@@ -47,9 +47,11 @@ class ProductCategory {
|
||||
@PrimaryKey({ columnType: "text" })
|
||||
id!: string
|
||||
|
||||
@Searchable()
|
||||
@Property({ columnType: "text", nullable: false })
|
||||
name?: string
|
||||
|
||||
@Searchable()
|
||||
@Property({ columnType: "text", default: "", nullable: false })
|
||||
description?: string
|
||||
|
||||
@@ -124,11 +126,14 @@ class ProductCategory {
|
||||
}
|
||||
|
||||
const { em } = args
|
||||
const parentCategoryId = args.changeSet?.entity?.parent_category?.id
|
||||
|
||||
let parentCategory: ProductCategory | null = null
|
||||
|
||||
if (parentCategoryId) {
|
||||
parentCategory = await em.findOne(ProductCategory, parentCategoryId)
|
||||
if (this.parent_category_id) {
|
||||
parentCategory = await em.findOne(
|
||||
ProductCategory,
|
||||
this.parent_category_id
|
||||
)
|
||||
}
|
||||
|
||||
if (parentCategory) {
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
ProductCategoryTransformOptions,
|
||||
ProductTypes,
|
||||
} from "@medusajs/types"
|
||||
import { DALUtils, MedusaError, isDefined } from "@medusajs/utils"
|
||||
import {
|
||||
DALUtils,
|
||||
MedusaError,
|
||||
isDefined
|
||||
} from "@medusajs/utils"
|
||||
import {
|
||||
LoadStrategy,
|
||||
FilterQuery as MikroFilterQuery,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types"
|
||||
import {
|
||||
FreeTextSearchFilterKey,
|
||||
InjectManager,
|
||||
InjectTransactionManager,
|
||||
MedusaContext,
|
||||
@@ -76,6 +77,17 @@ export default class ProductCategoryService<
|
||||
delete filters.include_descendants_tree
|
||||
delete filters.include_ancestors_tree
|
||||
|
||||
// Apply free text search filter
|
||||
if (filters?.q) {
|
||||
config.filters ??= {}
|
||||
config.filters[FreeTextSearchFilterKey] = {
|
||||
value: filters.q,
|
||||
fromEntity: ProductCategory.name,
|
||||
}
|
||||
|
||||
delete filters.q
|
||||
}
|
||||
|
||||
const queryOptions = ModulesSdkUtils.buildQuery<ProductCategory>(
|
||||
filters,
|
||||
config
|
||||
@@ -102,6 +114,17 @@ export default class ProductCategoryService<
|
||||
delete filters.include_descendants_tree
|
||||
delete filters.include_ancestors_tree
|
||||
|
||||
// Apply free text search filter
|
||||
if (filters?.q) {
|
||||
config.filters ??= {}
|
||||
config.filters[FreeTextSearchFilterKey] = {
|
||||
value: filters.q,
|
||||
fromEntity: ProductCategory.name,
|
||||
}
|
||||
|
||||
delete filters.q
|
||||
}
|
||||
|
||||
const queryOptions = ModulesSdkUtils.buildQuery<ProductCategory>(
|
||||
filters,
|
||||
config
|
||||
|
||||
@@ -2,9 +2,9 @@ import { RepositoryTransformOptions } from "../common"
|
||||
import { Context } from "../shared-context"
|
||||
import {
|
||||
BaseFilterable,
|
||||
FilterQuery as InternalFilterQuery,
|
||||
FilterQuery,
|
||||
FindOptions,
|
||||
FilterQuery as InternalFilterQuery,
|
||||
UpsertWithReplaceConfig,
|
||||
} from "./index"
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from "./pricing"
|
||||
export * from "./sales-channel"
|
||||
export * from "./stock-locations"
|
||||
export * from "./tax"
|
||||
export * from "./product-category"
|
||||
|
||||
1
packages/types/src/http/product-category/admin/index.ts
Normal file
1
packages/types/src/http/product-category/admin/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./product-category"
|
||||
@@ -0,0 +1,34 @@
|
||||
import { PaginatedResponse } from "../../../common"
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
interface ProductCategoryResponse {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
handle: string | null
|
||||
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[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
export interface AdminProductCategoryResponse {
|
||||
product_category: ProductCategoryResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
export interface AdminProductCategoryListResponse extends PaginatedResponse {
|
||||
product_categories: ProductCategoryResponse[]
|
||||
}
|
||||
1
packages/types/src/http/product-category/index.ts
Normal file
1
packages/types/src/http/product-category/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./admin"
|
||||
@@ -900,6 +900,10 @@ export interface FilterableProductCategoryProps
|
||||
* Whether to include parents of retrieved product categories.
|
||||
*/
|
||||
include_ancestors_tree?: boolean
|
||||
/**
|
||||
* Filter product categories based on searchable fields
|
||||
*/
|
||||
q?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user