feat(core-flows, dashboard, medusa, types): optional shipping profile (#11434)

* feat: create product flow changes

* feat: allow unsetting SP on product update

* feat: update prepare line item helper

* test: add testcase

* wip: fix tests

* fix: update module tests

* fix: cart module test
This commit is contained in:
Frane Polić authored and GitHub committed 2025-02-17 19:08:59 +01:00
1 parent 3b7856e8f5
commit ee848bf0f4
18 files changed
+185 -106

No files matched your search

@@ -1123,6 +1123,102 @@ medusaIntegrationTestRunner({
) )
}) })
it("should successfully complete cart without shipping for digital products", async () => {
/**
* Product has a shipping profile so cart item should not require shipping
*/
const product = (
await api.post(
`/admin/products`,
{
title: "Product without inventory management",
description: "test",
options: [
{
title: "Size",
values: ["S", "M", "L", "XL"],
},
],
variants: [
{
title: "S / Black",
sku: "special-shirt",
options: {
Size: "S",
},
manage_inventory: false,
prices: [
{
amount: 1500,
currency_code: "usd",
},
],
},
],
},
adminHeaders
)
).data.product
let cart = (
await api.post(
`/store/carts`,
{
currency_code: "usd",
sales_channel_id: salesChannel.id,
region_id: region.id,
shipping_address: shippingAddressData,
},
storeHeadersWithCustomer
)
).data.cart
cart = (
await api.post(
`/store/carts/${cart.id}/line-items`,
{
variant_id: product.variants[0].id,
quantity: 1,
},
storeHeaders
)
).data.cart
const paymentCollection = (
await api.post(
`/store/payment-collections`,
{ cart_id: cart.id },
storeHeaders
)
).data.payment_collection
await api.post(
`/store/payment-collections/${paymentCollection.id}/payment-sessions`,
{ provider_id: "pp_system_default" },
storeHeaders
)
expect(cart.items[0].requires_shipping).toEqual(false)
const response = await api.post(
`/store/carts/${cart.id}/complete`,
{},
storeHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order).toEqual(
expect.objectContaining({
shipping_methods: [],
items: expect.arrayContaining([
expect.objectContaining({
requires_shipping: false,
}),
]),
})
)
})
describe("with sale price lists", () => { describe("with sale price lists", () => {
let priceList let priceList
@@ -114,7 +114,7 @@ export async function createOrderSeeder({
"/admin/products", "/admin/products",
{ {
title: `Test fixture ${shippingProfile.id}`, title: `Test fixture ${shippingProfile.id}`,
shipping_profile_id: shippingProfile.id, shipping_profile_id: withoutShipping ? undefined : shippingProfile.id,
options: [ options: [
{ title: "size", values: ["large", "small"] }, { title: "size", values: ["large", "small"] },
{ title: "color", values: ["green"] }, { title: "color", values: ["green"] },
@@ -668,7 +668,6 @@ medusaIntegrationTestRunner({
"/admin/products", "/admin/products",
{ {
title: `Test fixture 2`, title: `Test fixture 2`,
shipping_profile_id: shippingProfile.id,
options: [ options: [
{ title: "size", values: ["large", "small"] }, { title: "size", values: ["large", "small"] },
{ title: "color", values: ["green"] }, { title: "color", values: ["green"] },
@@ -990,7 +990,7 @@ medusaIntegrationTestRunner({
is_tax_inclusive: true, is_tax_inclusive: true,
is_custom_price: false, is_custom_price: false,
quantity: 1, quantity: 1,
requires_shipping: true, requires_shipping: false, // product doesn't have a shipping profile nor inventory items that require shipping
subtitle: "Test product", subtitle: "Test product",
title: "Test variant", title: "Test variant",
unit_price: 3000, unit_price: 3000,
@@ -1006,7 +1006,7 @@ medusaIntegrationTestRunner({
metadata: { metadata: {
foo: "bar", foo: "bar",
}, },
requires_shipping: true, requires_shipping: true, // overriden when adding to cart
subtitle: "Test subtitle", subtitle: "Test subtitle",
thumbnail: "some-url", thumbnail: "some-url",
title: "Test item", title: "Test item",
@@ -1040,7 +1040,7 @@ medusaIntegrationTestRunner({
is_tax_inclusive: false, is_tax_inclusive: false,
is_custom_price: false, is_custom_price: false,
quantity: 1, quantity: 1,
requires_shipping: true, requires_shipping: false,
subtitle: "Test product", subtitle: "Test product",
title: "Test variant", title: "Test variant",
unit_price: 2000, unit_price: 2000,
@@ -1218,7 +1218,6 @@ medusaIntegrationTestRunner({
"/admin/products", "/admin/products",
{ {
title: "Test fixture", title: "Test fixture",
shipping_profile_id: shippingProfile.id,
options: [ options: [
{ title: "size", values: ["large", "small"] }, { title: "size", values: ["large", "small"] },
{ title: "color", values: ["green"] }, { title: "color", values: ["green"] },
@@ -57,6 +57,7 @@ interface ComboboxProps<T extends Value = Value>
isFetchingNextPage?: boolean isFetchingNextPage?: boolean
onCreateOption?: (value: string) => void onCreateOption?: (value: string) => void
noResultsPlaceholder?: ReactNode noResultsPlaceholder?: ReactNode
allowClear?: boolean
} }
const ComboboxImpl = <T extends Value = string>( const ComboboxImpl = <T extends Value = string>(
@@ -72,6 +73,7 @@ const ComboboxImpl = <T extends Value = string>(
isFetchingNextPage, isFetchingNextPage,
onCreateOption, onCreateOption,
noResultsPlaceholder, noResultsPlaceholder,
allowClear,
...inputProps ...inputProps
}: ComboboxProps<T>, }: ComboboxProps<T>,
ref: ForwardedRef<HTMLInputElement> ref: ForwardedRef<HTMLInputElement>
@@ -303,6 +305,18 @@ const ComboboxImpl = <T extends Value = string>(
{...inputProps} {...inputProps}
/> />
</div> </div>
{allowClear && controlledValue && (
<button
type="button"
onClick={(e) => {
e.preventDefault()
handleValueChange(undefined)
}}
className="bg-ui-bg-base hover:bg-ui-bg-base-hover txt-compact-small-plus text-ui-fg-subtle focus-within:border-ui-fg-interactive transition-fg absolute right-[28px] top-0.5 z-[1] flex h-[28px] items-center rounded-[4px] border px-1.5 py-[2px] outline-none"
>
<XMarkMini className="text-ui-fg-muted" />
</button>
)}
<PrimitiveComboboxDisclosure <PrimitiveComboboxDisclosure
render={(props) => { render={(props) => {
return ( return (
@@ -181,15 +181,6 @@ export const ProductCreateForm = ({
} }
if (currentTab === Tab.ORGANIZE) { if (currentTab === Tab.ORGANIZE) {
// TODO: this is temp until we add partial validation per tab
if (!form.getValues("shipping_profile_id")) {
form.setError("shipping_profile_id", {
type: "required",
message: t("products.shippingProfile.create.errors.required"),
})
return
}
setTab(Tab.VARIANTS) setTab(Tab.VARIANTS)
} }
@@ -173,7 +173,9 @@ export const ProductCreateOrganizationSection = ({
</div> </div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div> <div>
<Form.Label>{t("products.fields.shipping_profile.label")}</Form.Label> <Form.Label optional>
{t("products.fields.shipping_profile.label")}
</Form.Label>
<Form.Hint> <Form.Hint>
<Trans i18nKey={"products.fields.shipping_profile.hint"} /> <Trans i18nKey={"products.fields.shipping_profile.hint"} />
</Form.Hint> </Form.Hint>
@@ -1,7 +1,7 @@
import { z } from "zod" import { z } from "zod"
import { i18n } from "../../../components/utilities/i18n/i18n.tsx" import { i18n } from "../../../components/utilities/i18n/i18n"
import { optionalFloat, optionalInt } from "../../../lib/validation.ts" import { optionalFloat, optionalInt } from "../../../lib/validation"
import { decorateVariantsWithDefaultValues } from "./utils.ts" import { decorateVariantsWithDefaultValues } from "./utils"
export const MediaSchema = z.object({ export const MediaSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
@@ -64,7 +64,7 @@ export const ProductCreateSchema = z
discountable: z.boolean(), discountable: z.boolean(),
type_id: z.string().optional(), type_id: z.string().optional(),
collection_id: z.string().optional(), collection_id: z.string().optional(),
shipping_profile_id: z.string(), // TODO: require min(1) when partial validation per tab is added shipping_profile_id: z.string().optional(),
categories: z.array(z.string()), categories: z.array(z.string()),
tags: z.array(z.string()).optional(), tags: z.array(z.string()).optional(),
sales_channels: z sales_channels: z
@@ -24,7 +24,7 @@ export const normalizeProductFormValues = (
: undefined, : undefined,
images, images,
collection_id: values.collection_id || undefined, collection_id: values.collection_id || undefined,
shipping_profile_id: values.shipping_profile_id, shipping_profile_id: values.shipping_profile_id || undefined,
categories: values.categories.map((id) => ({ id })), categories: values.categories.map((id) => ({ id })),
type_id: values.type_id || undefined, type_id: values.type_id || undefined,
handle: values.handle || undefined, handle: values.handle || undefined,
@@ -7,12 +7,13 @@ import { Form } from "../../../../../components/common/form"
import { Combobox } from "../../../../../components/inputs/combobox" import { Combobox } from "../../../../../components/inputs/combobox"
import { RouteDrawer, useRouteModal } from "../../../../../components/modals" import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
import { KeyboundForm } from "../../../../../components/utilities/keybound-form" import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useExtendableForm } from "../../../../../extensions"
import { useUpdateProduct } from "../../../../../hooks/api/products" import { useUpdateProduct } from "../../../../../hooks/api/products"
import { useComboboxData } from "../../../../../hooks/use-combobox-data" import { useComboboxData } from "../../../../../hooks/use-combobox-data"
import { sdk } from "../../../../../lib/client" import { sdk } from "../../../../../lib/client"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { se } from "date-fns/locale"
import { useEffect } from "react"
type ProductShippingProfileFormProps = { type ProductShippingProfileFormProps = {
product: HttpTypes.AdminProduct & { product: HttpTypes.AdminProduct & {
@@ -47,12 +48,15 @@ export const ProductShippingProfileForm = ({
resolver: zodResolver(ProductShippingProfileSchema), resolver: zodResolver(ProductShippingProfileSchema),
}) })
const selectedShippingProfile = form.watch("shipping_profile_id")
const { mutateAsync, isPending } = useUpdateProduct(product.id) const { mutateAsync, isPending } = useUpdateProduct(product.id)
const handleSubmit = form.handleSubmit(async (data) => { const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync( await mutateAsync(
{ {
shipping_profile_id: data.shipping_profile_id, shipping_profile_id:
data.shipping_profile_id === "" ? null : data.shipping_profile_id,
}, },
{ {
onSuccess: ({ product }) => { onSuccess: ({ product }) => {
@@ -70,6 +74,12 @@ export const ProductShippingProfileForm = ({
) )
}) })
useEffect(() => {
if (typeof selectedShippingProfile === "undefined") {
form.setValue("shipping_profile_id", "")
}
}, [selectedShippingProfile])
return ( return (
<RouteDrawer.Form form={form}> <RouteDrawer.Form form={form}>
<KeyboundForm onSubmit={handleSubmit} className="flex h-full flex-col"> <KeyboundForm onSubmit={handleSubmit} className="flex h-full flex-col">
@@ -87,6 +97,7 @@ export const ProductShippingProfileForm = ({
<Form.Control> <Form.Control>
<Combobox <Combobox
{...field} {...field}
allowClear
options={shippingProfiles.options} options={shippingProfiles.options}
searchValue={shippingProfiles.searchValue} searchValue={shippingProfiles.searchValue}
onSearchValueChange={ onSearchValueChange={
@@ -147,6 +147,7 @@ export const productVariantsFields = [
"product.collection.title", "product.collection.title",
"product.handle", "product.handle",
"product.discountable", "product.discountable",
"product.shipping_profile.id",
"calculated_price.*", "calculated_price.*",
"inventory_items.inventory_item_id", "inventory_items.inventory_item_id",
"inventory_items.required_quantity", "inventory_items.required_quantity",
@@ -5,6 +5,7 @@ import {
InventoryItemDTO, InventoryItemDTO,
LineItemAdjustmentDTO, LineItemAdjustmentDTO,
LineItemTaxLineDTO, LineItemTaxLineDTO,
ProductDTO,
ProductVariantDTO, ProductVariantDTO,
} from "@medusajs/framework/types" } from "@medusajs/framework/types"
import { import {
@@ -50,6 +51,10 @@ interface PrepareItemLineItemInput {
metadata?: Record<string, unknown> | null metadata?: Record<string, unknown> | null
} }
type AddItemProductDTO = ProductDTO & {
shipping_profile: { id: string }
}
export interface PrepareVariantLineItemInput extends ProductVariantDTO { export interface PrepareVariantLineItemInput extends ProductVariantDTO {
inventory_items: { inventory: InventoryItemDTO }[] inventory_items: { inventory: InventoryItemDTO }[]
calculated_price: { calculated_price: {
@@ -106,17 +111,19 @@ export function prepareLineItemData(data: PrepareLineItemDataInput) {
compareAtUnitPrice = variant.calculated_price.original_amount compareAtUnitPrice = variant.calculated_price.original_amount
} }
// Note: If any of the items require shipping, we enable fulfillment const hasShippingProfile = isDefined(
// unless explicitly set to not require shipping by the item in the request (variant?.product as AddItemProductDTO)?.shipping_profile?.id
const someInventoryRequiresShipping = variant?.inventory_items?.length )
? variant.inventory_items.some(
(inventoryItem) => !!inventoryItem.inventory.requires_shipping
)
: true
const someInventoryRequiresShipping = !!variant?.inventory_items?.some(
(inventoryItem) => !!inventoryItem.inventory.requires_shipping
)
// Note: If any of the items require shipping or product has a shipping profile set,
// we enable fulfillment unless explicitly set to not require shipping by the item in the request
const requiresShipping = isDefined(item?.requires_shipping) const requiresShipping = isDefined(item?.requires_shipping)
? item.requires_shipping ? item.requires_shipping
: someInventoryRequiresShipping : hasShippingProfile || someInventoryRequiresShipping
let lineItem: any = { let lineItem: any = {
quantity: item?.quantity, quantity: item?.quantity,
@@ -19,11 +19,7 @@ import {
transform, transform,
createStep, createStep,
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
import { import { createRemoteLinkStep, emitEventStep } from "../../common"
createRemoteLinkStep,
emitEventStep,
useQueryGraphStep,
} from "../../common"
import { associateProductsWithSalesChannelsStep } from "../../sales-channel" import { associateProductsWithSalesChannelsStep } from "../../sales-channel"
import { createProductsStep } from "../steps/create-products" import { createProductsStep } from "../steps/create-products"
import { createProductVariantsWorkflow } from "./create-product-variants" import { createProductVariantsWorkflow } from "./create-product-variants"
@@ -36,16 +32,6 @@ export interface ValidateProductInputStepInput {
* The products to validate. * The products to validate.
*/ */
products: Omit<CreateProductWorkflowInputDTO, "sales_channels">[] products: Omit<CreateProductWorkflowInputDTO, "sales_channels">[]
/**
* The shipping profiles to validate.
*/
shippingProfiles: {
/**
* The shipping profile's ID.
*/
id: string
}[]
} }
const validateProductInputStepId = "validate-product-input" const validateProductInputStepId = "validate-product-input"
@@ -87,7 +73,7 @@ const validateProductInputStepId = "validate-product-input"
export const validateProductInputStep = createStep( export const validateProductInputStep = createStep(
validateProductInputStepId, validateProductInputStepId,
async (data: ValidateProductInputStepInput) => { async (data: ValidateProductInputStepInput) => {
const { products, shippingProfiles } = data const { products } = data
const missingOptionsProductTitles = products const missingOptionsProductTitles = products
.filter((product) => !product.options?.length) .filter((product) => !product.options?.length)
@@ -101,25 +87,6 @@ export const validateProductInputStep = createStep(
)}].` )}].`
) )
} }
const existingProfileIds = new Set(shippingProfiles.map((p) => p.id))
const missingShippingProfileProductTitles = products
.filter(
(product) =>
!product.shipping_profile_id ||
!existingProfileIds.has(product.shipping_profile_id)
)
.map((product) => product.title)
if (missingShippingProfileProductTitles.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Shipping profile is not provided for: [${missingShippingProfileProductTitles.join(
", "
)}].`
)
}
} }
) )
@@ -190,14 +157,10 @@ export const createProductsWorkflow = createWorkflow(
createProductsWorkflowId, createProductsWorkflowId,
(input: WorkflowData<CreateProductsWorkflowInput>) => { (input: WorkflowData<CreateProductsWorkflowInput>) => {
// Passing prices to the product module will fail, we want to keep them for after the product is created. // Passing prices to the product module will fail, we want to keep them for after the product is created.
const { products: productWithoutExternalRelations, shippingPorfileIds } = const { products: productWithoutExternalRelations } = transform(
transform({ input }, (data) => { { input },
const shippingPorfileIds: string[] = [] (data) => {
const productsData = data.input.products.map((p) => { const productsData = data.input.products.map((p) => {
if (p.shipping_profile_id) {
shippingPorfileIds.push(p.shipping_profile_id)
}
return { return {
...p, ...p,
sales_channels: undefined, sales_channels: undefined,
@@ -206,18 +169,11 @@ export const createProductsWorkflow = createWorkflow(
} }
}) })
return { products: productsData, shippingPorfileIds } return { products: productsData }
}) }
)
const { data: shippingProfiles } = useQueryGraphStep({ validateProductInputStep({ products: input.products })
entity: "shipping_profile",
fields: ["id"],
filters: {
id: shippingPorfileIds,
},
})
validateProductInputStep({ products: input.products, shippingProfiles })
const createdProducts = createProductsStep(productWithoutExternalRelations) const createdProducts = createProductsStep(productWithoutExternalRelations)
@@ -240,16 +196,18 @@ export const createProductsWorkflow = createWorkflow(
const shippingProfileLinks = transform( const shippingProfileLinks = transform(
{ input, createdProducts }, { input, createdProducts },
(data) => { (data) => {
return data.createdProducts.map((createdProduct, i) => { return data.createdProducts
return { .map((createdProduct, i) => {
[Modules.PRODUCT]: { return {
product_id: createdProduct.id, [Modules.PRODUCT]: {
}, product_id: createdProduct.id,
[Modules.FULFILLMENT]: { },
shipping_profile_id: data.input.products[i].shipping_profile_id, [Modules.FULFILLMENT]: {
}, shipping_profile_id: data.input.products[i].shipping_profile_id,
} },
}) }
})
.filter((link) => !!link[Modules.FULFILLMENT].shipping_profile_id)
} }
) )
@@ -10,6 +10,7 @@ import {
Modules, Modules,
ProductWorkflowEvents, ProductWorkflowEvents,
arrayDifference, arrayDifference,
isDefined,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
import { import {
WorkflowData, WorkflowData,
@@ -50,7 +51,7 @@ export type UpdateProductsWorkflowInputSelector = {
/** /**
* The shipping profile to set. * The shipping profile to set.
*/ */
shipping_profile_id?: string shipping_profile_id?: string | null
} }
} & AdditionalData } & AdditionalData
@@ -73,7 +74,7 @@ export type UpdateProductsWorkflowInputProducts = {
/** /**
* The shipping profile to set. * The shipping profile to set.
*/ */
shipping_profile_id?: string shipping_profile_id?: string | null
})[] })[]
} & AdditionalData } & AdditionalData
@@ -152,12 +153,12 @@ function findProductsWithShippingProfiles({
if ("products" in input) { if ("products" in input) {
const discardedProductIds: string[] = input.products const discardedProductIds: string[] = input.products
.filter((p) => !p.shipping_profile_id) .filter((p) => !isDefined(p.shipping_profile_id))
.map((p) => p.id as string) .map((p) => p.id as string)
return arrayDifference(productIds, discardedProductIds) return arrayDifference(productIds, discardedProductIds)
} }
return !input.update?.shipping_profile_id ? [] : productIds return !isDefined(input.update?.shipping_profile_id) ? [] : productIds
} }
function prepareSalesChannelLinks({ function prepareSalesChannelLinks({
@@ -215,7 +216,7 @@ function prepareShippingProfileLinks({
} }
return input.products return input.products
.filter((p) => p.shipping_profile_id) .filter((p) => typeof p.shipping_profile_id === "string")
.map((p) => ({ .map((p) => ({
[Modules.PRODUCT]: { [Modules.PRODUCT]: {
product_id: p.id, product_id: p.id,
@@ -226,7 +227,7 @@ function prepareShippingProfileLinks({
})) }))
} }
if (input.selector && input.update?.shipping_profile_id) { if (input.selector && typeof input.update?.shipping_profile_id === "string") {
return updatedProducts.map((p) => ({ return updatedProducts.map((p) => ({
[Modules.PRODUCT]: { [Modules.PRODUCT]: {
product_id: p.id, product_id: p.id,
@@ -213,7 +213,7 @@ export interface AdminCreateProduct {
/** /**
* The ID of the product's shipping profile. * The ID of the product's shipping profile.
*/ */
shipping_profile_id: string shipping_profile_id?: string
/** /**
* The product's categories. * The product's categories.
*/ */
@@ -467,7 +467,7 @@ export interface AdminUpdateProduct {
/** /**
* The ID of the product's shipping profile. * The ID of the product's shipping profile.
*/ */
shipping_profile_id?: string shipping_profile_id?: string | null
/** /**
* The product's weight. * The product's weight.
*/ */
@@ -36,7 +36,7 @@ export type CreateProductWorkflowInputDTO = Omit<
/** /**
* The product's shipping profile. * The product's shipping profile.
*/ */
shipping_profile_id: string shipping_profile_id?: string
/** /**
* The product's variants. * The product's variants.
*/ */
@@ -45,5 +45,5 @@ export type CreateProductWorkflowInputDTO = Omit<
export type UpdateProductWorkflowInputDTO = ProductTypes.UpsertProductDTO & { export type UpdateProductWorkflowInputDTO = ProductTypes.UpsertProductDTO & {
sales_channels?: { id: string }[] sales_channels?: { id: string }[]
shipping_profile_id?: string shipping_profile_id?: string | null
} }
@@ -232,7 +232,7 @@ export const CreateProduct = z
options: z.array(CreateProductOption).optional(), options: z.array(CreateProductOption).optional(),
variants: z.array(CreateProductVariant).optional(), variants: z.array(CreateProductVariant).optional(),
sales_channels: z.array(z.object({ id: z.string() })).optional(), sales_channels: z.array(z.object({ id: z.string() })).optional(),
shipping_profile_id: z.string(), shipping_profile_id: z.string().optional(),
weight: z.number().nullish(), weight: z.number().nullish(),
length: z.number().nullish(), length: z.number().nullish(),
height: z.number().nullish(), height: z.number().nullish(),
@@ -267,7 +267,7 @@ export const UpdateProduct = z
categories: z.array(IdAssociation).optional(), categories: z.array(IdAssociation).optional(),
tags: z.array(IdAssociation).optional(), tags: z.array(IdAssociation).optional(),
sales_channels: z.array(z.object({ id: z.string() })).optional(), sales_channels: z.array(z.object({ id: z.string() })).optional(),
shipping_profile_id: z.string().optional(), shipping_profile_id: z.string().nullish(),
weight: z.number().nullish(), weight: z.number().nullish(),
length: z.number().nullish(), length: z.number().nullish(),
height: z.number().nullish(), height: z.number().nullish(),