feat: Add product routes and components to v2 in admin-next (#6958)

This commit is contained in:
Stevche Radevski authored and GitHub committed 2024-04-06 11:59:52 +02:00
1 parent d333db0842
commit 07fb058d96
81 files changed
+597 -144

No files matched your search

@@ -236,6 +236,22 @@
}, },
"weight": { "weight": {
"label": "Weight" "label": "Weight"
},
"options": {
"label": "Product options",
"hint": "Options are used to define the color, size, etc. of the product",
"optionTitle": "Option title",
"variations": "Variations (comma-separated)"
},
"variants": {
"label": "Product variants",
"hint": "Variants left unchecked won't be created, This ranking will affect how the variants are ranked in your frontend."
},
"mid_code": {
"label": "Mid code"
},
"hs_code": {
"label": "HS code"
} }
}, },
"variant": { "variant": {
@@ -0,0 +1 @@
export * from "./list"
@@ -0,0 +1,54 @@
import { Checkbox, Text } from "@medusajs/ui"
export interface ListProps<T> {
options: { title: string; value: T }[]
value?: T[]
onChange?: (value: T[]) => void
compare?: (a: T, b: T) => boolean
disabled?: boolean
}
export const List = <T extends any>({
options,
onChange,
value,
compare,
disabled,
}: ListProps<T>) => {
if (options.length === 0) {
return <div>No options</div>
}
return (
<div className="flex-row justify-center border divide-y rounded-lg">
{options.map((option) => {
return (
<div className="flex p-4 gap-x-4">
{onChange && value !== undefined && (
<Checkbox
disabled={disabled}
checked={value.some(
(v) => compare?.(v, option.value) ?? v === option.value
)}
onCheckedChange={(checked) => {
if (checked) {
onChange([...value, option.value])
} else {
onChange(
value.filter(
(v) =>
!(compare?.(v, option.value) ?? v === option.value)
)
)
}
}}
/>
)}
<Text key={option.title}>{option.title}</Text>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,39 @@
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,
options?: Omit<
UseQueryOptions<CategoryRes, Error, CategoryRes, QueryKey>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: categoriesQueryKeys.detail(id),
queryFn: async () => client.categories.retrieve(id),
...options,
})
return { ...data, ...rest }
}
export const useCategories = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<CategoriesListRes, Error, CategoriesListRes, QueryKey>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: categoriesQueryKeys.list(query),
queryFn: async () => client.categories.list(query),
...options,
})
return { ...data, ...rest }
}
@@ -1,7 +1,18 @@
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query" import {
QueryKey,
UseMutationOptions,
UseQueryOptions,
useMutation,
useQuery,
} from "@tanstack/react-query"
import { client } from "../../lib/client" import { client } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory" import { queryKeysFactory } from "../../lib/query-key-factory"
import { ProductListRes, ProductRes } from "../../types/api-responses" import {
ProductDeleteRes,
ProductListRes,
ProductRes,
} from "../../types/api-responses"
import { queryClient } from "../../lib/medusa"
const PRODUCTS_QUERY_KEY = "products" as const const PRODUCTS_QUERY_KEY = "products" as const
export const productsQueryKeys = queryKeysFactory(PRODUCTS_QUERY_KEY) export const productsQueryKeys = queryKeysFactory(PRODUCTS_QUERY_KEY)
@@ -38,3 +49,48 @@ export const useProducts = (
return { ...data, ...rest } return { ...data, ...rest }
} }
export const useCreateProduct = (
options?: UseMutationOptions<ProductRes, Error, any>
) => {
return useMutation({
mutationFn: (payload: any) => client.products.create(payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({ queryKey: productsQueryKeys.lists() })
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useUpdateProduct = (
id: string,
options?: UseMutationOptions<ProductRes, Error, any>
) => {
return useMutation({
mutationFn: (payload: any) => client.products.update(id, payload),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({ queryKey: productsQueryKeys.lists() })
queryClient.invalidateQueries({ queryKey: productsQueryKeys.detail(id) })
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useDeleteProduct = (
id: string,
options?: UseMutationOptions<ProductDeleteRes, Error, void>
) => {
return useMutation({
mutationFn: () => client.products.delete(id),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({ queryKey: productsQueryKeys.lists() })
queryClient.invalidateQueries({ queryKey: productsQueryKeys.detail(id) })
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
@@ -0,0 +1,39 @@
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
import { client } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { TagsListRes, TagRes } from "../../types/api-responses"
const TAGS_QUERY_KEY = "tags" as const
export const tagsQueryKeys = queryKeysFactory(TAGS_QUERY_KEY)
export const useTag = (
id: string,
options?: Omit<
UseQueryOptions<TagRes, Error, TagRes, QueryKey>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: tagsQueryKeys.detail(id),
queryFn: async () => client.tags.retrieve(id),
...options,
})
return { ...data, ...rest }
}
export const useTags = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<TagsListRes, Error, TagsListRes, QueryKey>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: tagsQueryKeys.list(query),
queryFn: async () => client.tags.list(query),
...options,
})
return { ...data, ...rest }
}
@@ -0,0 +1,21 @@
import {
ProductCollectionListRes,
ProductCollectionRes,
} from "../../types/api-responses"
import { getRequest } from "./common"
async function listProductCategories(query?: Record<string, any>) {
return getRequest<ProductCollectionListRes>(`/admin/categories`, query)
}
async function retrieveProductCategory(
id: string,
query?: Record<string, any>
) {
return getRequest<ProductCollectionRes>(`/admin/categories/${id}`, query)
}
export const categories = {
list: listProductCategories,
retrieve: retrieveProductCategory,
}
@@ -1,5 +1,6 @@
import { apiKeys } from "./api-keys" import { apiKeys } from "./api-keys"
import { auth } from "./auth" import { auth } from "./auth"
import { categories } from "./categories"
import { collections } from "./collections" import { collections } from "./collections"
import { currencies } from "./currencies" import { currencies } from "./currencies"
import { customers } from "./customers" import { customers } from "./customers"
@@ -11,18 +12,21 @@ import { regions } from "./regions"
import { salesChannels } from "./sales-channels" import { salesChannels } from "./sales-channels"
import { stockLocations } from "./stock-locations" import { stockLocations } from "./stock-locations"
import { stores } from "./stores" import { stores } from "./stores"
import { tags } from "./tags"
import { users } from "./users" import { users } from "./users"
import { workflowExecutions } from "./workflow-executions" import { workflowExecutions } from "./workflow-executions"
export const client = { export const client = {
auth: auth, auth: auth,
apiKeys: apiKeys, apiKeys: apiKeys,
categories: categories,
customers: customers, customers: customers,
currencies: currencies, currencies: currencies,
collections: collections, collections: collections,
promotions: promotions, promotions: promotions,
stores: stores, stores: stores,
salesChannels: salesChannels, salesChannels: salesChannels,
tags: tags,
users: users, users: users,
regions: regions, regions: regions,
invites: invites, invites: invites,
@@ -1,15 +1,34 @@
import { ProductListRes, ProductRes } from "../../types/api-responses" import {
import { getRequest } from "./common" ProductDeleteRes,
ProductListRes,
ProductRes,
} from "../../types/api-responses"
import { deleteRequest, getRequest, postRequest } from "./common"
async function retrieveProduct(id: string, query?: Record<string, any>) { async function retrieveProduct(id: string, query?: Record<string, any>) {
return getRequest<ProductRes>(`/admin/products/${id}`, query) return getRequest<ProductRes>(`/admin/products/${id}`, query)
} }
async function createProduct(payload: any) {
return postRequest<ProductRes>(`/admin/products`, payload)
}
async function listProducts(query?: Record<string, any>) { async function listProducts(query?: Record<string, any>) {
return getRequest<ProductListRes>(`/admin/products`, query) return getRequest<ProductListRes>(`/admin/products`, query)
} }
async function updateProduct(id: string, payload: any) {
return postRequest<ProductRes>(`/admin/products/${id}`, payload)
}
async function deleteProduct(id: string) {
return deleteRequest<ProductDeleteRes>(`/admin/products/${id}`)
}
export const products = { export const products = {
retrieve: retrieveProduct, retrieve: retrieveProduct,
list: listProducts, list: listProducts,
create: createProduct,
update: updateProduct,
delete: deleteProduct,
} }
@@ -0,0 +1,18 @@
import {
ProductCollectionListRes,
ProductCollectionRes,
} from "../../types/api-responses"
import { getRequest } from "./common"
async function listProductTags(query?: Record<string, any>) {
return getRequest<ProductCollectionListRes>(`/admin/tags`, query)
}
async function retrieveProductTag(id: string, query?: Record<string, any>) {
return getRequest<ProductCollectionRes>(`/admin/tags/${id}`, query)
}
export const tags = {
list: listProductTags,
retrieve: retrieveProductTag,
}
@@ -5,7 +5,9 @@ import type {
AdminDraftOrdersRes, AdminDraftOrdersRes,
AdminGiftCardsRes, AdminGiftCardsRes,
AdminOrdersRes, AdminOrdersRes,
AdminProductsRes, AdminRegionsRes,
AdminSalesChannelsRes,
AdminUserRes,
} from "@medusajs/medusa" } from "@medusajs/medusa"
import { Outlet, RouteObject } from "react-router-dom" import { Outlet, RouteObject } from "react-router-dom"
@@ -188,66 +190,6 @@ export const v1Routes: RouteObject[] = [
}, },
], ],
}, },
{
path: "/products",
handle: {
crumb: () => "Products",
},
children: [
{
path: "",
lazy: () => import("../../routes/products/product-list"),
children: [
{
path: "create",
lazy: () => import("../../routes/products/product-create"),
},
],
},
{
path: ":id",
lazy: () => import("../../routes/products/product-detail"),
handle: {
crumb: (data: AdminProductsRes) => data.product.title,
},
children: [
{
path: "edit",
lazy: () => import("../../routes/products/product-edit"),
},
{
path: "sales-channels",
lazy: () =>
import("../../routes/products/product-sales-channels"),
},
{
path: "attributes",
lazy: () =>
import("../../routes/products/product-attributes"),
},
{
path: "options/create",
lazy: () =>
import("../../routes/products/product-create-option"),
},
{
path: "options/:option_id/edit",
lazy: () =>
import("../../routes/products/product-edit-option"),
},
{
path: "media",
lazy: () => import("../../routes/products/product-media"),
},
{
path: "variants/:variant_id/edit",
lazy: () =>
import("../../routes/products/product-edit-variant"),
},
],
},
],
},
{ {
path: "/categories", path: "/categories",
handle: { handle: {
@@ -2,7 +2,7 @@ import { SalesChannelDTO, UserDTO } from "@medusajs/types"
import { Navigate, Outlet, RouteObject, useLocation } from "react-router-dom" import { Navigate, Outlet, RouteObject, useLocation } from "react-router-dom"
import { Spinner } from "@medusajs/icons" import { Spinner } from "@medusajs/icons"
import { AdminCollectionsRes } from "@medusajs/medusa" import { AdminCollectionsRes, AdminProductsRes } from "@medusajs/medusa"
import { ErrorBoundary } from "../../components/error/error-boundary" import { ErrorBoundary } from "../../components/error/error-boundary"
import { MainLayout } from "../../components/layout-v2/main-layout" import { MainLayout } from "../../components/layout-v2/main-layout"
import { SettingsLayout } from "../../components/layout/settings-layout" import { SettingsLayout } from "../../components/layout/settings-layout"
@@ -66,6 +66,68 @@ export const v2Routes: RouteObject[] = [
path: "/", path: "/",
element: <MainLayout />, element: <MainLayout />,
children: [ children: [
{
path: "/products",
handle: {
crumb: () => "Products",
},
children: [
{
path: "",
lazy: () => import("../../v2-routes/products/product-list"),
children: [
{
path: "create",
lazy: () =>
import("../../v2-routes/products/product-create"),
},
],
},
{
path: ":id",
lazy: () => import("../../v2-routes/products/product-detail"),
handle: {
crumb: (data: AdminProductsRes) => data.product.title,
},
children: [
{
path: "edit",
lazy: () => import("../../v2-routes/products/product-edit"),
},
{
path: "sales-channels",
lazy: () =>
import("../../v2-routes/products/product-sales-channels"),
},
{
path: "attributes",
lazy: () =>
import("../../v2-routes/products/product-attributes"),
},
{
path: "options/create",
lazy: () =>
import("../../v2-routes/products/product-create-option"),
},
{
path: "options/:option_id/edit",
lazy: () =>
import("../../v2-routes/products/product-edit-option"),
},
{
path: "media",
lazy: () =>
import("../../v2-routes/products/product-media"),
},
{
path: "variants/:variant_id/edit",
lazy: () =>
import("../../v2-routes/products/product-edit-variant"),
},
],
},
],
},
{ {
path: "/orders", path: "/orders",
handle: { handle: {
@@ -364,7 +426,7 @@ export const v2Routes: RouteObject[] = [
handle: { handle: {
crumb: (data: ApiKeyRes) => { crumb: (data: ApiKeyRes) => {
console.log("data", data) console.log("data", data)
return data.apiKey.title return data.api_key.title
}, },
}, },
children: [ children: [
@@ -15,10 +15,10 @@ import { MoneyAmountCell } from "../../../../../../components/table/table-cells/
import { PlaceholderCell } from "../../../../../../components/table/table-cells/common/placeholder-cell" import { PlaceholderCell } from "../../../../../../components/table/table-cells/common/placeholder-cell"
import { ProductCell } from "../../../../../../components/table/table-cells/product/product-cell" import { ProductCell } from "../../../../../../components/table/table-cells/product/product-cell"
import { useDataTable } from "../../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../../hooks/use-data-table"
import { useProductVariantTableFilters } from "../../../../../products/product-detail/components/product-variant-section/use-variant-table-filters"
import { useProductVariantTableQuery } from "../../../../../products/product-detail/components/product-variant-section/use-variant-table-query"
import { useCreateDraftOrder } from "../hooks" import { useCreateDraftOrder } from "../hooks"
import { ExistingItem } from "../types" import { ExistingItem } from "../types"
import { useProductVariantTableQuery } from "../../../../../../v2-routes/products/product-detail/components/product-variant-section/use-variant-table-query"
import { useProductVariantTableFilters } from "../../../../../../v2-routes/products/product-detail/components/product-variant-section/use-variant-table-filters"
const PAGE_SIZE = 50 const PAGE_SIZE = 50
const PREFIX = "av" const PREFIX = "av"
@@ -193,7 +193,7 @@ const useVariantTableColumns = () => {
cell: ({ getValue }) => { cell: ({ getValue }) => {
const options = getValue() const options = getValue()
const displayValue = options?.map((o) => o.value).join(" · ") const displayValue = options?.map((o: any) => o.value).join(" · ")
return ( return (
<div className="flex size-full items-center overflow-hidden"> <div className="flex size-full items-center overflow-hidden">
@@ -21,6 +21,7 @@ import {
UserDTO, UserDTO,
} from "@medusajs/types" } from "@medusajs/types"
import { WorkflowExecutionDTO } from "../v2-routes/workflow-executions/types" import { WorkflowExecutionDTO } from "../v2-routes/workflow-executions/types"
import { ProductTagDTO } from "@medusajs/types/dist/product"
type ListRes = { type ListRes = {
count: number count: number
@@ -99,6 +100,14 @@ export type ProductRes = { product: ExtendedProductDTO }
export type ProductListRes = { products: ExtendedProductDTO[] } & ListRes export type ProductListRes = { products: ExtendedProductDTO[] } & ListRes
export type ProductDeleteRes = DeleteRes export type ProductDeleteRes = DeleteRes
// Categories
export type CategoryRes = { category: ProductCategoryDTO }
export type CategoriesListRes = { categories: ProductCategoryDTO[] } & ListRes
// Tags
export type TagRes = { tag: ProductTagDTO }
export type TagsListRes = { tags: ProductTagDTO[] } & ListRes
// Product Types // Product Types
export type ProductTypeRes = { product_type: ProductTypeDTO } export type ProductTypeRes = { product_type: ProductTypeDTO }
export type ProductTypeListRes = { product_types: ProductTypeDTO[] } & ListRes export type ProductTypeListRes = { product_types: ProductTypeDTO[] } & ListRes
@@ -1,17 +1,16 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { Product } from "@medusajs/medusa" import { Product } from "@medusajs/medusa"
import { Button, Input } from "@medusajs/ui" import { Button, Input } from "@medusajs/ui"
import { useAdminUpdateProduct } from "medusa-react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import * as zod from "zod" import * as zod from "zod"
import { CountrySelect } from "../../../../../components/common/country-select" import { CountrySelect } from "../../../../../components/common/country-select"
import { Form } from "../../../../../components/common/form" import { Form } from "../../../../../components/common/form"
import { import {
RouteDrawer, RouteDrawer,
useRouteModal, useRouteModal,
} from "../../../../../components/route-modal" } from "../../../../../components/route-modal"
import { useUpdateProduct } from "../../../../../hooks/api/products"
type ProductAttributesFormProps = { type ProductAttributesFormProps = {
product: Product product: Product
@@ -57,7 +56,7 @@ export const ProductAttributesForm = ({
resolver: zodResolver(ProductAttributesSchema), resolver: zodResolver(ProductAttributesSchema),
}) })
const { mutateAsync, isLoading } = useAdminUpdateProduct(product.id) const { mutateAsync, isLoading } = useUpdateProduct(product.id)
const handleSubmit = form.handleSubmit(async (data) => { const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync( await mutateAsync(
@@ -1,16 +1,16 @@
import { Heading } from "@medusajs/ui" import { Heading } from "@medusajs/ui"
import { useAdminProduct } from "medusa-react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal" import { RouteDrawer } from "../../../components/route-modal"
import { ProductAttributesForm } from "./components/product-attributes-form" import { ProductAttributesForm } from "./components/product-attributes-form"
import { useProduct } from "../../../hooks/api/products"
export const ProductAttributes = () => { export const ProductAttributes = () => {
const { id } = useParams() const { id } = useParams()
const { t } = useTranslation() const { t } = useTranslation()
const { product, isLoading, isError, error } = useAdminProduct(id!) const { product, isLoading, isError, error } = useProduct(id!)
if (isError) { if (isError) {
throw error throw error
@@ -1,15 +1,15 @@
import { Heading } from "@medusajs/ui" import { Heading } from "@medusajs/ui"
import { useAdminProduct } from "medusa-react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal" import { RouteDrawer } from "../../../components/route-modal"
import { CreateProductOptionForm } from "./components/create-product-option-form" import { CreateProductOptionForm } from "./components/create-product-option-form"
import { useProduct } from "../../../hooks/api/products"
export const ProductCreateOption = () => { export const ProductCreateOption = () => {
const { id } = useParams() const { id } = useParams()
const { t } = useTranslation() const { t } = useTranslation()
const { product, isLoading, isError, error } = useAdminProduct(id!) const { product, isLoading, isError, error } = useProduct(id!)
if (isError) { if (isError) {
throw error throw error
@@ -13,13 +13,6 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"
import { SalesChannel } from "@medusajs/medusa" import { SalesChannel } from "@medusajs/medusa"
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table" import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
import {
useAdminCollections,
useAdminProductCategories,
useAdminProductTags,
useAdminProductTypes,
useAdminSalesChannels,
} from "medusa-react"
import { Fragment, useMemo, useState } from "react" import { Fragment, useMemo, useState } from "react"
import { CountrySelect } from "../../../../../components/common/country-select" import { CountrySelect } from "../../../../../components/common/country-select"
import { Form } from "../../../../../components/common/form" import { Form } from "../../../../../components/common/form"
@@ -32,6 +25,12 @@ import { useDataTable } from "../../../../../hooks/use-data-table"
import { CreateProductFormReturn } from "./create-product-form" import { CreateProductFormReturn } from "./create-product-form"
import { Combobox } from "../../../../../components/common/combobox" import { Combobox } from "../../../../../components/common/combobox"
import { FileUpload } from "../../../../../components/common/file-upload" import { FileUpload } from "../../../../../components/common/file-upload"
import { List } from "../../../../../components/common/list"
import { useProductTypes } from "../../../../../hooks/api/product-types"
import { useCollections } from "../../../../../hooks/api/collections"
import { useSalesChannels } from "../../../../../hooks/api/sales-channels"
import { useCategories } from "../../../../../hooks/api/categories"
import { useTags } from "../../../../../hooks/api/tags"
type CreateProductPropsProps = { type CreateProductPropsProps = {
form: CreateProductFormReturn form: CreateProductFormReturn
@@ -46,16 +45,48 @@ const SUPPORTED_FORMATS = [
"image/svg+xml", "image/svg+xml",
] ]
const permutations = (
data: { title: string; values: string[] }[]
): { [key: string]: string }[] => {
if (data.length === 0) {
return []
}
if (data.length === 1) {
return data[0].values.map((value) => ({ [data[0].title]: value }))
}
const toProcess = data[0]
const rest = data.slice(1)
return toProcess.values.flatMap((value) => {
return permutations(rest).map((permutation) => {
return {
[toProcess.title]: value,
...permutation,
}
})
})
}
const generateNameFromPermutation = (permutation: {
[key: string]: string
}) => {
return Object.values(permutation).join(" / ")
}
export const CreateProductDetails = ({ form }: CreateProductPropsProps) => { export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [open, onOpenChange] = useState(false) const [open, onOpenChange] = useState(false)
const { product_types, isLoading: isLoadingTypes } = useAdminProductTypes() const { product_types, isLoading: isLoadingTypes } = useProductTypes()
const { product_tags, isLoading: isLoadingTags } = useAdminProductTags() const { product_tags, isLoading: isLoadingTags } = useTags()
const { collections, isLoading: isLoadingCollections } = useAdminCollections() const { collections, isLoading: isLoadingCollections } = useCollections()
const { sales_channels, isLoading: isLoadingSalesChannels } = const { sales_channels, isLoading: isLoadingSalesChannels } =
useAdminSalesChannels() useSalesChannels()
const { product_categories, isLoading: isLoadingCategories } = const { product_categories, isLoading: isLoadingCategories } = useCategories()
useAdminProductCategories()
const options = form.watch("options")
const optionPermutations = permutations(options)
// const { append } = useFieldArray({ // const { append } = useFieldArray({
// name: "images", // name: "images",
@@ -202,14 +233,18 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
<Form.Field <Form.Field
control={form.control} control={form.control}
name="type_id" name="type_id"
render={({ field }) => { render={({ field: { onChange, ...field } }) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label optional> <Form.Label optional>
{t("products.fields.type.label")} {t("products.fields.type.label")}
</Form.Label> </Form.Label>
<Form.Control> <Form.Control>
<Select disabled={isLoadingTypes} {...field}> <Select
disabled={isLoadingTypes}
{...field}
onValueChange={onChange}
>
<Select.Trigger ref={field.ref}> <Select.Trigger ref={field.ref}>
<Select.Value /> <Select.Value />
</Select.Trigger> </Select.Trigger>
@@ -229,14 +264,18 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
<Form.Field <Form.Field
control={form.control} control={form.control}
name="collection_id" name="collection_id"
render={({ field }) => { render={({ field: { onChange, ...field } }) => {
return ( return (
<Form.Item> <Form.Item>
<Form.Label optional> <Form.Label optional>
{t("products.fields.collection.label")} {t("products.fields.collection.label")}
</Form.Label> </Form.Label>
<Form.Control> <Form.Control>
<Select disabled={isLoadingCollections} {...field}> <Select
disabled={isLoadingCollections}
{...field}
onValueChange={onChange}
>
<Select.Trigger ref={field.ref}> <Select.Trigger ref={field.ref}>
<Select.Value /> <Select.Value />
</Select.Trigger> </Select.Trigger>
@@ -351,6 +390,91 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
</div> </div>
<div id="variants" className="flex flex-col gap-y-8"> <div id="variants" className="flex flex-col gap-y-8">
<Heading level="h2">{t("products.variants")}</Heading> <Heading level="h2">{t("products.variants")}</Heading>
<div className="grid grid-cols-1 gap-x-4 gap-y-8">
<Form.Field
control={form.control}
name="options"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.options.label")}
</Form.Label>
<Form.Hint>
{t("products.fields.options.hint")}
</Form.Hint>
<Form.Control>
<button
type="button"
onClick={() => {
field.onChange([
{
title: "Color",
values: ["Red", "Blue", "Green"],
},
{ title: "Size", values: ["S", "M", "L"] },
])
}}
>
Test
</button>
</Form.Control>
</Form.Item>
)
}}
/>
</div>
<div className="grid grid-cols-1 gap-x-4 gap-y-8">
<Form.Field
control={form.control}
name="variants"
render={({ field: { value, onChange, ...field } }) => {
const selectedOptions = value.map((v) => v.options)
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.variants.label")}
</Form.Label>
<Form.Hint>
{t("products.fields.variants.hint")}
</Form.Hint>
<Form.Control>
<List
{...field}
value={selectedOptions}
onChange={(v) => {
onChange(
v.map((options, i) => {
return {
title:
generateNameFromPermutation(options),
variant_rank: i,
options,
prices: [],
}
})
)
}}
compare={(a, b) => {
return (
generateNameFromPermutation(a) ===
generateNameFromPermutation(b)
)
}}
options={optionPermutations.map((opt) => {
return {
title: generateNameFromPermutation(opt),
value: opt,
}
})}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div> </div>
<div id="attributes" className="flex flex-col gap-y-8"> <div id="attributes" className="flex flex-col gap-y-8">
@@ -454,8 +578,39 @@ export const CreateProductDetails = ({ form }: CreateProductPropsProps) => {
) )
}} }}
/> />
<Form.Field
control={form.control}
name="mid_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.mid_code.label")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="hs_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("products.fields.hs_code.label")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
</div> </div>
{/* TODO: Add missing attribute fields */}
</div> </div>
<div id="media" className="flex flex-col gap-y-8"> <div id="media" className="flex flex-col gap-y-8">
<Heading level="h2">{t("products.media.label")}</Heading> <Heading level="h2">{t("products.media.label")}</Heading>
@@ -1,6 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { Button } from "@medusajs/ui" import { Button } from "@medusajs/ui"
import { useAdminCreateProduct } from "medusa-react"
import { UseFormReturn, useForm } from "react-hook-form" import { UseFormReturn, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import * as zod from "zod" import * as zod from "zod"
@@ -10,6 +9,7 @@ import {
useRouteModal, useRouteModal,
} from "../../../../../components/route-modal" } from "../../../../../components/route-modal"
import { CreateProductDetails } from "./create-product-details" import { CreateProductDetails } from "./create-product-details"
import { useCreateProduct } from "../../../../../hooks/api/products"
const CreateProductSchema = zod.object({ const CreateProductSchema = zod.object({
title: zod.string(), title: zod.string(),
@@ -30,8 +30,16 @@ const CreateProductSchema = zod.object({
weight: zod.string().optional(), weight: zod.string().optional(),
mid_code: zod.string().optional(), mid_code: zod.string().optional(),
hs_code: zod.string().optional(), hs_code: zod.string().optional(),
options: zod.array(
zod.object({
title: zod.string(),
values: zod.array(zod.string()),
})
),
variants: zod.array( variants: zod.array(
zod.object({ zod.object({
title: zod.string(),
options: zod.record(zod.string(), zod.string()),
variant_rank: zod.number(), variant_rank: zod.number(),
}) })
), ),
@@ -55,17 +63,18 @@ export const CreateProductForm = () => {
discountable: true, discountable: true,
tags: [], tags: [],
sales_channels: [], sales_channels: [],
options: [],
variants: [], variants: [],
images: [], images: [],
}, },
resolver: zodResolver(CreateProductSchema), resolver: zodResolver(CreateProductSchema),
}) })
const { mutateAsync, isLoading } = useAdminCreateProduct() const { mutateAsync, isLoading } = useCreateProduct()
const handleSubmit = form.handleSubmit(async (values) => { const handleSubmit = form.handleSubmit(
await mutateAsync( async (values) => {
{ const reqData = {
...values, ...values,
is_giftcard: false, is_giftcard: false,
tags: values.tags?.map((tag) => ({ value: tag })), tags: values.tags?.map((tag) => ({ value: tag })),
@@ -74,15 +83,21 @@ export const CreateProductForm = () => {
length: values.length ? parseFloat(values.length) : undefined, length: values.length ? parseFloat(values.length) : undefined,
height: values.height ? parseFloat(values.height) : undefined, height: values.height ? parseFloat(values.height) : undefined,
weight: values.weight ? parseFloat(values.weight) : undefined, weight: values.weight ? parseFloat(values.weight) : undefined,
variants: values.variants.map((v) => ({ title: "", prices: [] })), variants: values.variants,
}, } as any
{
delete reqData.sales_channels
await mutateAsync(reqData, {
onSuccess: ({ product }) => { onSuccess: ({ product }) => {
handleSuccess(`../${product.id}`) handleSuccess(`../${product.id}`)
}, },
} })
) },
}) (err) => {
console.log(err)
}
)
return ( return (
<RouteFocusModal.Form form={form}> <RouteFocusModal.Form form={form}>
@@ -1,10 +1,10 @@
import { PencilSquare, Trash } from "@medusajs/icons" import { PencilSquare, Trash } from "@medusajs/icons"
import { Product } from "@medusajs/medusa" import { Product } from "@medusajs/medusa"
import { Container, Heading, StatusBadge, Text, usePrompt } from "@medusajs/ui" import { Container, Heading, StatusBadge, Text, usePrompt } from "@medusajs/ui"
import { useAdminDeleteProduct } from "medusa-react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeleteProduct } from "../../../../../hooks/api/products"
type ProductGeneralSectionProps = { type ProductGeneralSectionProps = {
product: Product product: Product
@@ -17,7 +17,7 @@ export const ProductGeneralSection = ({
const prompt = usePrompt() const prompt = usePrompt()
const navigate = useNavigate() const navigate = useNavigate()
const { mutateAsync } = useAdminDeleteProduct(product.id) const { mutateAsync } = useDeleteProduct(product.id)
const handleDelete = async () => { const handleDelete = async () => {
const res = await prompt({ const res = await prompt({
@@ -9,11 +9,11 @@ import {
clx, clx,
usePrompt, usePrompt,
} from "@medusajs/ui" } from "@medusajs/ui"
import { useAdminUpdateProduct } from "medusa-react"
import { useState } from "react" import { useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { useUpdateProduct } from "../../../../../hooks/api/products"
type ProductMedisaSectionProps = { type ProductMedisaSectionProps = {
product: Product product: Product
@@ -37,7 +37,7 @@ export const ProductMediaSection = ({ product }: ProductMedisaSectionProps) => {
}) })
} }
const { mutateAsync } = useAdminUpdateProduct(product.id) const { mutateAsync } = useUpdateProduct(product.id)
const handleDelete = async () => { const handleDelete = async () => {
const ids = Object.keys(selection) const ids = Object.keys(selection)
@@ -1,4 +1,3 @@
import { useAdminProduct } from "medusa-react"
import { Outlet, useLoaderData, useParams } from "react-router-dom" import { Outlet, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section" import { JsonViewSection } from "../../../components/common/json-view-section"
@@ -15,6 +14,7 @@ import before from "medusa-admin:widgets/product/details/before"
import sideAfter from "medusa-admin:widgets/product/details/side/after" import sideAfter from "medusa-admin:widgets/product/details/side/after"
import sideBefore from "medusa-admin:widgets/product/details/side/before" import sideBefore from "medusa-admin:widgets/product/details/side/before"
import { ProductOrganizationSection } from "./components/product-organization-section" import { ProductOrganizationSection } from "./components/product-organization-section"
import { useProduct } from "../../../hooks/api/products"
export const ProductDetail = () => { export const ProductDetail = () => {
const initialData = useLoaderData() as Awaited< const initialData = useLoaderData() as Awaited<
@@ -22,13 +22,9 @@ export const ProductDetail = () => {
> >
const { id } = useParams() const { id } = useParams()
const { product, isLoading, isError, error } = useAdminProduct( const { product, isLoading, isError, error } = useProduct(id!, undefined, {
id!, initialData: initialData,
undefined, })
{
initialData: initialData,
}
)
if (isLoading || !product) { if (isLoading || !product) {
return <div>Loading...</div> return <div>Loading...</div>
@@ -1,15 +1,15 @@
import { Heading } from "@medusajs/ui" import { Heading } from "@medusajs/ui"
import { useAdminProduct } from "medusa-react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { json, useParams } from "react-router-dom" import { json, useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal" import { RouteDrawer } from "../../../components/route-modal"
import { CreateProductOptionForm } from "./components/edit-product-option-form" import { CreateProductOptionForm } from "./components/edit-product-option-form"
import { useProduct } from "../../../hooks/api/products"
export const ProductEditOption = () => { export const ProductEditOption = () => {
const { id, option_id } = useParams() const { id, option_id } = useParams()
const { t } = useTranslation() const { t } = useTranslation()
const { product, isLoading, isError, error } = useAdminProduct(id!) const { product, isLoading, isError, error } = useProduct(id!)
const option = product?.options.find((o) => o.id === option_id) const option = product?.options.find((o) => o.id === option_id)
@@ -1,11 +1,11 @@
import { ProductVariant } from "@medusajs/medusa" import { ProductVariant } from "@medusajs/medusa"
import { Heading } from "@medusajs/ui" import { Heading } from "@medusajs/ui"
import { useAdminProduct } from "medusa-react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { json, useLoaderData, useParams } from "react-router-dom" import { json, useLoaderData, useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal" import { RouteDrawer } from "../../../components/route-modal"
import { ProductEditVariantForm } from "./components/product-edit-variant-form" import { ProductEditVariantForm } from "./components/product-edit-variant-form"
import { editProductVariantLoader } from "./loader" import { editProductVariantLoader } from "./loader"
import { useProduct } from "../../../hooks/api/products"
export const ProductEditVariant = () => { export const ProductEditVariant = () => {
const loaderData = useLoaderData() as Awaited< const loaderData = useLoaderData() as Awaited<
@@ -15,13 +15,9 @@ export const ProductEditVariant = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { id, variant_id } = useParams() const { id, variant_id } = useParams()
const { product, isLoading, isError, error } = useAdminProduct( const { product, isLoading, isError, error } = useProduct(id!, undefined, {
id!, initialData: loaderData?.initialData,
undefined, })
{
initialData: loaderData?.initialData,
}
)
const variant = product?.variants.find( const variant = product?.variants.find(
(v: ProductVariant) => v.id === variant_id (v: ProductVariant) => v.id === variant_id
@@ -2,7 +2,6 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { Product } from "@medusajs/medusa" import { Product } from "@medusajs/medusa"
import { ProductStatus } from "@medusajs/types" import { ProductStatus } from "@medusajs/types"
import { Button, Input, Select, Switch, Text, Textarea } from "@medusajs/ui" import { Button, Input, Select, Switch, Text, Textarea } from "@medusajs/ui"
import { useAdminUpdateProduct } from "medusa-react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { Trans, useTranslation } from "react-i18next" import { Trans, useTranslation } from "react-i18next"
import * as zod from "zod" import * as zod from "zod"
@@ -12,6 +11,7 @@ import {
RouteDrawer, RouteDrawer,
useRouteModal, useRouteModal,
} from "../../../../../components/route-modal" } from "../../../../../components/route-modal"
import { useUpdateProduct } from "../../../../../hooks/api/products"
type EditProductFormProps = { type EditProductFormProps = {
product: Product product: Product
@@ -44,7 +44,7 @@ export const EditProductForm = ({ product }: EditProductFormProps) => {
resolver: zodResolver(EditProductSchema), resolver: zodResolver(EditProductSchema),
}) })
const { mutateAsync, isLoading } = useAdminUpdateProduct(product.id) const { mutateAsync, isLoading } = useUpdateProduct(product.id)
const handleSubmit = form.handleSubmit(async (data) => { const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync( await mutateAsync(
@@ -1,16 +1,16 @@
import { Heading } from "@medusajs/ui" import { Heading } from "@medusajs/ui"
import { useAdminProduct } from "medusa-react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal" import { RouteDrawer } from "../../../components/route-modal"
import { EditProductForm } from "./components/edit-product-form" import { EditProductForm } from "./components/edit-product-form"
import { useProduct } from "../../../hooks/api/products"
export const ProductEdit = () => { export const ProductEdit = () => {
const { id } = useParams() const { id } = useParams()
const { t } = useTranslation() const { t } = useTranslation()
const { product, isLoading, isError, error } = useAdminProduct(id!) const { product, isLoading, isError, error } = useProduct(id!)
if (isError) { if (isError) {
throw error throw error
@@ -2,7 +2,6 @@ import { PencilSquare, Trash } from "@medusajs/icons"
import type { Product } from "@medusajs/medusa" import type { Product } from "@medusajs/medusa"
import { Button, Container, Heading, usePrompt } from "@medusajs/ui" import { Button, Container, Heading, usePrompt } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table" import { createColumnHelper } from "@tanstack/react-table"
import { useAdminDeleteProduct, useAdminProducts } from "medusa-react"
import { useMemo } from "react" import { useMemo } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Link, Outlet, useLoaderData } from "react-router-dom" import { Link, Outlet, useLoaderData } from "react-router-dom"
@@ -14,6 +13,10 @@ import { useProductTableFilters } from "../../../../../hooks/table/filters/use-p
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query" import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../hooks/use-data-table"
import { productsLoader } from "../../loader" import { productsLoader } from "../../loader"
import {
useDeleteProduct,
useProducts,
} from "../../../../../hooks/api/products"
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -25,7 +28,7 @@ export const ProductListTable = () => {
> >
const { searchParams, raw } = useProductTableQuery({ pageSize: PAGE_SIZE }) const { searchParams, raw } = useProductTableQuery({ pageSize: PAGE_SIZE })
const { products, count, isLoading, isError, error } = useAdminProducts( const { products, count, isLoading, isError, error } = useProducts(
{ {
...searchParams, ...searchParams,
}, },
@@ -80,7 +83,7 @@ export const ProductListTable = () => {
const ProductActions = ({ product }: { product: Product }) => { const ProductActions = ({ product }: { product: Product }) => {
const { t } = useTranslation() const { t } = useTranslation()
const prompt = usePrompt() const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteProduct(product.id) const { mutateAsync } = useDeleteProduct(product.id)
const handleDelete = async () => { const handleDelete = async () => {
const res = await prompt({ const res = await prompt({
@@ -12,8 +12,8 @@ import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Link, useLocation } from "react-router-dom" import { Link, useLocation } from "react-router-dom"
import { useAdminUpdateProduct } from "medusa-react"
import { RouteFocusModal } from "../../../../../components/route-modal" import { RouteFocusModal } from "../../../../../components/route-modal"
import { useUpdateProduct } from "../../../../../hooks/api/products"
type ProductMediaGalleryProps = { type ProductMediaGalleryProps = {
product: Product product: Product
@@ -25,7 +25,7 @@ export const ProductMediaGallery = ({ product }: ProductMediaGalleryProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const prompt = usePrompt() const prompt = usePrompt()
const { mutateAsync, isLoading } = useAdminUpdateProduct(product.id) const { mutateAsync, isLoading } = useUpdateProduct(product.id)
const media = getMedia(product.images, product.thumbnail) const media = getMedia(product.images, product.thumbnail)
@@ -1,12 +1,12 @@
import { useAdminProduct } from "medusa-react"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/route-modal" import { RouteFocusModal } from "../../../components/route-modal"
import { ProductMediaView } from "./components/product-media-view" import { ProductMediaView } from "./components/product-media-view"
import { useProduct } from "../../../hooks/api/products"
export const ProductMedia = () => { export const ProductMedia = () => {
const { id } = useParams() const { id } = useParams()
const { product, isLoading, isError, error } = useAdminProduct(id!) const { product, isLoading, isError, error } = useProduct(id!)
const ready = !isLoading && product const ready = !isLoading && product
@@ -1,12 +1,12 @@
import { useAdminProduct } from "medusa-react"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/route-modal" import { RouteFocusModal } from "../../../components/route-modal"
import { EditSalesChannelsForm } from "./components/edit-sales-channels-form" import { EditSalesChannelsForm } from "./components/edit-sales-channels-form"
import { useProduct } from "../../../hooks/api/products"
export const ProductSalesChannels = () => { export const ProductSalesChannels = () => {
const { id } = useParams() const { id } = useParams()
const { product, isLoading, isError, error } = useAdminProduct(id!) const { product, isLoading, isError, error } = useProduct(id!)
if (isError) { if (isError) {
throw error throw error
@@ -45,7 +45,7 @@ export const createProductsWorkflow = createWorkflow(
const inputProduct = data.input.products[i] const inputProduct = data.input.products[i]
return p.variants?.map((v, j) => ({ return p.variants?.map((v, j) => ({
...v, ...v,
prices: inputProduct?.variants?.[j]?.prices, prices: inputProduct?.variants?.[j]?.prices ?? [],
})) }))
}) })
.flat() .flat()
@@ -54,7 +54,7 @@ export const createProductsWorkflow = createWorkflow(
const pricesToCreate = transform({ variantsWithAssociatedPrices }, (data) => const pricesToCreate = transform({ variantsWithAssociatedPrices }, (data) =>
data.variantsWithAssociatedPrices.map((v) => ({ data.variantsWithAssociatedPrices.map((v) => ({
prices: v.prices, prices: v.prices ?? [],
})) }))
) )
@@ -509,6 +509,10 @@ export class AdminPostProductsProductVariantsReq {
@IsOptional() @IsOptional()
manage_inventory?: boolean = true manage_inventory?: boolean = true
@IsNumber()
@IsOptional()
variant_rank?: number
@IsNumber() @IsNumber()
@IsOptional() @IsOptional()
weight?: number weight?: number
@@ -538,9 +542,10 @@ export class AdminPostProductsProductVariantsReq {
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
@IsArray() @IsArray()
@IsOptional()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => ProductVariantPricesCreateReq) @Type(() => ProductVariantPricesCreateReq)
prices: ProductVariantPricesCreateReq[] prices?: ProductVariantPricesCreateReq[]
@IsOptional() @IsOptional()
@IsObject() @IsObject()
@@ -588,6 +593,10 @@ export class AdminPostProductsProductVariantsVariantReq {
@IsOptional() @IsOptional()
manage_inventory?: boolean manage_inventory?: boolean
@IsNumber()
@IsOptional()
variant_rank?: number
@IsNumber() @IsNumber()
@IsOptional() @IsOptional()
weight?: number weight?: number