From f65fbff53538c2b9ebf7ca56a6e0250887da09d3 Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Sun, 7 Apr 2024 17:52:47 +0200 Subject: [PATCH] feat: Improvements to the products details page in the admin (#6994) --- .../api/__tests__/admin/product.js | 24 ++ .../public/locales/en-US/translation.json | 3 + .../dashboard/src/hooks/api/products.tsx | 37 +++- .../dashboard/src/lib/client/products.ts | 5 + .../src/providers/router-provider/v2.tsx | 14 +- .../create-product-option-form.tsx | 30 ++- .../create-product-variant-form.tsx | 107 +++++++++ .../create-product-variant-form/index.ts | 1 + .../products/product-create-variant/index.ts | 1 + .../product-create-variant.tsx | 26 +++ .../edit-product-option-form.tsx | 32 ++- .../product-organization-form/index.ts | 1 + .../product-organization-form.tsx | 205 ++++++++++++++++++ .../products/product-organization/index.ts | 1 + .../product-organization.tsx | 27 +++ 15 files changed, 507 insertions(+), 7 deletions(-) create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/components/create-product-variant-form/create-product-variant-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/components/create-product-variant-form/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/product-create-variant.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-organization/components/product-organization-form/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-organization/components/product-organization-form/product-organization-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-organization/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/products/product-organization/product-organization.tsx diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index d6d4075b6f..8d42554284 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -381,6 +381,30 @@ medusaIntegrationTestRunner({ ) }) + it("returns a list of products where id is a list", async () => { + const response = await api + .get( + `/admin/products?id[]=${baseProduct.id},${proposedProduct.id}`, + adminHeaders + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(2) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: baseProduct.id, + }), + expect.objectContaining({ + id: proposedProduct.id, + }), + ]) + ) + }) + // TODO: Decide how this should be handled in v2 it.skip("returns a list of products filtered by discount condition id", async () => { const resProd = await api.get("/admin/products", adminHeaders) 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 a4b222ca3d..4cda516a6b 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -260,6 +260,9 @@ "edit": { "header": "Edit Variant" }, + "create": { + "header": "Create Variant" + }, "inventory": { "header": "Stock & Inventory", "manageInventoryLabel": "Manage inventory", diff --git a/packages/admin-next/dashboard/src/hooks/api/products.tsx b/packages/admin-next/dashboard/src/hooks/api/products.tsx index 6f4fa7be80..6a648e2ec7 100644 --- a/packages/admin-next/dashboard/src/hooks/api/products.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/products.tsx @@ -28,6 +28,9 @@ export const useCreateProductOption = ( client.products.createOption(productId, payload), onSuccess: (data: any, variables: any, context: any) => { queryClient.invalidateQueries({ queryKey: optionsQueryKeys.lists() }) + queryClient.invalidateQueries({ + queryKey: productsQueryKeys.detail(productId), + }) options?.onSuccess?.(data, variables, context) }, ...options, @@ -47,6 +50,9 @@ export const useUpdateProductOption = ( queryClient.invalidateQueries({ queryKey: optionsQueryKeys.detail(optionId), }) + queryClient.invalidateQueries({ + queryKey: productsQueryKeys.detail(productId), + }) options?.onSuccess?.(data, variables, context) }, @@ -62,9 +68,12 @@ export const useDeleteProductOption = ( return useMutation({ mutationFn: () => client.products.deleteOption(productId, optionId), onSuccess: (data: any, variables: any, context: any) => { - queryClient.invalidateQueries({ queryKey: productsQueryKeys.lists() }) + queryClient.invalidateQueries({ queryKey: optionsQueryKeys.lists() }) queryClient.invalidateQueries({ - queryKey: productsQueryKeys.detail(optionId), + queryKey: optionsQueryKeys.detail(optionId), + }) + queryClient.invalidateQueries({ + queryKey: productsQueryKeys.detail(productId), }) options?.onSuccess?.(data, variables, context) @@ -108,6 +117,24 @@ export const useProductVariants = ( return { ...data, ...rest } } +export const useCreateProductVariant = ( + productId: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: (payload: any) => + client.products.createVariant(productId, payload), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ queryKey: variantsQueryKeys.lists() }) + queryClient.invalidateQueries({ + queryKey: productsQueryKeys.detail(productId), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + export const useUpdateProductVariant = ( productId: string, variantId: string, @@ -121,6 +148,9 @@ export const useUpdateProductVariant = ( queryClient.invalidateQueries({ queryKey: variantsQueryKeys.detail(variantId), }) + queryClient.invalidateQueries({ + queryKey: productsQueryKeys.detail(productId), + }) options?.onSuccess?.(data, variables, context) }, @@ -140,6 +170,9 @@ export const useDeleteVariant = ( queryClient.invalidateQueries({ queryKey: variantsQueryKeys.detail(variantId), }) + queryClient.invalidateQueries({ + queryKey: productsQueryKeys.detail(productId), + }) options?.onSuccess?.(data, variables, context) }, diff --git a/packages/admin-next/dashboard/src/lib/client/products.ts b/packages/admin-next/dashboard/src/lib/client/products.ts index e29a52b150..80db93875d 100644 --- a/packages/admin-next/dashboard/src/lib/client/products.ts +++ b/packages/admin-next/dashboard/src/lib/client/products.ts @@ -36,6 +36,10 @@ async function listVariants(productId: string, query?: Record) { return getRequest(`/admin/products/${productId}/variants`, query) } +async function createVariant(productId: string, payload: any) { + return postRequest(`/admin/products/${productId}/variants`, payload) +} + async function updateVariant( productId: string, variantId: string, @@ -76,6 +80,7 @@ export const products = { delete: deleteProduct, retrieveVariant, listVariants, + createVariant, updateVariant, deleteVariant, createOption, 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 dc7eb48771..44c873b3e6 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx @@ -104,6 +104,16 @@ export const v2Routes: RouteObject[] = [ lazy: () => import("../../v2-routes/products/product-attributes"), }, + { + path: "organization", + lazy: () => + import("../../v2-routes/products/product-organization"), + }, + { + path: "media", + lazy: () => + import("../../v2-routes/products/product-media"), + }, { path: "options/create", lazy: () => @@ -115,9 +125,9 @@ export const v2Routes: RouteObject[] = [ import("../../v2-routes/products/product-edit-option"), }, { - path: "media", + path: "variants/create", lazy: () => - import("../../v2-routes/products/product-media"), + import("../../v2-routes/products/product-create-variant"), }, { path: "variants/:variant_id/edit", diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-create-option/components/create-product-option-form/create-product-option-form.tsx b/packages/admin-next/dashboard/src/v2-routes/products/product-create-option/components/create-product-option-form/create-product-option-form.tsx index 219256bf35..c2d028da07 100644 --- a/packages/admin-next/dashboard/src/v2-routes/products/product-create-option/components/create-product-option-form/create-product-option-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-create-option/components/create-product-option-form/create-product-option-form.tsx @@ -17,6 +17,7 @@ type EditProductOptionsFormProps = { const CreateProductOptionSchema = z.object({ title: z.string().min(1), + values: z.array(z.string()).optional(), }) export const CreateProductOptionForm = ({ @@ -28,6 +29,7 @@ export const CreateProductOptionForm = ({ const form = useForm>({ defaultValues: { title: "", + values: [], }, resolver: zodResolver(CreateProductOptionSchema), }) @@ -55,7 +57,9 @@ export const CreateProductOptionForm = ({ render={({ field }) => { return ( - {t("fields.title")} + + {t("products.fields.options.optionTitle")} + @@ -64,6 +68,30 @@ export const CreateProductOptionForm = ({ ) }} /> + { + return ( + + + {t("products.fields.options.variations")} + + + { + const val = e.target.value + onChange(val.split(",").map((v) => v.trim())) + }} + /> + + + + ) + }} + />
diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/components/create-product-variant-form/create-product-variant-form.tsx b/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/components/create-product-variant-form/create-product-variant-form.tsx new file mode 100644 index 0000000000..5f7c806356 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/components/create-product-variant-form/create-product-variant-form.tsx @@ -0,0 +1,107 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { Product } from "@medusajs/medusa" +import { Button, Input } from "@medusajs/ui" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { z } from "zod" +import { Form } from "../../../../../components/common/form" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../components/route-modal" +import { useCreateProductVariant } from "../../../../../hooks/api/products" + +type EditProductVariantsFormProps = { + product: Product +} + +const CreateProductVariantSchema = z.object({ + title: z.string().min(1), + values: z.array(z.string()).optional(), +}) + +export const CreateProductVariantForm = ({ + product, +}: EditProductVariantsFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: { + title: "", + values: [], + }, + resolver: zodResolver(CreateProductVariantSchema), + }) + + const { mutateAsync, isLoading } = useCreateProductVariant(product.id) + + const handleSubmit = form.handleSubmit(async (values) => { + mutateAsync(values, { + onSuccess: () => { + handleSuccess() + }, + }) + }) + + return ( + +
+ + { + return ( + + title + + + + + + ) + }} + /> + { + return ( + + value + + { + const val = e.target.value + onChange(val.split(",").map((v) => v.trim())) + }} + /> + + + + ) + }} + /> + + +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/components/create-product-variant-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/components/create-product-variant-form/index.ts new file mode 100644 index 0000000000..968410fc7f --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/components/create-product-variant-form/index.ts @@ -0,0 +1 @@ +export * from "./create-product-variant-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/index.ts b/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/index.ts new file mode 100644 index 0000000000..3d7630cad4 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/index.ts @@ -0,0 +1 @@ +export { ProductCreateVariant as Component } from "./product-create-variant" diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/product-create-variant.tsx b/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/product-create-variant.tsx new file mode 100644 index 0000000000..0ab64c6eaa --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-create-variant/product-create-variant.tsx @@ -0,0 +1,26 @@ +import { Heading } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" +import { RouteDrawer } from "../../../components/route-modal" +import { useProduct } from "../../../hooks/api/products" +import { CreateProductVariantForm } from "./components/create-product-variant-form" + +export const ProductCreateVariant = () => { + const { id } = useParams() + const { t } = useTranslation() + + const { product, isLoading, isError, error } = useProduct(id!) + + if (isError) { + throw error + } + + return ( + + + {t("products.variant.create.header")} + + {!isLoading && product && } + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-edit-option/components/edit-product-option-form/edit-product-option-form.tsx b/packages/admin-next/dashboard/src/v2-routes/products/product-edit-option/components/edit-product-option-form/edit-product-option-form.tsx index 55048807d5..f648ee69b6 100644 --- a/packages/admin-next/dashboard/src/v2-routes/products/product-edit-option/components/edit-product-option-form/edit-product-option-form.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-edit-option/components/edit-product-option-form/edit-product-option-form.tsx @@ -17,6 +17,7 @@ type EditProductOptionFormProps = { const CreateProductOptionSchema = z.object({ title: z.string().min(1), + values: z.array(z.string()).optional(), }) export const CreateProductOptionForm = ({ @@ -28,6 +29,7 @@ export const CreateProductOptionForm = ({ const form = useForm>({ defaultValues: { title: option.title, + values: option.values.map((v: any) => v.value), }, resolver: zodResolver(CreateProductOptionSchema), }) @@ -40,7 +42,7 @@ export const CreateProductOptionForm = ({ const handleSubmit = form.handleSubmit(async (values) => { mutateAsync( { - option_id: option.id, + id: option.id, ...values, }, { @@ -64,7 +66,9 @@ export const CreateProductOptionForm = ({ render={({ field }) => { return ( - {t("fields.title")} + + {t("products.fields.options.optionTitle")} + @@ -73,6 +77,30 @@ export const CreateProductOptionForm = ({ ) }} /> + { + return ( + + + {t("products.fields.options.variations")} + + + { + const val = e.target.value + onChange(val.split(",").map((v) => v.trim())) + }} + /> + + + + ) + }} + />
diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-organization/components/product-organization-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/products/product-organization/components/product-organization-form/index.ts new file mode 100644 index 0000000000..7562195f53 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-organization/components/product-organization-form/index.ts @@ -0,0 +1 @@ +export * from "./product-organization-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-organization/components/product-organization-form/product-organization-form.tsx b/packages/admin-next/dashboard/src/v2-routes/products/product-organization/components/product-organization-form/product-organization-form.tsx new file mode 100644 index 0000000000..dc59129cf7 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-organization/components/product-organization-form/product-organization-form.tsx @@ -0,0 +1,205 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { Product } from "@medusajs/medusa" +import { Button, Input, Select } from "@medusajs/ui" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import * as zod from "zod" +import { CountrySelect } from "../../../../../components/common/country-select" +import { Form } from "../../../../../components/common/form" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../components/route-modal" +import { useUpdateProduct } from "../../../../../hooks/api/products" +import { Combobox } from "../../../../../components/common/combobox" +import { useProductTypes } from "../../../../../hooks/api/product-types" +import { useTags } from "../../../../../hooks/api/tags" +import { useCollections } from "../../../../../hooks/api/collections" +import { useCategories } from "../../../../../hooks/api/categories" + +type ProductOrganizationFormProps = { + product: Product +} + +const ProductOrganizationSchema = zod.object({ + type_id: zod.string().optional(), + collection_id: zod.string().optional(), + category_ids: zod.array(zod.string()).optional(), + tags: zod.array(zod.string()).optional(), +}) + +export const ProductOrganizationForm = ({ + product, +}: ProductOrganizationFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + const { product_types, isLoading: isLoadingTypes } = useProductTypes() + const { product_tags, isLoading: isLoadingTags } = useTags() + const { collections, isLoading: isLoadingCollections } = useCollections() + const { product_categories, isLoading: isLoadingCategories } = useCategories() + + const form = useForm>({ + defaultValues: { + type_id: product.type_id || undefined, + collection_id: product.collection_id || undefined, + category_ids: product.categories?.map((c) => c.id) || undefined, + tags: product.tags?.map((t) => t.id) || undefined, + }, + resolver: zodResolver(ProductOrganizationSchema), + }) + + const { mutateAsync, isLoading } = useUpdateProduct(product.id) + + const handleSubmit = form.handleSubmit(async (data) => { + await mutateAsync( + { + type_id: data.type_id || undefined, + collection_id: data.collection_id || undefined, + category_ids: data.category_ids || undefined, + tags: + data.tags?.map((t) => { + id: t + }) || undefined, + }, + { + onSuccess: () => { + handleSuccess() + }, + } + ) + }) + + return ( + +
+ +
+ { + return ( + + + {t("products.fields.type.label")} + + + + + + ) + }} + /> + { + return ( + + + {t("products.fields.collection.label")} + + + + + + ) + }} + /> + { + return ( + + + {t("products.fields.categories.label")} + + + ({ + label: category.name, + value: category.id, + }))} + {...field} + /> + + + ) + }} + /> + { + return ( + + + {t("products.fields.tags.label")} + + + ({ + label: tag.value, + value: tag.id, + }))} + {...field} + /> + + + ) + }} + /> +
+
+ +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-organization/index.ts b/packages/admin-next/dashboard/src/v2-routes/products/product-organization/index.ts new file mode 100644 index 0000000000..d288c1ce3d --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-organization/index.ts @@ -0,0 +1 @@ +export { ProductOrganization as Component } from "./product-organization" diff --git a/packages/admin-next/dashboard/src/v2-routes/products/product-organization/product-organization.tsx b/packages/admin-next/dashboard/src/v2-routes/products/product-organization/product-organization.tsx new file mode 100644 index 0000000000..91b71679b6 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/products/product-organization/product-organization.tsx @@ -0,0 +1,27 @@ +import { Heading } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" + +import { RouteDrawer } from "../../../components/route-modal" +import { useProduct } from "../../../hooks/api/products" +import { ProductOrganizationForm } from "./components/product-organization-form" + +export const ProductOrganization = () => { + const { id } = useParams() + const { t } = useTranslation() + + const { product, isLoading, isError, error } = useProduct(id!) + + if (isError) { + throw error + } + + return ( + + + {t("products.editOrganization")} + + {!isLoading && product && } + + ) +}