feat: Categories retrieve + list API (#7009)

This commit is contained in:
Oli Juhl
2024-04-08 19:26:34 +02:00
committed by GitHub
parent db2f0ef53f
commit 6cc9a5e469
41 changed files with 1176 additions and 35 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { CategoryDetail as Component } from "./category-detail"
export { categoryLoader as loader } from "./loader"

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from "./category-list-table"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,3 +5,4 @@ export * from "./pricing"
export * from "./sales-channel"
export * from "./stock-locations"
export * from "./tax"
export * from "./product-category"

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from "./admin"

View File

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