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 e1c332957a..b5961098ae 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -43,6 +43,10 @@ "includesTaxTooltip": "Enter the total amount including tax. The net amount excluding tax will be automatically calculated and saved.", "timeline": "Timeline" }, + "validation": { + "mustBeInt": "The value must be a whole number.", + "mustBePositive": "The value must be a positive number." + }, "nav": { "general": "General", "developer": "Developer", @@ -181,6 +185,18 @@ "published": "Published", "proposed": "Proposed", "rejected": "Rejected" + }, + "variant": { + "edit": { + "header": "Edit Variant" + }, + "inventory": { + "header": "Stock & Inventory", + "manageInventoryLabel": "Manage inventory", + "manageInventoryHint": "When enabled the inventory level will be regulated when orders and returns are created.", + "allowBackordersLabel": "Allow backorders", + "allowBackordersHint": "When enabled the variant can be sold even if the inventory level is below zero." + } } }, "collections": { @@ -940,9 +956,13 @@ "width": "Width", "length": "Length", "weight": "Weight", - "midCode": "MID Code", - "hsCode": "HS Code", - "countryOfOrigin": "Country of Origin", + "midCode": "MID code", + "hsCode": "HS code", + "ean": "EAN", + "upc": "UPC", + "inventoryQuantity": "Inventory quantity", + "barcode": "Barcode", + "countryOfOrigin": "Country of origin", "material": "Material", "thumbnail": "Thumbnail", "sku": "SKU", diff --git a/packages/admin-next/dashboard/src/lib/validation.ts b/packages/admin-next/dashboard/src/lib/validation.ts new file mode 100644 index 0000000000..f6ef30d191 --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/validation.ts @@ -0,0 +1,34 @@ +import i18next from "i18next" +import { z } from "zod" +import { castNumber } from "./cast-number" + +/** + * Validates that an optional value is an integer. + */ +export const optionalInt = z + .union([z.string(), z.number()]) + .optional() + .refine( + (value) => { + if (value === "" || value === undefined) { + return true + } + + return Number.isInteger(castNumber(value)) + }, + { + message: i18next.t("validation.mustBeInt"), + } + ) + .refine( + (value) => { + if (value === "" || value === undefined) { + return true + } + + return castNumber(value) >= 0 + }, + { + message: i18next.t("validation.mustBePositive"), + } + ) diff --git a/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx b/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx index 749b6d720d..c261f11a85 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx @@ -239,6 +239,11 @@ export const v1Routes: RouteObject[] = [ path: "media", lazy: () => import("../../routes/products/product-media"), }, + { + path: "variants/:variant_id/edit", + lazy: () => + import("../../routes/products/product-edit-variant"), + }, ], }, ], diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/product-variant-section.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/product-variant-section.tsx index d5e0d3f670..f2e11ee1c2 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/product-variant-section.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/product-variant-section.tsx @@ -1,4 +1,4 @@ -import { PencilSquare } from "@medusajs/icons" +import { Plus } from "@medusajs/icons" import { Product } from "@medusajs/medusa" import { Container, Heading } from "@medusajs/ui" import { useAdminProductVariants } from "medusa-react" @@ -64,9 +64,9 @@ export const ProductVariantSection = ({ { actions: [ { - label: t("actions.edit"), - to: "variants", - icon: , + label: t("actions.create"), + to: `variants/create`, + icon: , }, ], }, diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-columns.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-columns.tsx index 74e07b81c1..836c87fe2d 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-columns.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-columns.tsx @@ -44,7 +44,7 @@ const VariantActions = ({ actions: [ { label: t("actions.edit"), - to: `variants/${variant.id}`, + to: `variants/${variant.id}/edit`, icon: , }, ], diff --git a/packages/admin-next/dashboard/src/routes/products/product-edit-variant/components/product-edit-variant-form/index.ts b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/components/product-edit-variant-form/index.ts new file mode 100644 index 0000000000..3612fd815d --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/components/product-edit-variant-form/index.ts @@ -0,0 +1 @@ +export * from "./product-edit-variant-form" diff --git a/packages/admin-next/dashboard/src/routes/products/product-edit-variant/components/product-edit-variant-form/product-edit-variant-form.tsx b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/components/product-edit-variant-form/product-edit-variant-form.tsx new file mode 100644 index 0000000000..08bea58e37 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/components/product-edit-variant-form/product-edit-variant-form.tsx @@ -0,0 +1,465 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { Product, ProductVariant } from "@medusajs/medusa" +import { Button, Heading, Input, Switch } from "@medusajs/ui" +import { useAdminUpdateVariant } from "medusa-react" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { z } from "zod" + +import { Fragment } from "react" +import { CountrySelect } from "../../../../../components/common/country-select" +import { Divider } from "../../../../../components/common/divider" +import { Form } from "../../../../../components/common/form" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../components/route-modal" +import { castNumber } from "../../../../../lib/cast-number" +import { optionalInt } from "../../../../../lib/validation" + +type ProductEditVariantFormProps = { + product: Product + variant: ProductVariant + isStockAndInventoryEnabled?: boolean +} + +const ProductEditVariantSchema = z.object({ + title: z.string().min(1), + material: z.string().optional(), + sku: z.string().optional(), + ean: z.string().optional(), + upc: z.string().optional(), + barcode: z.string().optional(), + inventory_quantity: optionalInt, + manage_inventory: z.boolean(), + allow_backorder: z.boolean(), + weight: optionalInt, + height: optionalInt, + width: optionalInt, + length: optionalInt, + mid_code: z.string().optional(), + hs_code: z.string().optional(), + origin_country: z.string().optional(), +}) + +export const ProductEditVariantForm = ({ + product, + variant, + isStockAndInventoryEnabled = false, +}: ProductEditVariantFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: { + title: variant.title, + material: variant.material || "", + sku: variant.sku || "", + ean: variant.ean || "", + upc: variant.upc || "", + barcode: variant.barcode || "", + inventory_quantity: variant.inventory_quantity || "", + manage_inventory: variant.manage_inventory, + allow_backorder: variant.allow_backorder, + weight: variant.weight || "", + height: variant.height || "", + width: variant.width || "", + length: variant.length || "", + mid_code: variant.mid_code || "", + hs_code: variant.hs_code || "", + origin_country: variant.origin_country || "", + }, + resolver: zodResolver(ProductEditVariantSchema), + }) + + const { mutateAsync, isLoading } = useAdminUpdateVariant(product.id) + + const handleSubmit = form.handleSubmit(async (data) => { + const parseNumber = (value?: string | number) => { + if (typeof value === "undefined" || value === "") { + return undefined + } + + if (typeof value === "string") { + return castNumber(value) + } + + return value + } + + const { + weight, + height, + width, + length, + inventory_quantity, + allow_backorder, + manage_inventory, + sku, + ean, + upc, + barcode, + ...rest + } = data + + /** + * If stock and inventory is not enabled, we need to send the inventory and + * stock related fields to the API. If it is enabled, it should be handled + * in the separate stock and inventory form. + */ + const conditionalPayload = !isStockAndInventoryEnabled + ? { + sku, + ean, + upc, + barcode, + inventory_quantity: parseNumber(inventory_quantity), + allow_backorder, + manage_inventory, + } + : {} + + await mutateAsync( + { + variant_id: variant.id, + weight: parseNumber(weight), + height: parseNumber(height), + width: parseNumber(width), + length: parseNumber(length), + ...conditionalPayload, + ...rest, + }, + { + onSuccess: () => { + handleSuccess() + }, + } + ) + }) + + return ( + +
+ +
+ { + return ( + + {t("fields.title")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.material")} + + + + + + ) + }} + /> +
+ + {!isStockAndInventoryEnabled && ( + +
+
+ + {t("products.variant.inventory.header")} + + { + return ( + + {t("fields.sku")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.ean")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.upc")} + + + + + + ) + }} + /> + { + return ( + + + {t("fields.barcode")} + + + + + + + ) + }} + /> + { + return ( + + + {t("fields.inventoryQuantity")} + + + + + + + ) + }} + /> +
+ { + return ( + +
+
+ + {t( + "products.variant.inventory.manageInventoryLabel" + )} + + + + onChange(!!checked) + } + {...field} + /> + +
+ + {t( + "products.variant.inventory.manageInventoryHint" + )} + +
+ +
+ ) + }} + /> + { + return ( + +
+
+ + {t( + "products.variant.inventory.allowBackordersLabel" + )} + + + + onChange(!!checked) + } + {...field} + /> + +
+ + {t( + "products.variant.inventory.allowBackordersHint" + )} + +
+ +
+ ) + }} + /> +
+ +
+ )} +
+ {t("products.attributes")} + { + return ( + + {t("fields.weight")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.width")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.length")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.height")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.midCode")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.hsCode")} + + + + + + ) + }} + /> + { + return ( + + + {t("fields.countryOfOrigin")} + + + + + + + ) + }} + /> +
+
+ +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/products/product-edit-variant/index.ts b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/index.ts new file mode 100644 index 0000000000..d40dd05384 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/index.ts @@ -0,0 +1,2 @@ +export { editProductVariantLoader as loader } from "./loader" +export { ProductEditVariant as Component } from "./product-edit-variant" diff --git a/packages/admin-next/dashboard/src/routes/products/product-edit-variant/loader.ts b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/loader.ts new file mode 100644 index 0000000000..321ace2e9b --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/loader.ts @@ -0,0 +1,40 @@ +import { adminProductKeys, adminStoreKeys } from "medusa-react" +import { LoaderFunctionArgs } from "react-router-dom" + +import { medusa, queryClient } from "../../../lib/medusa" + +const queryKey = (id: string) => { + return [adminProductKeys.detail(id), adminStoreKeys.details()] +} + +const queryFn = async (id: string) => { + const productRes = await medusa.admin.products.retrieve(id) + + const storeRes = await medusa.admin.store.retrieve() + + const isStockAndInventoryEnabled = storeRes.store.modules.some( + (m) => m.module === "inventoryService" || "stockLocationService" + ) + + return { + initialData: productRes, + isStockAndInventoryEnabled, + } +} + +const editProductVariantQuery = (id: string) => ({ + queryKey: queryKey(id), + queryFn: async () => queryFn(id), +}) + +export const editProductVariantLoader = async ({ + params, +}: LoaderFunctionArgs) => { + const id = params.id + const query = editProductVariantQuery(id!) + + return ( + queryClient.getQueryData>(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/routes/products/product-edit-variant/product-edit-variant.tsx b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/product-edit-variant.tsx new file mode 100644 index 0000000000..f6e28b05bd --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/products/product-edit-variant/product-edit-variant.tsx @@ -0,0 +1,57 @@ +import { ProductVariant } from "@medusajs/medusa" +import { Heading } from "@medusajs/ui" +import { useAdminProduct } from "medusa-react" +import { useTranslation } from "react-i18next" +import { json, useLoaderData, useParams } from "react-router-dom" +import { RouteDrawer } from "../../../components/route-modal" +import { ProductEditVariantForm } from "./components/product-edit-variant-form" +import { editProductVariantLoader } from "./loader" + +export const ProductEditVariant = () => { + const loaderData = useLoaderData() as Awaited< + ReturnType + > + + const { t } = useTranslation() + const { id, variant_id } = useParams() + + const { product, isLoading, isError, error } = useAdminProduct( + id!, + undefined, + { + initialData: loaderData?.initialData, + } + ) + + const variant = product?.variants.find( + (v: ProductVariant) => v.id === variant_id + ) + + if (!isLoading && !variant) { + throw json({ + status: 404, + message: `Variant with ID ${variant_id} was not found.`, + }) + } + + const ready = !isLoading && !!product && !!variant + + if (isError) { + throw error + } + + return ( + + + {t("products.variant.edit.header")} + + {ready && ( + + )} + + ) +}