From 6cc9a5e469e28dcc41388d47b017c0b6571e39ff Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Mon, 8 Apr 2024 19:26:34 +0200 Subject: [PATCH] feat: Categories retrieve + list API (#7009) --- .../__tests__/admin/product-category.spec.ts | 79 +++++++ .../public/locales/en-US/translation.json | 15 +- .../data-table-root/data-table-root.tsx | 20 +- .../dashboard/src/hooks/api/categories.tsx | 24 ++- .../filters/use-product-table-filters.tsx | 10 +- .../dashboard/src/hooks/use-data-table.tsx | 9 + .../dashboard/src/lib/client/categories.ts | 16 +- .../dashboard/src/lib/query-key-factory.ts | 15 +- .../src/providers/router-provider/v2.tsx | 33 ++- .../category-detail/category-detail.tsx | 51 +++++ .../category-general-section.tsx | 53 +++++ .../category-general-section/index.ts | 1 + .../category-organization-section.tsx | 196 +++++++++++++++++ .../category-organization-section/index.ts | 1 + .../category-product-section.tsx | 71 ++++++ .../category-product-section/index.ts | 1 + .../categories/category-detail/index.ts | 2 + .../categories/category-detail/loader.ts | 21 ++ .../category-list/category-list.tsx | 9 + .../category-list-table.tsx | 203 ++++++++++++++++++ .../components/category-list-table/index.ts | 1 + .../categories/category-list/index.ts | 1 + .../common/hooks/use-category-table-query.tsx | 23 ++ .../src/v2-routes/categories/common/utils.ts | 71 ++++++ .../admin/product-categories/[id]/route.ts | 30 +++ .../admin/product-categories/middlewares.ts | 36 ++++ .../admin/product-categories/query-config.ts | 29 +++ .../api-v2/admin/product-categories/route.ts | 35 +++ .../admin/product-categories/validators.ts | 52 +++++ .../src/api-v2/admin/products/validators.ts | 3 +- packages/medusa/src/api-v2/middlewares.ts | 2 + .../medusa/src/api-v2/utils/validate-query.ts | 13 +- .../product/src/models/product-category.ts | 13 +- .../src/repositories/product-category.ts | 6 +- .../product/src/services/product-category.ts | 23 ++ packages/types/src/dal/repository-service.ts | 2 +- packages/types/src/http/index.ts | 1 + .../src/http/product-category/admin/index.ts | 1 + .../admin/product-category.ts | 34 +++ .../types/src/http/product-category/index.ts | 1 + packages/types/src/product/common.ts | 4 + 41 files changed, 1176 insertions(+), 35 deletions(-) create mode 100644 integration-tests/api/__tests__/admin/product-category.spec.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/categories/category-detail/category-detail.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-general-section/category-general-section.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-general-section/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-organization-section/category-organization-section.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-organization-section/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-product-section/category-product-section.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-product-section/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/categories/category-detail/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/categories/category-detail/loader.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/categories/category-list/category-list.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/categories/category-list/components/category-list-table/category-list-table.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/categories/category-list/components/category-list-table/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/categories/category-list/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/categories/common/hooks/use-category-table-query.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/categories/common/utils.ts create mode 100644 packages/medusa/src/api-v2/admin/product-categories/[id]/route.ts create mode 100644 packages/medusa/src/api-v2/admin/product-categories/middlewares.ts create mode 100644 packages/medusa/src/api-v2/admin/product-categories/query-config.ts create mode 100644 packages/medusa/src/api-v2/admin/product-categories/route.ts create mode 100644 packages/medusa/src/api-v2/admin/product-categories/validators.ts create mode 100644 packages/types/src/http/product-category/admin/index.ts create mode 100644 packages/types/src/http/product-category/admin/product-category.ts create mode 100644 packages/types/src/http/product-category/index.ts diff --git a/integration-tests/api/__tests__/admin/product-category.spec.ts b/integration-tests/api/__tests__/admin/product-category.spec.ts new file mode 100644 index 0000000000..c7173b6b1e --- /dev/null +++ b/integration-tests/api/__tests__/admin/product-category.spec.ts @@ -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", + }), + ]) + }) + }) + }, +}) diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json index 4cda516a6b..a8dc33319b 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -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", diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx b/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx index 59de680244..0c959d8346 100644 --- a/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-root/data-table-root.tsx @@ -182,6 +182,7 @@ export const DataTableRoot = ({ {table.getRowModel().rows.map((row) => { const to = navigateTo ? navigateTo(row) : undefined const isRowDisabled = hasSelect && !row.getCanSelect() + return ( ({ "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 = ({ 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 ( ({ 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, diff --git a/packages/admin-next/dashboard/src/hooks/api/categories.tsx b/packages/admin-next/dashboard/src/hooks/api/categories.tsx index f5cd9e88c3..b0a606915c 100644 --- a/packages/admin-next/dashboard/src/hooks/api/categories.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/categories.tsx @@ -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, options?: Omit< - UseQueryOptions, + 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, options?: Omit< - UseQueryOptions, + UseQueryOptions< + AdminProductCategoryListResponse, + Error, + AdminProductCategoryListResponse, + QueryKey + >, "queryFn" | "queryKey" > ) => { diff --git a/packages/admin-next/dashboard/src/hooks/table/filters/use-product-table-filters.tsx b/packages/admin-next/dashboard/src/hooks/table/filters/use-product-table-filters.tsx index a89c4b7ca8..0907848b68 100644 --- a/packages/admin-next/dashboard/src/hooks/table/filters/use-product-table-filters.tsx +++ b/packages/admin-next/dashboard/src/hooks/table/filters/use-product-table-filters.tsx @@ -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") diff --git a/packages/admin-next/dashboard/src/hooks/use-data-table.tsx b/packages/admin-next/dashboard/src/hooks/use-data-table.tsx index 3ef748051e..ec79d72e9d 100644 --- a/packages/admin-next/dashboard/src/hooks/use-data-table.tsx +++ b/packages/admin-next/dashboard/src/hooks/use-data-table.tsx @@ -5,6 +5,7 @@ import { Row, RowSelectionState, getCoreRowModel, + getExpandedRowModel, getPaginationRowModel, useReactTable, } from "@tanstack/react-table" @@ -22,7 +23,9 @@ type UseDataTableProps = { updater: OnChangeFn } enablePagination?: boolean + enableExpandableRows?: boolean getRowId?: (original: TData, index: number) => string + getSubRows?: (original: TData) => TData[] meta?: Record prefix?: string } @@ -34,7 +37,9 @@ export const useDataTable = ({ pageSize: _pageSize = 20, enablePagination = true, enableRowSelection = false, + enableExpandableRows = false, rowSelection: _rowSelection, + getSubRows, getRowId, meta, prefix, @@ -107,6 +112,7 @@ export const useDataTable = ({ pageCount: Math.ceil((count ?? 0) / pageSize), enableRowSelection, getRowId, + getSubRows, onRowSelectionChange: enableRowSelection ? setRowSelection : undefined, onPaginationChange: enablePagination ? (onPaginationChange as OnChangeFn) @@ -115,6 +121,9 @@ export const useDataTable = ({ getPaginationRowModel: enablePagination ? getPaginationRowModel() : undefined, + getExpandedRowModel: enableExpandableRows + ? getExpandedRowModel() + : undefined, manualPagination: enablePagination ? true : undefined, meta, }) diff --git a/packages/admin-next/dashboard/src/lib/client/categories.ts b/packages/admin-next/dashboard/src/lib/client/categories.ts index cc5a3029fd..c6cbd611e0 100644 --- a/packages/admin-next/dashboard/src/lib/client/categories.ts +++ b/packages/admin-next/dashboard/src/lib/client/categories.ts @@ -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) { - return getRequest(`/admin/categories`, query) + return getRequest( + `/admin/product-categories`, + query + ) } async function retrieveProductCategory( id: string, query?: Record ) { - return getRequest(`/admin/categories/${id}`, query) + return getRequest( + `/admin/product-categories/${id}`, + query + ) } export const categories = { diff --git a/packages/admin-next/dashboard/src/lib/query-key-factory.ts b/packages/admin-next/dashboard/src/lib/query-key-factory.ts index df2830166b..7b3970df1d 100644 --- a/packages/admin-next/dashboard/src/lib/query-key-factory.ts +++ b/packages/admin-next/dashboard/src/lib/query-key-factory.ts @@ -11,8 +11,13 @@ type TQueryKey = { ] details: () => readonly [...TQueryKey["all"], "detail"] detail: ( - id: TDetailQuery - ) => readonly [...ReturnType["details"]>, TDetailQuery] + id: TDetailQuery, + query?: TListQuery + ) => readonly [ + ...ReturnType["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 } diff --git a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx index ea216cde9f..fec1a68be0 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx @@ -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: { diff --git a/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/category-detail.tsx b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/category-detail.tsx new file mode 100644 index 0000000000..434694ea2a --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/category-detail.tsx @@ -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 + > + + const { product_category, isLoading, isError, error } = useCategory( + id!, + undefined, + { + initialData, + } + ) + + if (isLoading || !product_category) { + return
Loading...
+ } + + if (isError) { + throw error + } + + return ( +
+
+
+ + +
+ +
+
+
+ +
+ +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-general-section/category-general-section.tsx b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-general-section/category-general-section.tsx new file mode 100644 index 0000000000..a16315386f --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-general-section/category-general-section.tsx @@ -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 ( + +
+ {category.name} +
+
+ + {activeProps.label} + + + {internalProps.label} + +
+ +
+
+
+ + {t("fields.description")} + + + {category.description || "-"} + +
+
+ + {t("fields.handle")} + + + /{category.handle} + +
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-general-section/index.ts b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-general-section/index.ts new file mode 100644 index 0000000000..d3f09e0907 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-general-section/index.ts @@ -0,0 +1 @@ +export * from "./category-general-section" diff --git a/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-organization-section/category-organization-section.tsx b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-organization-section/category-organization-section.tsx new file mode 100644 index 0000000000..84b909a838 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-organization-section/category-organization-section.tsx @@ -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 ( + +
+ {t("categories.organization.header")} + +
+
+ + {t("categories.organization.pathLabel")} + + +
+
+ + {t("categories.organization.childrenLabel")} + + +
+
+ ) +} + +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 + } + + if (isError) { + throw error + } + + if (!chips.length) { + return ( + + - + + ) + } + + if (chips.length > 1 && !expanded) { + return ( +
+ +
+ + + + + + {chips[chips.length - 1].name} + +
+
+ ) + } + + if (chips.length > 1 && expanded) { + return ( +
+ +
+ {chips.map((chip, index) => { + return ( +
+ {index === chips.length - 1 ? ( + + {chip.name} + + ) : ( + + {chip.name} + + )} + {index < chips.length - 1 && } +
+ ) + })} +
+
+ ) + } + + return ( +
+ {chips.map((chip, index) => ( +
+ + {chip.name} + + {index < chips.length - 1 && } +
+ ))} +
+ ) +} + +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 + } + + if (isError) { + throw error + } + + if (!chips.length) { + return ( + + - + + ) + } + + return ( +
+ {chips.map((chip) => ( + + {chip.name} + + ))} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-organization-section/index.ts b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-organization-section/index.ts new file mode 100644 index 0000000000..82e80f1664 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-organization-section/index.ts @@ -0,0 +1 @@ +export * from "./category-organization-section" diff --git a/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-product-section/category-product-section.tsx b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-product-section/category-product-section.tsx new file mode 100644 index 0000000000..5b7e9a619a --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-product-section/category-product-section.tsx @@ -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 ( + +
+ {t("products.domain")} + +
+ row.id} + isLoading={isLoading} + queryObject={raw} + /> +
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-product-section/index.ts b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-product-section/index.ts new file mode 100644 index 0000000000..c66de67a19 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/components/category-product-section/index.ts @@ -0,0 +1 @@ +export * from "./category-product-section" diff --git a/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/index.ts b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/index.ts new file mode 100644 index 0000000000..8e305c712c --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/index.ts @@ -0,0 +1,2 @@ +export { CategoryDetail as Component } from "./category-detail" +export { categoryLoader as loader } from "./loader" diff --git a/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/loader.ts b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/loader.ts new file mode 100644 index 0000000000..4b8d10b131 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/categories/category-detail/loader.ts @@ -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(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/categories/category-list/category-list.tsx b/packages/admin-next/dashboard/src/v2-routes/categories/category-list/category-list.tsx new file mode 100644 index 0000000000..01ee589e2b --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/categories/category-list/category-list.tsx @@ -0,0 +1,9 @@ +import { CategoryListTable } from "./components/category-list-table" + +export const CategoryList = () => { + return ( +
+ +
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/categories/category-list/components/category-list-table/category-list-table.tsx b/packages/admin-next/dashboard/src/v2-routes/categories/category-list/components/category-list-table/category-list-table.tsx new file mode 100644 index 0000000000..90fad0227a --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/categories/category-list/components/category-list-table/category-list-table.tsx @@ -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 ( + +
+ {t("categories.domain")} +
+ row.id} + queryObject={raw} + search + pagination + /> +
+ ) +} + +const CategoryRowActions = ({ + category, +}: { + category: AdminProductCategoryResponse["product_category"] +}) => { + const { t } = useTranslation() + + return ( + , + to: `${category.id}/edit`, + }, + ], + }, + ]} + /> + ) +} + +const columnHelper = + createColumnHelper() + +const useCategoryTableColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.accessor("name", { + header: () => , + cell: ({ getValue, row }) => { + const expandHandler = row.getToggleExpandedHandler() + + console.log(row.original) + + if (row.original.parent_category !== undefined) { + const path = getCategoryPath(row.original) + + return ( +
+ {path.map((chip) => ( +
+ {chip.name} +
+ ))} +
+ ) + } + + return ( +
+
+ {row.getCanExpand() ? ( + { + e.stopPropagation() + e.preventDefault() + + expandHandler() + }} + size="small" + variant="transparent" + > + + + ) : null} +
+ {getValue()} +
+ ) + }, + }), + columnHelper.accessor("handle", { + header: () => , + cell: ({ getValue }) => { + return + }, + }), + columnHelper.accessor("is_active", { + header: () => , + cell: ({ getValue }) => { + const { color, label } = getIsActiveProps(getValue(), t) + + return {label} + }, + }), + columnHelper.accessor("is_internal", { + header: () => , + cell: ({ getValue }) => { + const { color, label } = getIsInternalProps(getValue(), t) + + return {label} + }, + }), + columnHelper.display({ + id: "actions", + cell: ({ row }) => { + return + }, + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/categories/category-list/components/category-list-table/index.ts b/packages/admin-next/dashboard/src/v2-routes/categories/category-list/components/category-list-table/index.ts new file mode 100644 index 0000000000..0422d9cf07 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/categories/category-list/components/category-list-table/index.ts @@ -0,0 +1 @@ +export * from "./category-list-table" diff --git a/packages/admin-next/dashboard/src/v2-routes/categories/category-list/index.ts b/packages/admin-next/dashboard/src/v2-routes/categories/category-list/index.ts new file mode 100644 index 0000000000..7fe49c3194 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/categories/category-list/index.ts @@ -0,0 +1 @@ +export { CategoryList as Component } from "./category-list" diff --git a/packages/admin-next/dashboard/src/v2-routes/categories/common/hooks/use-category-table-query.tsx b/packages/admin-next/dashboard/src/v2-routes/categories/common/hooks/use-category-table-query.tsx new file mode 100644 index 0000000000..195c5b0f2e --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/categories/common/hooks/use-category-table-query.tsx @@ -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, + } +} diff --git a/packages/admin-next/dashboard/src/v2-routes/categories/common/utils.ts b/packages/admin-next/dashboard/src/v2-routes/categories/common/utils.ts new file mode 100644 index 0000000000..ef1334d7c3 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/categories/common/utils.ts @@ -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, + })) +} diff --git a/packages/medusa/src/api-v2/admin/product-categories/[id]/route.ts b/packages/medusa/src/api-v2/admin/product-categories/[id]/route.ts new file mode 100644 index 0000000000..12f6360182 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/product-categories/[id]/route.ts @@ -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, + res: MedusaResponse +) => { + 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 }) +} diff --git a/packages/medusa/src/api-v2/admin/product-categories/middlewares.ts b/packages/medusa/src/api-v2/admin/product-categories/middlewares.ts new file mode 100644 index 0000000000..d7c30229e8 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/product-categories/middlewares.ts @@ -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 + ), + ], + }, +] diff --git a/packages/medusa/src/api-v2/admin/product-categories/query-config.ts b/packages/medusa/src/api-v2/admin/product-categories/query-config.ts new file mode 100644 index 0000000000..54c4298cee --- /dev/null +++ b/packages/medusa/src/api-v2/admin/product-categories/query-config.ts @@ -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, +} diff --git a/packages/medusa/src/api-v2/admin/product-categories/route.ts b/packages/medusa/src/api-v2/admin/product-categories/route.ts new file mode 100644 index 0000000000..3b30d8e53a --- /dev/null +++ b/packages/medusa/src/api-v2/admin/product-categories/route.ts @@ -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, + res: MedusaResponse +) => { + 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, + }) +} diff --git a/packages/medusa/src/api-v2/admin/product-categories/validators.ts b/packages/medusa/src/api-v2/admin/product-categories/validators.ts new file mode 100644 index 0000000000..c9dae51403 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/product-categories/validators.ts @@ -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(), + }) +) diff --git a/packages/medusa/src/api-v2/admin/products/validators.ts b/packages/medusa/src/api-v2/admin/products/validators.ts index b440dda1da..51f4ebb65c 100644 --- a/packages/medusa/src/api-v2/admin/products/validators.ts +++ b/packages/medusa/src/api-v2/admin/products/validators.ts @@ -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(), diff --git a/packages/medusa/src/api-v2/middlewares.ts b/packages/medusa/src/api-v2/middlewares.ts index cdf5c7ffff..59afc2fe7d 100644 --- a/packages/medusa/src/api-v2/middlewares.ts +++ b/packages/medusa/src/api-v2/middlewares.ts @@ -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, ], } diff --git a/packages/medusa/src/api-v2/utils/validate-query.ts b/packages/medusa/src/api-v2/utils/validate-query.ts index ed78f5ad70..495590c99e 100644 --- a/packages/medusa/src/api-v2/utils/validate-query.ts +++ b/packages/medusa/src/api-v2/utils/validate-query.ts @@ -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"] } diff --git a/packages/product/src/models/product-category.ts b/packages/product/src/models/product-category.ts index c1be75ac5d..eceed4d89f 100644 --- a/packages/product/src/models/product-category.ts +++ b/packages/product/src/models/product-category.ts @@ -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) { diff --git a/packages/product/src/repositories/product-category.ts b/packages/product/src/repositories/product-category.ts index 5741510030..b098acff87 100644 --- a/packages/product/src/repositories/product-category.ts +++ b/packages/product/src/repositories/product-category.ts @@ -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, diff --git a/packages/product/src/services/product-category.ts b/packages/product/src/services/product-category.ts index a9836fe22d..e23ce2875b 100644 --- a/packages/product/src/services/product-category.ts +++ b/packages/product/src/services/product-category.ts @@ -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( 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( filters, config diff --git a/packages/types/src/dal/repository-service.ts b/packages/types/src/dal/repository-service.ts index 810a6074aa..f048136cdc 100644 --- a/packages/types/src/dal/repository-service.ts +++ b/packages/types/src/dal/repository-service.ts @@ -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" diff --git a/packages/types/src/http/index.ts b/packages/types/src/http/index.ts index a1ae4fdc9c..6bdcee2941 100644 --- a/packages/types/src/http/index.ts +++ b/packages/types/src/http/index.ts @@ -5,3 +5,4 @@ export * from "./pricing" export * from "./sales-channel" export * from "./stock-locations" export * from "./tax" +export * from "./product-category" diff --git a/packages/types/src/http/product-category/admin/index.ts b/packages/types/src/http/product-category/admin/index.ts new file mode 100644 index 0000000000..96720aa473 --- /dev/null +++ b/packages/types/src/http/product-category/admin/index.ts @@ -0,0 +1 @@ +export * from "./product-category" diff --git a/packages/types/src/http/product-category/admin/product-category.ts b/packages/types/src/http/product-category/admin/product-category.ts new file mode 100644 index 0000000000..afc9f12d5a --- /dev/null +++ b/packages/types/src/http/product-category/admin/product-category.ts @@ -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[] +} diff --git a/packages/types/src/http/product-category/index.ts b/packages/types/src/http/product-category/index.ts new file mode 100644 index 0000000000..26b8eb9dad --- /dev/null +++ b/packages/types/src/http/product-category/index.ts @@ -0,0 +1 @@ +export * from "./admin" diff --git a/packages/types/src/product/common.ts b/packages/types/src/product/common.ts index a40cc133bd..82245a5f42 100644 --- a/packages/types/src/product/common.ts +++ b/packages/types/src/product/common.ts @@ -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 } /**