Skip to content

Commit

Permalink
feat(core-flows, dashboard, medusa, types): optional shipping profile (
Browse files Browse the repository at this point in the history
…#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
  • Loading branch information
fPolic authored Feb 17, 2025
1 parent 3b7856e commit ee848bf
Show file tree
Hide file tree
Showing 19 changed files with 191 additions and 112 deletions.
96 changes: 96 additions & 0 deletions integration-tests/http/__tests__/cart/store/cart.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
let priceList

Expand Down
2 changes: 1 addition & 1 deletion integration-tests/http/__tests__/fixtures/order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export async function createOrderSeeder({
"/admin/products",
{
title: `Test fixture ${shippingProfile.id}`,
shipping_profile_id: shippingProfile.id,
shipping_profile_id: withoutShipping ? undefined : shippingProfile.id,
options: [
{ title: "size", values: ["large", "small"] },
{ title: "color", values: ["green"] },
Expand Down
1 change: 0 additions & 1 deletion integration-tests/http/__tests__/order/admin/order.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,7 +668,6 @@ medusaIntegrationTestRunner({
"/admin/products",
{
title: `Test fixture 2`,
shipping_profile_id: shippingProfile.id,
options: [
{ title: "size", values: ["large", "small"] },
{ title: "color", values: ["green"] },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -990,7 +990,7 @@ medusaIntegrationTestRunner({
is_tax_inclusive: true,
is_custom_price: false,
quantity: 1,
requires_shipping: true,
requires_shipping: false, // product doesn't have a shipping profile nor inventory items that require shipping
subtitle: "Test product",
title: "Test variant",
unit_price: 3000,
Expand All @@ -1006,7 +1006,7 @@ medusaIntegrationTestRunner({
metadata: {
foo: "bar",
},
requires_shipping: true,
requires_shipping: true, // overriden when adding to cart
subtitle: "Test subtitle",
thumbnail: "some-url",
title: "Test item",
Expand Down Expand Up @@ -1040,7 +1040,7 @@ medusaIntegrationTestRunner({
is_tax_inclusive: false,
is_custom_price: false,
quantity: 1,
requires_shipping: true,
requires_shipping: false,
subtitle: "Test product",
title: "Test variant",
unit_price: 2000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1218,7 +1218,6 @@ medusaIntegrationTestRunner({
"/admin/products",
{
title: "Test fixture",
shipping_profile_id: shippingProfile.id,
options: [
{ title: "size", values: ["large", "small"] },
{ title: "color", values: ["green"] },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ interface ComboboxProps<T extends Value = Value>
isFetchingNextPage?: boolean
onCreateOption?: (value: string) => void
noResultsPlaceholder?: ReactNode
allowClear?: boolean
}

const ComboboxImpl = <T extends Value = string>(
Expand All @@ -72,6 +73,7 @@ const ComboboxImpl = <T extends Value = string>(
isFetchingNextPage,
onCreateOption,
noResultsPlaceholder,
allowClear,
...inputProps
}: ComboboxProps<T>,
ref: ForwardedRef<HTMLInputElement>
Expand Down Expand Up @@ -303,6 +305,18 @@ const ComboboxImpl = <T extends Value = string>(
{...inputProps}
/>
</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
render={(props) => {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,6 @@ export const ProductCreateForm = ({
}

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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@ export const ProductCreateOrganizationSection = ({
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Form.Label>{t("products.fields.shipping_profile.label")}</Form.Label>
<Form.Label optional>
{t("products.fields.shipping_profile.label")}
</Form.Label>
<Form.Hint>
<Trans i18nKey={"products.fields.shipping_profile.hint"} />
</Form.Hint>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from "zod"
import { i18n } from "../../../components/utilities/i18n/i18n.tsx"
import { optionalFloat, optionalInt } from "../../../lib/validation.ts"
import { decorateVariantsWithDefaultValues } from "./utils.ts"
import { i18n } from "../../../components/utilities/i18n/i18n"
import { optionalFloat, optionalInt } from "../../../lib/validation"
import { decorateVariantsWithDefaultValues } from "./utils"

export const MediaSchema = z.object({
id: z.string().optional(),
Expand Down Expand Up @@ -64,7 +64,7 @@ export const ProductCreateSchema = z
discountable: z.boolean(),
type_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()),
tags: z.array(z.string()).optional(),
sales_channels: z
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const normalizeProductFormValues = (
: undefined,
images,
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 })),
type_id: values.type_id || undefined,
handle: values.handle || undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ import { Form } from "../../../../../components/common/form"
import { Combobox } from "../../../../../components/inputs/combobox"
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useExtendableForm } from "../../../../../extensions"
import { useUpdateProduct } from "../../../../../hooks/api/products"
import { useComboboxData } from "../../../../../hooks/use-combobox-data"
import { sdk } from "../../../../../lib/client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { se } from "date-fns/locale"
import { useEffect } from "react"

type ProductShippingProfileFormProps = {
product: HttpTypes.AdminProduct & {
Expand Down Expand Up @@ -47,12 +48,15 @@ export const ProductShippingProfileForm = ({
resolver: zodResolver(ProductShippingProfileSchema),
})

const selectedShippingProfile = form.watch("shipping_profile_id")

const { mutateAsync, isPending } = useUpdateProduct(product.id)

const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
{
shipping_profile_id: data.shipping_profile_id,
shipping_profile_id:
data.shipping_profile_id === "" ? null : data.shipping_profile_id,
},
{
onSuccess: ({ product }) => {
Expand All @@ -70,6 +74,12 @@ export const ProductShippingProfileForm = ({
)
})

useEffect(() => {
if (typeof selectedShippingProfile === "undefined") {
form.setValue("shipping_profile_id", "")
}
}, [selectedShippingProfile])

return (
<RouteDrawer.Form form={form}>
<KeyboundForm onSubmit={handleSubmit} className="flex h-full flex-col">
Expand All @@ -87,6 +97,7 @@ export const ProductShippingProfileForm = ({
<Form.Control>
<Combobox
{...field}
allowClear
options={shippingProfiles.options}
searchValue={shippingProfiles.searchValue}
onSearchValueChange={
Expand Down
1 change: 1 addition & 0 deletions packages/core/core-flows/src/cart/utils/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export const productVariantsFields = [
"product.collection.title",
"product.handle",
"product.discountable",
"product.shipping_profile.id",
"calculated_price.*",
"inventory_items.inventory_item_id",
"inventory_items.required_quantity",
Expand Down
23 changes: 15 additions & 8 deletions packages/core/core-flows/src/cart/utils/prepare-line-item-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
InventoryItemDTO,
LineItemAdjustmentDTO,
LineItemTaxLineDTO,
ProductDTO,
ProductVariantDTO,
} from "@medusajs/framework/types"
import {
Expand Down Expand Up @@ -50,6 +51,10 @@ interface PrepareItemLineItemInput {
metadata?: Record<string, unknown> | null
}

type AddItemProductDTO = ProductDTO & {
shipping_profile: { id: string }
}

export interface PrepareVariantLineItemInput extends ProductVariantDTO {
inventory_items: { inventory: InventoryItemDTO }[]
calculated_price: {
Expand Down Expand Up @@ -106,17 +111,19 @@ export function prepareLineItemData(data: PrepareLineItemDataInput) {
compareAtUnitPrice = variant.calculated_price.original_amount
}

// Note: If any of the items require shipping, we enable fulfillment
// unless explicitly set to not require shipping by the item in the request
const someInventoryRequiresShipping = variant?.inventory_items?.length
? variant.inventory_items.some(
(inventoryItem) => !!inventoryItem.inventory.requires_shipping
)
: true
const hasShippingProfile = isDefined(
(variant?.product as AddItemProductDTO)?.shipping_profile?.id
)

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)
? item.requires_shipping
: someInventoryRequiresShipping
: hasShippingProfile || someInventoryRequiresShipping

let lineItem: any = {
quantity: item?.quantity,
Expand Down
12 changes: 6 additions & 6 deletions packages/core/core-flows/src/cart/workflows/add-to-cart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ const cartFields = ["completed_at"].concat(cartFieldsForPricingContext)

export const addToCartWorkflowId = "add-to-cart"
/**
* This workflow adds a product variant to a cart as a line item. It's executed by the
* This workflow adds a product variant to a cart as a line item. It's executed by the
* [Add Line Item Store API Route](https://docs.medusajs.com/api/store#carts_postcartsidlineitems).
*
*
* You can use this workflow within your own customizations or custom workflows, allowing you to wrap custom logic around adding an item to the cart.
* For example, you can use this workflow to add a line item to the cart with a custom price.
*
*
* @example
* const { result } = await addToCartWorkflow(container)
* .run({
Expand All @@ -59,11 +59,11 @@ export const addToCartWorkflowId = "add-to-cart"
* ]
* }
* })
*
*
* @summary
*
*
* Add a line item to a cart.
*
*
* @property hooks.validate - This hook is executed before all operations. You can consume this hook to perform any custom validation. If validation fails, you can throw an error to stop the workflow execution.
*/
export const addToCartWorkflow = createWorkflow(
Expand Down
Loading

0 comments on commit ee848bf

Please sign in to comment.