feat: Categories retrieve + list API (#7009)
This commit is contained in:
+18
-2
@@ -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: {
|
||||
|
||||
+51
@@ -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>
|
||||
)
|
||||
}
|
||||
+53
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./category-general-section"
|
||||
+196
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./category-organization-section"
|
||||
+71
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -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>
|
||||
)
|
||||
}
|
||||
+203
@@ -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]
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./category-list-table"
|
||||
@@ -0,0 +1 @@
|
||||
export { CategoryList as Component } from "./category-list"
|
||||
+23
@@ -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,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user