feat(core-flows, dashboard, link-modules,medusa, types, utils): fulfillment shipping changes (#10902)

**What**
- product <> shipping profile link
- create and update product workflows/endpoints accepts shipping profile
- pass shipping option id when creating fulfillment to allow overriding customer selected SO
- validate shipping profile delete
- dashboard
  - set shipping profile on product create
  - manage shipping profile for a product
  - **update the create fulfillment form**
- other
  - fix create product form infinite rerenders
 
---

CLOSES CMRC-831 CMRC-834 CMRC-836 CMRC-837 CMRC-838 CMRC-857 TRI-761
This commit is contained in:
Frane Polić authored and GitHub committed 2025-01-27 12:00:20 +00:00
1 parent 3e81962503
commit 864d772e34
78 files changed
+3529 -794

No files matched your search

@@ -0,0 +1 @@
export * from "./sidebar-link"
@@ -0,0 +1,47 @@
import { ReactNode } from "react"
import { Link } from "react-router-dom"
import { IconAvatar } from "../icon-avatar"
import { Text } from "@medusajs/ui"
import { TriangleRightMini } from "@medusajs/icons"
export interface SidebarLinkProps {
to: string
labelKey: string
descriptionKey: string
icon: ReactNode
}
export const SidebarLink = ({
to,
labelKey,
descriptionKey,
icon,
}: SidebarLinkProps) => {
return (
<Link to={to} className="group outline-none">
<div className="flex flex-col gap-2 px-2 pb-2">
<div className="shadow-elevation-card-rest bg-ui-bg-component transition-fg hover:bg-ui-bg-component-hover active:bg-ui-bg-component-pressed group-focus-visible:shadow-borders-interactive-with-active rounded-md px-4 py-2">
<div className="flex items-center gap-4">
<IconAvatar>{icon}</IconAvatar>
<div className="flex flex-1 flex-col">
<Text size="small" leading="compact" weight="plus">
{labelKey}
</Text>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
{descriptionKey}
</Text>
</div>
<div className="flex size-7 items-center justify-center">
<TriangleRightMini className="text-ui-fg-muted" />
</div>
</div>
</div>
</div>
</Link>
)
}
File diff suppressed because it is too large. Load diff
@@ -554,6 +554,10 @@
"label": "Discountable",
"hint": "When unchecked, discounts will not be applied to this product"
},
"shipping_profile": {
"label": "Shipping profile",
"hint": "Connect the product to a shipping profile"
},
"type": {
"label": "Type"
},
@@ -681,6 +685,20 @@
"alreadyManagedWithSku": "This inventory item is already editable under {{title}} ({{sku}})."
}
},
"shippingProfile": {
"header": "Shipping configuration",
"edit": {
"header": "Shipping Configuration",
"toasts": {
"success": "Successfully updated the shipping profile for {{title}}."
}
},
"create": {
"errors": {
"required": "Shipping profile is required"
}
}
},
"toasts": {
"delete": {
"success": {
@@ -1249,6 +1267,8 @@
"fulfillment": {
"cancelWarning": "You are about to cancel a fulfillment. This action cannot be undone.",
"markAsDeliveredWarning": "You are about to mark fulfillment as delivered. This action cannot be undone.",
"differentOptionSelected": "The selected shipping option is different from the one selected by the customer.",
"disabledItemTooltip": "The shipping option you have selected don’t allow fulfillment of this item",
"unfulfilledItems": "Unfulfilled Items",
"statusLabel": "Fulfillment status",
"statusTitle": "Fulfillment Status",
@@ -1269,7 +1289,9 @@
"error": {
"wrongQuantity": "Only one item is available for fulfillment",
"wrongQuantity_other": "Quantity should be a number between 1 and {{number}}",
"noItems": "No items to fulfill."
"noItems": "No items to fulfill.",
"noShippingOption": "Shipping option is required",
"noLocation": "Location is required"
},
"status": {
"notFulfilled": "Not fulfilled",
@@ -2749,7 +2771,9 @@
"added": "Added",
"removed": "Removed",
"from": "From",
"to": "To"
"to": "To",
"beaware": "Be aware",
"loading": "Loading"
},
"fields": {
"amount": "Amount",
@@ -102,6 +102,13 @@ export const RouteMap: RouteObject[] = [
lazy: () =>
import("../../routes/products/product-organization"),
},
{
path: "shipping-profile",
lazy: () =>
import(
"../../routes/products/product-shipping-profile"
),
},
{
path: "media",
lazy: () =>
@@ -13,6 +13,7 @@ import { IconAvatar } from "../../../components/common/icon-avatar"
import { TwoColumnPage } from "../../../components/layout/pages"
import { useDashboardExtension } from "../../../extensions"
import { LocationListHeader } from "./components/location-list-header"
import { SidebarLink } from "../../../components/common/sidebar-link/sidebar-link"
export function LocationList() {
const initialData = useLoaderData() as Awaited<
@@ -61,47 +62,6 @@ export function LocationList() {
)
}
interface SidebarLinkProps {
to: string
labelKey: string
descriptionKey: string
icon: ReactNode
}
const SidebarLink = ({
to,
labelKey,
descriptionKey,
icon,
}: SidebarLinkProps) => {
return (
<Link to={to} className="group outline-none">
<div className="flex flex-col gap-2 px-2 pb-2">
<div className="shadow-elevation-card-rest bg-ui-bg-component transition-fg hover:bg-ui-bg-component-hover active:bg-ui-bg-component-pressed group-focus-visible:shadow-borders-interactive-with-active rounded-md px-4 py-2">
<div className="flex items-center gap-4">
<IconAvatar>{icon}</IconAvatar>
<div className="flex flex-1 flex-col">
<Text size="small" leading="compact" weight="plus">
{labelKey}
</Text>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
{descriptionKey}
</Text>
</div>
<div className="flex size-7 items-center justify-center">
<TriangleRightMini className="text-ui-fg-muted" />
</div>
</div>
</div>
</div>
</Link>
)
}
const LinksSection = () => {
const { t } = useTranslation()
@@ -2,7 +2,6 @@ import { z } from "zod"
export const CreateFulfillmentSchema = z.object({
quantity: z.record(z.string(), z.number()),
location_id: z.string(),
shipping_option_id: z.string().optional(),
send_notification: z.boolean().optional(),
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { AdminOrder } from "@medusajs/types"
import { AdminOrder, HttpTypes } from "@medusajs/types"
import { Alert, Button, Select, Switch, toast } from "@medusajs/ui"
import { useForm, useWatch } from "react-hook-form"
@@ -19,7 +19,10 @@ import { useStockLocations } from "../../../../../hooks/api/stock-locations"
import { getFulfillableQuantity } from "../../../../../lib/order-item"
import { CreateFulfillmentSchema } from "./constants"
import { OrderCreateFulfillmentItem } from "./order-create-fulfillment-item"
import { useReservationItems } from "../../../../../hooks/api"
import {
useReservationItems,
useShippingOptions,
} from "../../../../../hooks/api"
type OrderCreateFulfillmentFormProps = {
order: AdminOrder
@@ -56,30 +59,86 @@ export function OrderCreateFulfillmentForm({
const form = useForm<zod.infer<typeof CreateFulfillmentSchema>>({
defaultValues: {
quantity: fulfillableItems.reduce((acc, item) => {
acc[item.id] = getFulfillableQuantity(item)
return acc
}, {} as Record<string, number>),
quantity: fulfillableItems.reduce(
(acc, item) => {
acc[item.id] = getFulfillableQuantity(item)
return acc
},
{} as Record<string, number>
),
send_notification: !order.no_notification,
},
resolver: zodResolver(CreateFulfillmentSchema),
})
const selectedLocationId = useWatch({
name: "location_id",
control: form.control,
})
const { stock_locations = [] } = useStockLocations()
const { shipping_options = [], isLoading: isShippingOptionsLoading } =
useShippingOptions({
stock_location_id: selectedLocationId,
// is_return: false, // TODO: 500 when enabled
fields: "+service_zone.fulfillment_set.location.id",
})
const shippingOptionId = useWatch({
name: "shipping_option_id",
control: form.control,
})
const handleSubmit = form.handleSubmit(async (data) => {
try {
await createOrderFulfillment({
location_id: data.location_id,
// shipping_option_id: data.shipping_option_id,
no_notification: !data.send_notification,
items: Object.entries(data.quantity)
.filter(([, value]) => !!value)
.map(([id, quantity]) => ({
id,
quantity,
})),
const selectedShippingOption = shipping_options.find(
(o) => o.id === shippingOptionId
)
if (!selectedShippingOption) {
form.setError("shipping_option_id", {
type: "manual",
message: t("orders.fulfillment.error.noShippingOption"),
})
return
}
if (!selectedLocationId) {
form.setError("location_id", {
type: "manual",
message: t("orders.fulfillment.error.noLocation"),
})
return
}
const selectedShippingProfileId =
selectedShippingOption?.shipping_profile_id
const itemShippingProfileMap = order.items.reduce(
(acc, item) => {
acc[item.id] = item.variant?.product?.shipping_profile?.id
return acc
},
{} as Record<string, string | null>
)
const payload: HttpTypes.AdminCreateOrderFulfillment = {
location_id: selectedLocationId,
shipping_option_id: shippingOptionId,
no_notification: !data.send_notification,
items: Object.entries(data.quantity)
.filter(
([id, value]) =>
!!value && itemShippingProfileMap[id] === selectedShippingProfileId
)
.map(([id, quantity]) => ({
id,
quantity,
})),
}
try {
await createOrderFulfillment(payload)
toast.success(t("orders.fulfillment.toast.created"))
handleSuccess(`/orders/${order.id}`)
@@ -89,15 +148,28 @@ export function OrderCreateFulfillmentForm({
})
useEffect(() => {
if (stock_locations?.length) {
form.setValue("location_id", stock_locations[0].id)
}
}, [stock_locations?.length])
if (stock_locations?.length && shipping_options?.length) {
const initialShippingOptionId =
order.shipping_methods?.[0]?.shipping_option_id
const selectedLocationId = useWatch({
name: "location_id",
control: form.control,
})
if (initialShippingOptionId) {
const shippingOption = shipping_options.find(
(o) => o.id === initialShippingOptionId
)
if (shippingOption) {
const locationId =
shippingOption.service_zone.fulfillment_set.location.id
form.setValue("location_id", locationId)
form.setValue(
"shipping_option_id",
initialShippingOptionId || undefined
)
} // else -> TODO: what if original shipping option is deleted?
}
}
}, [stock_locations?.length, shipping_options?.length])
const fulfilledQuantityArray = (order.items || []).map(
(item) =>
@@ -124,14 +196,21 @@ export function OrderCreateFulfillmentForm({
})
}
const quantityMap = itemsToFulfill.reduce((acc, item) => {
acc[item.id] = getFulfillableQuantity(item as OrderLineItemDTO)
return acc
}, {} as Record<string, number>)
const quantityMap = itemsToFulfill.reduce(
(acc, item) => {
acc[item.id] = getFulfillableQuantity(item as OrderLineItemDTO)
return acc
},
{} as Record<string, number>
)
form.setValue("quantity", quantityMap)
}, [...fulfilledQuantityArray, requiresShipping])
const differentOptionSelected =
shippingOptionId &&
order.shipping_methods?.[0]?.shipping_option_id !== shippingOptionId
return (
<RouteFocusModal.Form form={form}>
<KeyboundForm
@@ -185,48 +264,69 @@ export function OrderCreateFulfillmentForm({
/>
</div>
{/* <div className="py-8">*/}
{/* <Form.Field*/}
{/* control={form.control}*/}
{/* name="shipping_option_id"*/}
{/* render={({ field: { onChange, ref, ...field } }) => {*/}
{/* return (*/}
{/* <Form.Item>*/}
{/* <div className="flex flex-col gap-2 xl:flex-row xl:items-center">*/}
{/* <div className="flex-1">*/}
{/* <Form.Label>*/}
{/* {t("fields.shippingMethod")}*/}
{/* </Form.Label>*/}
{/* <Form.Hint>*/}
{/* {t("orders.fulfillment.methodDescription")}*/}
{/* </Form.Hint>*/}
{/* </div>*/}
{/* <div className="flex-1">*/}
{/* <Form.Control>*/}
{/* <Select onValueChange={onChange} {...field}>*/}
{/* <Select.Trigger*/}
{/* className="bg-ui-bg-base"*/}
{/* ref={ref}*/}
{/* >*/}
{/* <Select.Value />*/}
{/* </Select.Trigger>*/}
{/* <Select.Content>*/}
{/* {shipping_options.map((o) => (*/}
{/* <Select.Item key={o.id} value={o.id}>*/}
{/* {o.name}*/}
{/* </Select.Item>*/}
{/* ))}*/}
{/* </Select.Content>*/}
{/* </Select>*/}
{/* </Form.Control>*/}
{/* </div>*/}
{/* </div>*/}
{/* <Form.ErrorMessage />*/}
{/* </Form.Item>*/}
{/* )*/}
{/* }}*/}
{/* />*/}
{/* </div>*/}
<div className="py-8">
<Form.Field
control={form.control}
name="shipping_option_id"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<div className="flex flex-col gap-2 xl:flex-row xl:items-center">
<div className="flex-1">
<Form.Label>
{t("fields.shippingMethod")}
</Form.Label>
<Form.Hint>
{t("orders.fulfillment.methodDescription")}
</Form.Hint>
</div>
<div className="flex-1">
<Form.Control>
<Select
onValueChange={onChange}
{...field}
disabled={!selectedLocationId}
>
<Select.Trigger
className="bg-ui-bg-base"
ref={ref}
>
{isShippingOptionsLoading ? (
<span className="text-right">
{t("labels.loading")}...
</span>
) : (
<Select.Value />
)}
</Select.Trigger>
<Select.Content>
{shipping_options.map((o) => (
<Select.Item key={o.id} value={o.id}>
{o.name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
</div>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{differentOptionSelected && (
<Alert className="mt-4 p-4" variant="warning">
<span className="-mt-[3px] block font-semibold">
{t("labels.beaware")}
</span>
<span className="text-ui-fg-muted">
{t("orders.fulfillment.differentOptionSelected")}
</span>
</Alert>
)}
</div>
<div>
<Form.Item className="mt-8">
<Form.Label>
@@ -238,12 +338,19 @@ export function OrderCreateFulfillmentForm({
<div className="flex flex-col gap-y-1">
{fulfillableItems.map((item) => {
const isShippingProfileMatching =
shipping_options.find(
(o) => o.id === shippingOptionId
)?.shipping_profile_id ===
item.variant?.product?.shipping_profile?.id
return (
<OrderCreateFulfillmentItem
key={item.id}
form={form}
item={item}
locationId={selectedLocationId}
disabled={isShippingProfileMatching}
itemReservedQuantitiesMap={
itemReservedQuantitiesMap
}
@@ -305,7 +412,12 @@ export function OrderCreateFulfillmentForm({
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button size="small" type="submit" isLoading={isMutating}>
<Button
size="small"
type="submit"
isLoading={isMutating}
disabled={!shippingOptionId}
>
{t("orders.fulfillment.create")}
</Button>
</div>
@@ -1,7 +1,7 @@
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Input, Text } from "@medusajs/ui"
import { clx, Input, Text, Tooltip } from "@medusajs/ui"
import { UseFormReturn } from "react-hook-form"
import { HttpTypes } from "@medusajs/types"
@@ -10,6 +10,7 @@ import { Thumbnail } from "../../../../../components/common/thumbnail/index"
import { useProductVariant } from "../../../../../hooks/api/products"
import { getFulfillableQuantity } from "../../../../../lib/order-item"
import { CreateFulfillmentSchema } from "./constants"
import { InformationCircleSolid } from "@medusajs/icons"
type OrderEditItemProps = {
item: HttpTypes.AdminOrderLineItem
@@ -18,6 +19,7 @@ type OrderEditItemProps = {
onItemRemove: (itemId: string) => void
itemReservedQuantitiesMap: Map<string, number>
form: UseFormReturn<zod.infer<typeof CreateFulfillmentSchema>>
disabled: boolean
}
export function OrderCreateFulfillmentItem({
@@ -25,6 +27,7 @@ export function OrderCreateFulfillmentItem({
form,
locationId,
itemReservedQuantitiesMap,
disabled,
}: OrderEditItemProps) {
const { t } = useTranslation()
@@ -70,102 +73,120 @@ export function OrderCreateFulfillmentItem({
)
return (
<div className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 rounded-xl ">
<div className="flex flex-col gap-x-2 gap-y-2 border-b p-3 text-sm sm:flex-row">
<div className="flex flex-1 items-center gap-x-3">
<Thumbnail src={item.thumbnail} />
<div className="flex flex-col">
<div>
<Text className="txt-small" as="span" weight="plus">
{item.title}
</Text>
{item.variant_sku && <span>({item.variant_sku})</span>}
</div>
<Text as="div" className="text-ui-fg-subtle txt-small">
{item.variant_title}
</Text>
<div className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 rounded-xl">
<div className="flex flex-row items-center">
{disabled && (
<div className="inline-flex items-center ml-4">
<Tooltip
content={t("orders.fulfillment.disabledItemTooltip")}
side="top"
>
<InformationCircleSolid className="text-ui-tag-orange-icon" />
</Tooltip>
</div>
</div>
)}
<div className="flex flex-1 items-center gap-x-1">
<div className="mr-2 block h-[16px] w-[2px] bg-gray-200" />
<div className="text-small flex flex-1 flex-col">
<span className="text-ui-fg-subtle font-medium">
{t("orders.fulfillment.available")}
</span>
<span className="text-ui-fg-subtle">
{availableQuantity || "N/A"}
</span>
<div
className={clx(
"flex flex-col flex-1 gap-x-2 gap-y-2 border-b p-3 text-sm sm:flex-row",
disabled && "opacity-50 pointer-events-none"
)}
>
<div className="flex flex-1 items-center gap-x-3">
<Thumbnail src={item.thumbnail} />
<div className="flex flex-col">
<div>
<Text className="txt-small" as="span" weight="plus">
{item.title}
</Text>
{item.variant_sku && <span>({item.variant_sku})</span>}
</div>
<Text as="div" className="text-ui-fg-subtle txt-small">
{item.variant_title}
</Text>
</div>
</div>
<div className="flex flex-1 items-center gap-x-1">
<div className="mr-2 block h-[16px] w-[2px] bg-gray-200" />
<div className="flex flex-col">
<div className="text-small flex flex-1 flex-col">
<span className="text-ui-fg-subtle font-medium">
{t("orders.fulfillment.inStock")}
{t("orders.fulfillment.available")}
</span>
<span className="text-ui-fg-subtle">
{inStockQuantity || "N/A"}{" "}
{inStockQuantity && (
<span className="font-medium text-red-500">
-{form.getValues(`quantity.${item.id}`)}
</span>
)}
{availableQuantity || "N/A"}
</span>
</div>
</div>
<div className="flex flex-1 items-center gap-1">
<Form.Field
control={form.control}
name={`quantity.${item.id}`}
rules={{ required: true, min: minValue, max: maxValue }}
render={({ field }) => {
return (
<Form.Item>
<Form.Control>
<Input
className="bg-ui-bg-base txt-small w-[50px] rounded-lg text-right [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
type="number"
{...field}
onChange={(e) => {
const val =
e.target.value === ""
? null
: Number(e.target.value)
<div className="flex flex-1 items-center gap-x-1">
<div className="mr-2 block h-[16px] w-[2px] bg-gray-200" />
field.onChange(val)
<div className="flex flex-col">
<span className="text-ui-fg-subtle font-medium">
{t("orders.fulfillment.inStock")}
</span>
<span className="text-ui-fg-subtle">
{inStockQuantity || "N/A"}{" "}
{inStockQuantity && (
<span className="font-medium text-red-500">
-{form.getValues(`quantity.${item.id}`)}
</span>
)}
</span>
</div>
</div>
if (!isNaN(val)) {
if (val < minValue || val > maxValue) {
form.setError(`quantity.${item.id}`, {
type: "manual",
message: t(
"orders.fulfillment.error.wrongQuantity",
{
count: maxValue,
number: maxValue,
}
),
})
} else {
form.clearErrors(`quantity.${item.id}`)
<div className="flex flex-1 items-center gap-1">
<Form.Field
control={form.control}
name={`quantity.${item.id}`}
rules={{ required: true, min: minValue, max: maxValue }}
render={({ field }) => {
return (
<Form.Item>
<Form.Control>
<Input
className="bg-ui-bg-base txt-small w-[50px] rounded-lg text-right [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
type="number"
{...field}
onChange={(e) => {
const val =
e.target.value === ""
? null
: Number(e.target.value)
field.onChange(val)
if (!isNaN(val)) {
if (val < minValue || val > maxValue) {
form.setError(`quantity.${item.id}`, {
type: "manual",
message: t(
"orders.fulfillment.error.wrongQuantity",
{
count: maxValue,
number: maxValue,
}
),
})
} else {
form.clearErrors(`quantity.${item.id}`)
}
}
}
}}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
}}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<span className="text-ui-fg-subtle">
/ {item.quantity} {t("fields.qty")}
</span>
<span className="text-ui-fg-subtle">
/ {item.quantity} {t("fields.qty")}
</span>
</div>
</div>
</div>
</div>
@@ -10,7 +10,8 @@ export function OrderCreateFulfillment() {
const requiresShipping = searchParams.get("requires_shipping") === "true"
const { order, isLoading, isError, error } = useOrder(id!, {
fields: "currency_code,*items,*items.variant,*shipping_address",
fields:
"currency_code,*items,*items.variant,+items.variant.product.shipping_profile.id,*shipping_address,+shipping_methods.shipping_option_id",
})
if (isError) {
@@ -79,10 +79,13 @@ export const ProductCreateForm = ({
return {}
}
return regions.reduce((acc, reg) => {
acc[reg.id] = reg.currency_code
return acc
}, {} as Record<string, string>)
return regions.reduce(
(acc, reg) => {
acc[reg.id] = reg.currency_code
return acc
},
{} as Record<string, string>
)
}, [regions])
/**
@@ -178,6 +181,15 @@ 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)
}
@@ -208,7 +220,8 @@ export const ProductCreateForm = ({
}
setTabState({ ...currentState })
}, [tab, tabState])
// eslint-disable-next-line react-hooks/exhaustive-deps -- we only want this effect to run when the tab changes
}, [tab])
return (
<RouteFocusModal.Form form={form}>
@@ -51,6 +51,16 @@ export const ProductCreateOrganizationSection = ({
})),
})
const shippingProfiles = useComboboxData({
queryKey: ["shipping_profiles"],
queryFn: (params) => sdk.admin.shippingProfile.list(params),
getOptions: (data) =>
data.shipping_profiles.map((shippingProfile) => ({
label: shippingProfile.name,
value: shippingProfile.id,
})),
})
const { fields, remove, replace } = useFieldArray({
control: form.control,
name: "sales_channels",
@@ -161,6 +171,34 @@ 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.Hint>
<Trans i18nKey={"products.fields.shipping_profile.hint"} />
</Form.Hint>
</div>
<Form.Field
control={form.control}
name="shipping_profile_id"
render={({ field }) => {
return (
<Form.Item>
<Form.Control>
<Combobox
{...field}
options={shippingProfiles.options}
searchValue={shippingProfiles.searchValue}
onSearchValueChange={shippingProfiles.onSearchValueChange}
fetchNextPage={shippingProfiles.fetchNextPage}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="grid grid-cols-1 gap-y-4">
<Form.Field
control={form.control}
@@ -64,6 +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
categories: z.array(z.string()),
tags: z.array(z.string()).optional(),
sales_channels: z
@@ -145,6 +146,7 @@ export const PRODUCT_CREATE_FORM_DEFAULTS: Partial<
media: [],
categories: [],
collection_id: "",
shipping_profile_id: "",
description: "",
handle: "",
height: "",
@@ -24,6 +24,7 @@ export const normalizeProductFormValues = (
: undefined,
images,
collection_id: values.collection_id || undefined,
shipping_profile_id: values.shipping_profile_id,
categories: values.categories.map((id) => ({ id })),
type_id: values.type_id || undefined,
handle: values.handle || undefined,
@@ -0,0 +1 @@
export * from "./product-shipping-profile-section"
@@ -0,0 +1,53 @@
import { PencilSquare, ShoppingBag } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { SidebarLink } from "../../../../../components/common/sidebar-link/sidebar-link"
import { ActionMenu } from "../../../../../components/common/action-menu"
type ProductShippingProfileSectionProps = {
product: HttpTypes.AdminProduct & {
shipping_profile: HttpTypes.AdminShippingProfile
}
}
export const ProductShippingProfileSection = ({
product,
}: ProductShippingProfileSectionProps) => {
const { t } = useTranslation()
const shippingProfile = product.shipping_profile
if (!shippingProfile) {
return null
}
return (
<Container className="p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("products.shippingProfile.header")}</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: "shipping-profile",
icon: <PencilSquare />,
},
],
},
]}
/>
</div>
<SidebarLink
to={`/settings/locations/shipping-profiles/${shippingProfile.id}`}
labelKey={shippingProfile.name}
descriptionKey={shippingProfile.type}
icon={<ShoppingBag />}
/>
</Container>
)
}
@@ -2,5 +2,5 @@ import { getLinkedFields } from "../../../extensions"
export const PRODUCT_DETAIL_FIELDS = getLinkedFields(
"product",
"*categories,-variants"
"*categories,*shipping_profile,-variants"
)
@@ -14,6 +14,7 @@ import { PRODUCT_DETAIL_FIELDS } from "./constants"
import { productLoader } from "./loader"
import { useDashboardExtension } from "../../../extensions"
import { ProductShippingProfileSection } from "./components/product-shipping-profile-section"
export const ProductDetail = () => {
const initialData = useLoaderData() as Awaited<
@@ -71,6 +72,7 @@ export const ProductDetail = () => {
</TwoColumnPage.Main>
<TwoColumnPage.Sidebar>
<ProductSalesChannelSection product={product} />
<ProductShippingProfileSection product={product} />
<ProductOrganizationSection product={product} />
<ProductAttributeSection product={product} />
</TwoColumnPage.Sidebar>
@@ -0,0 +1 @@
export * from "./product-shipping-profile-form"
@@ -0,0 +1,122 @@
import { HttpTypes } from "@medusajs/types"
import { Button, toast } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
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"
type ProductShippingProfileFormProps = {
product: HttpTypes.AdminProduct & {
shipping_profile?: HttpTypes.AdminShippingProfile
}
}
const ProductShippingProfileSchema = zod.object({
shipping_profile_id: zod.string(),
})
export const ProductShippingProfileForm = ({
product,
}: ProductShippingProfileFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const shippingProfiles = useComboboxData({
queryKey: ["shipping_profiles"],
queryFn: (params) => sdk.admin.shippingProfile.list(params),
getOptions: (data) =>
data.shipping_profiles.map((shippingProfile) => ({
label: shippingProfile.name,
value: shippingProfile.id,
})),
})
const form = useForm({
defaultValues: {
shipping_profile_id: product.shipping_profile?.id ?? "",
},
resolver: zodResolver(ProductShippingProfileSchema),
})
const { mutateAsync, isPending } = useUpdateProduct(product.id)
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
{
shipping_profile_id: data.shipping_profile_id,
},
{
onSuccess: ({ product }) => {
toast.success(
t("products.shippingProfile.edit.toasts.success", {
title: product.title,
})
)
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
}
)
})
return (
<RouteDrawer.Form form={form}>
<KeyboundForm onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteDrawer.Body>
<div className="flex h-full flex-col gap-y-4">
<Form.Field
control={form.control}
name="shipping_profile_id"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("products.fields.shipping_profile.label")}
</Form.Label>
<Form.Control>
<Combobox
{...field}
options={shippingProfiles.options}
searchValue={shippingProfiles.searchValue}
onSearchValueChange={
shippingProfiles.onSearchValueChange
}
fetchNextPage={shippingProfiles.fetchNextPage}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{/* <FormExtensionZone fields={fields} form={form} /> */}
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</KeyboundForm>
</RouteDrawer.Form>
)
}
@@ -0,0 +1 @@
export { ProductShippingProfile as Component } from "./product-shipping-profile"
@@ -0,0 +1,34 @@
import { Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/modals"
import { useProduct } from "../../../hooks/api/products"
import { PRODUCT_DETAIL_FIELDS } from "../product-detail/constants"
import { ProductShippingProfileForm } from "./components/product-organization-form"
export const ProductShippingProfile = () => {
const { id } = useParams()
const { t } = useTranslation()
const { product, isLoading, isError, error } = useProduct(id!, {
fields: PRODUCT_DETAIL_FIELDS,
})
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<RouteDrawer.Title asChild>
<Heading>{t("products.shippingProfile.edit.header")}</Heading>
</RouteDrawer.Title>
</RouteDrawer.Header>
{!isLoading && product && (
<ProductShippingProfileForm product={product} />
)}
</RouteDrawer>
)
}
@@ -58,14 +58,14 @@ export type CreateFulfillmentValidateOrderStepInput = {
* This step validates that a fulfillment can be created for an order. If the order
* is canceled, the items don't exist in the order, or the items aren't grouped by
* shipping requirement, the step throws an error.
*
*
* :::note
*
*
* You can retrieve an order's details using [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query),
* or [useQueryGraphStep](https://docs.medusajs.com/resources/references/medusa-workflows/steps/useQueryGraphStep).
*
*
* :::
*
*
* @example
* const data = createFulfillmentValidateOrder({
* order: {
@@ -82,10 +82,7 @@ export type CreateFulfillmentValidateOrderStepInput = {
*/
export const createFulfillmentValidateOrder = createStep(
"create-fulfillment-validate-order",
({
order,
inputItems,
}: CreateFulfillmentValidateOrderStepInput) => {
({ order, inputItems }: CreateFulfillmentValidateOrderStepInput) => {
throwIfOrderIsCancelled({ order })
throwIfItemsDoesNotExistsInOrder({ order, inputItems })
throwIfItemsAreNotGroupedByShippingRequirement({ order, inputItems })
@@ -128,6 +125,7 @@ function prepareFulfillmentData({
id: string
provider_id: string
service_zone: { fulfillment_set: { location?: { id: string } } }
shipping_profile_id: string
}
shippingMethod: { data?: Record<string, unknown> | null }
reservations: ReservationItemDTO[]
@@ -156,6 +154,18 @@ function prepareFulfillmentData({
const orderItem = orderItemsMap.get(i.id)!
const reservation = reservationItemMap.get(i.id)!
if (
orderItem.requires_shipping &&
(orderItem as any).variant?.product &&
(orderItem as any).variant?.product.shipping_profile?.id !==
shippingOption.shipping_profile_id
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Shipping profile ${shippingOption.shipping_profile_id} does not match the shipping profile of the order item ${orderItem.id}`
)
}
return {
line_item_id: i.id,
inventory_item_id: reservation?.inventory_item_id,
@@ -273,17 +283,18 @@ function prepareInventoryUpdate({
/**
* The details of the fulfillment to create, along with custom data that's passed to the workflow's hooks.
*/
export type CreateOrderFulfillmentWorkflowInput = OrderWorkflow.CreateOrderFulfillmentWorkflowInput & AdditionalData
export type CreateOrderFulfillmentWorkflowInput =
OrderWorkflow.CreateOrderFulfillmentWorkflowInput & AdditionalData
export const createOrderFulfillmentWorkflowId = "create-order-fulfillment"
/**
* This workflow creates a fulfillment for an order. It's used by the [Create Order Fulfillment Admin API Route](https://docs.medusajs.com/api/admin#orders_postordersidfulfillments).
*
* This workflow has a hook that allows you to perform custom actions on the created fulfillment. For example, you can pass under `additional_data` custom data that
*
* This workflow has a hook that allows you to perform custom actions on the created fulfillment. For example, you can pass under `additional_data` custom data that
* allows you to create custom data models linked to the fulfillment.
*
*
* You can also use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around creating a fulfillment.
*
*
* @example
* const { result } = await createOrderFulfillmentWorkflow(container)
* .run({
@@ -300,18 +311,16 @@ export const createOrderFulfillmentWorkflowId = "create-order-fulfillment"
* }
* }
* })
*
*
* @summary
*
*
* Creates a fulfillment for an order.
*
*
* @property hooks.fulfillmentCreated - This hook is executed after the fulfillment is created. You can consume this hook to perform custom actions on the created fulfillment.
*/
export const createOrderFulfillmentWorkflow = createWorkflow(
createOrderFulfillmentWorkflowId,
(
input: WorkflowData<CreateOrderFulfillmentWorkflowInput>
) => {
(input: WorkflowData<CreateOrderFulfillmentWorkflowInput>) => {
const order: OrderDTO = useRemoteQueryStep({
entry_point: "orders",
fields: [
@@ -323,12 +332,14 @@ export const createOrderFulfillmentWorkflow = createWorkflow(
"items.variant.manage_inventory",
"items.variant.allow_backorder",
"items.variant.product.id",
"items.variant.product.shipping_profile.id",
"items.variant.weight",
"items.variant.length",
"items.variant.height",
"items.variant.width",
"items.variant.material",
"shipping_address.*",
"shipping_methods.id",
"shipping_methods.shipping_option_id",
"shipping_methods.data",
],
@@ -346,17 +357,29 @@ export const createOrderFulfillmentWorkflow = createWorkflow(
}, {})
})
const shippingMethod = transform(order, (data) => {
return { data: data.shipping_methods?.[0]?.data }
const shippingOptionId = transform({ order, input }, (data) => {
return (
data.input.shipping_option_id ??
data.order.shipping_methods?.[0]?.shipping_option_id
)
})
const shippingOptionId = transform(order, (data) => {
return data.shipping_methods?.[0]?.shipping_option_id
const shippingMethod = transform({ order, shippingOptionId }, (data) => {
return {
data: data.order.shipping_methods?.find(
(sm) => sm.shipping_option_id === data.shippingOptionId
)?.data,
}
})
const shippingOption = useRemoteQueryStep({
entry_point: "shipping_options",
fields: ["id", "provider_id", "service_zone.fulfillment_set.location.id"],
fields: [
"id",
"provider_id",
"service_zone.fulfillment_set.location.id",
"shipping_profile_id",
],
variables: {
id: shippingOptionId,
},
@@ -136,6 +136,11 @@ const normalizeProductForImport = (
return
}
if (normalizedKey.startsWith("shipping_profile_id")) {
response["shipping_profile_id"] = normalizedValue
return
}
if (normalizedKey.startsWith("product_category_")) {
response["categories"] = [
...(response["categories"] || []),
@@ -1,4 +1,8 @@
import { ProductTypes, SalesChannelTypes } from "@medusajs/framework/types"
import {
ProductTypes,
SalesChannelTypes,
ShippingProfileDTO,
} from "@medusajs/framework/types"
import { MedusaError } from "@medusajs/framework/utils"
const basicFieldsToOmit = [
@@ -32,6 +36,7 @@ export const normalizeV1Products = (
productTypes: ProductTypes.ProductTypeDTO[]
productCollections: ProductTypes.ProductCollectionDTO[]
salesChannels: SalesChannelTypes.SalesChannelDTO[]
shippingProfiles: ShippingProfileDTO[]
}
): object[] => {
const productTypesMap = new Map(
@@ -43,6 +48,9 @@ export const normalizeV1Products = (
const salesChannelsMap = new Map(
supportingData.salesChannels.map((sc) => [sc.name, sc.id])
)
const shippingProfilesIds = new Set(
supportingData.shippingProfiles.map((sp) => sp.id)
)
return rawProducts.map((product) => {
let finalRes = {
@@ -140,6 +148,21 @@ export const normalizeV1Products = (
}
}
if (key.startsWith("Shipping Profile Id")) {
if (!value) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Shipping Profile Id is required when importing products"
)
}
if (!shippingProfilesIds.has(value)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Shipping profile: '${value}' does not exist`
)
}
}
if (
key.startsWith("Product Category") &&
(key.endsWith("Handle") ||
@@ -1,4 +1,5 @@
import {
IFulfillmentModuleService,
IProductModuleService,
IRegionModuleService,
ISalesChannelModuleService,
@@ -16,9 +17,9 @@ export type ParseProductCsvStepInput = string
export const parseProductCsvStepId = "parse-product-csv"
/**
* This step parses a CSV file holding products to import, returning the products as
* This step parses a CSV file holding products to import, returning the products as
* objects that can be imported.
*
*
* @example
* const data = parseProductCsvStep("products.csv")
*/
@@ -35,20 +36,25 @@ export const parseProductCsvStep = createStep(
Modules.SALES_CHANNEL
)
const fulfillmentService = container.resolve<IFulfillmentModuleService>(
Modules.FULFILLMENT
)
const csvProducts = convertCsvToJson(fileContent)
const [productTypes, productCollections, salesChannels] = await Promise.all(
[
const [productTypes, productCollections, salesChannels, shippingProfiles] =
await Promise.all([
productService.listProductTypes({}, {}),
productService.listProductCollections({}, {}),
salesChannelService.listSalesChannels({}, {}),
]
)
fulfillmentService.listShippingProfiles({}, {}),
])
const v1Normalized = normalizeV1Products(csvProducts, {
productTypes,
productCollections,
salesChannels,
shippingProfiles,
})
// We use the handle to group products and variants correctly.
@@ -1,6 +1,7 @@
import {
AdditionalData,
CreateProductWorkflowInputDTO,
LinkDefinition,
PricingTypes,
ProductTypes,
} from "@medusajs/framework/types"
@@ -8,6 +9,7 @@ import {
ProductWorkflowEvents,
isPresent,
MedusaError,
Modules,
} from "@medusajs/framework/utils"
import {
WorkflowData,
@@ -17,7 +19,11 @@ import {
transform,
createStep,
} from "@medusajs/framework/workflows-sdk"
import { emitEventStep } from "../../common"
import {
createRemoteLinkStep,
emitEventStep,
useQueryGraphStep,
} from "../../common"
import { associateProductsWithSalesChannelsStep } from "../../sales-channel"
import { createProductsStep } from "../steps/create-products"
import { createProductVariantsWorkflow } from "./create-product-variants"
@@ -29,14 +35,19 @@ export interface ValidateProductInputStepInput {
/**
* The products to validate.
*/
products: CreateProductWorkflowInputDTO[]
products: Omit<CreateProductWorkflowInputDTO, "sales_channels">[]
/**
* The shipping profiles to validate.
*/
shippingProfiles: { id: string }[]
}
const validateProductInputStepId = "validate-product-input"
/**
* This step validates that all provided products have options.
* If a product is missing options, an error is thrown.
*
*
* @example
* const data = validateProductInputStep({
* products: [
@@ -71,7 +82,7 @@ const validateProductInputStepId = "validate-product-input"
export const validateProductInputStep = createStep(
validateProductInputStepId,
async (data: ValidateProductInputStepInput) => {
const { products } = data
const { products, shippingProfiles } = data
const missingOptionsProductTitles = products
.filter((product) => !product.options?.length)
@@ -85,6 +96,25 @@ 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(
", "
)}].`
)
}
}
)
@@ -102,11 +132,11 @@ export const createProductsWorkflowId = "create-products"
/**
* This workflow creates one or more products. It's used by the [Create Product Admin API Route](https://docs.medusajs.com/api/admin#products_postproducts).
* It can also be useful to you when creating [seed scripts](https://docs.medusajs.com/learn/fundamentals/custom-cli-scripts/seed-data), for example.
*
*
* This workflow has a hook that allows you to perform custom actions on the created products. You can see an example in [this guide](https://docs.medusajs.com/resources/commerce-modules/product/extend).
*
*
* You can also use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around product creation.
*
*
* @example
* const { result } = await createProductsWorkflow(container)
* .run({
@@ -143,26 +173,45 @@ export const createProductsWorkflowId = "create-products"
* }
* }
* })
*
*
* @summary
*
*
* Create one or more products with options and variants.
*
*
* @property hooks.productCreated - This hook is executed after the products are created. You can consume this hook to perform custom actions on the created products.
*/
export const createProductsWorkflow = createWorkflow(
createProductsWorkflowId,
(input: WorkflowData<CreateProductsWorkflowInput>) => {
// Passing prices to the product module will fail, we want to keep them for after the product is created.
const productWithoutExternalRelations = transform({ input }, (data) =>
data.input.products.map((p) => ({
...p,
sales_channels: undefined,
variants: undefined,
}))
)
const { products: productWithoutExternalRelations, shippingPorfileIds } =
transform({ input }, (data) => {
const shippingPorfileIds: string[] = []
const productsData = data.input.products.map((p) => {
if (p.shipping_profile_id) {
shippingPorfileIds.push(p.shipping_profile_id)
}
validateProductInputStep({ products: productWithoutExternalRelations })
return {
...p,
sales_channels: undefined,
shipping_profile_id: undefined,
variants: undefined,
}
})
return { products: productsData, shippingPorfileIds }
})
const { data: shippingProfiles } = useQueryGraphStep({
entity: "shipping_profile",
fields: ["id"],
filters: {
id: shippingPorfileIds,
},
})
validateProductInputStep({ products: input.products, shippingProfiles })
const createdProducts = createProductsStep(productWithoutExternalRelations)
@@ -182,6 +231,24 @@ export const createProductsWorkflow = createWorkflow(
associateProductsWithSalesChannelsStep({ links: salesChannelLinks })
const shippingProfileLinks = transform(
{ input, createdProducts },
(data) => {
return data.createdProducts.map((createdProduct, i) => {
return {
[Modules.PRODUCT]: {
product_id: createdProduct.id,
},
[Modules.FULFILLMENT]: {
shipping_profile_id: data.input.products[i].shipping_profile_id,
},
}
})
}
)
createRemoteLinkStep(shippingProfileLinks as LinkDefinition[])
const variantsInput = transform({ input, createdProducts }, (data) => {
// TODO: Move this to a unified place for all product workflow types
const productVariants: (ProductTypes.CreateProductVariantDTO & {
@@ -47,6 +47,10 @@ export type UpdateProductsWorkflowInputSelector = {
* The variants to update.
*/
variants?: UpdateProductVariantWorkflowInputDTO[]
/**
* The shipping profile to set.
*/
shipping_profile_id?: string
}
} & AdditionalData
@@ -66,6 +70,10 @@ export type UpdateProductsWorkflowInputProducts = {
* The variants to update.
*/
variants?: UpdateProductVariantWorkflowInputDTO[]
/**
* The shipping profile to set.
*/
shipping_profile_id?: string
})[]
} & AdditionalData
@@ -90,6 +98,7 @@ function prepareUpdateProductInput({
products: input.products.map((p) => ({
...p,
sales_channels: undefined,
shipping_profile_id: undefined,
variants: p.variants?.map((v) => ({
...v,
prices: undefined,
@@ -103,6 +112,7 @@ function prepareUpdateProductInput({
update: {
...input.update,
sales_channels: undefined,
shipping_profile_id: undefined,
variants: input.update?.variants?.map((v) => ({
...v,
prices: undefined,
@@ -173,6 +183,44 @@ function prepareSalesChannelLinks({
return []
}
function prepareShippingProfileLinks({
input,
updatedProducts,
}: {
updatedProducts: ProductTypes.ProductDTO[]
input: UpdateProductWorkflowInput
}): Record<string, Record<string, any>>[] {
if ("products" in input) {
if (!input.products.length) {
return []
}
return input.products
.filter((p) => p.shipping_profile_id)
.map((p) => ({
[Modules.PRODUCT]: {
product_id: p.id,
},
[Modules.FULFILLMENT]: {
shipping_profile_id: p.shipping_profile_id,
},
}))
}
if (input.selector && input.update?.shipping_profile_id) {
return updatedProducts.map((p) => ({
[Modules.PRODUCT]: {
product_id: p.id,
},
[Modules.FULFILLMENT]: {
shipping_profile_id: input.update.shipping_profile_id,
},
}))
}
return []
}
function prepareVariantPrices({
input,
updatedProducts,
@@ -243,18 +291,42 @@ function prepareToDeleteSalesChannelLinks({
}))
}
function prepareToDeleteShippingProfileLinks({
currentShippingProfileLinks,
}: {
currentShippingProfileLinks: {
product_id: string
shipping_profile_id: string
}[]
}) {
if (!currentShippingProfileLinks.length) {
return []
}
return currentShippingProfileLinks.map(
({ product_id, shipping_profile_id }) => ({
[Modules.PRODUCT]: {
product_id,
},
[Modules.FULFILLMENT]: {
shipping_profile_id,
},
})
)
}
export const updateProductsWorkflowId = "update-products"
/**
* This workflow updates one or more products. It's used by the [Update Product Admin API Route](https://docs.medusajs.com/api/admin#products_postproductsid).
*
* This workflow has a hook that allows you to perform custom actions on the updated products. For example, you can pass under `additional_data` custom data that
*
* This workflow has a hook that allows you to perform custom actions on the updated products. For example, you can pass under `additional_data` custom data that
* allows you to update custom data models linked to the products.
*
*
* You can also use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around product update.
*
*
* @example
* To update products by their IDs:
*
*
* ```ts
* const { result } = await updateProductsWorkflow(container)
* .run({
@@ -282,9 +354,9 @@ export const updateProductsWorkflowId = "update-products"
* }
* })
* ```
*
*
* You can also update products by a selector:
*
*
* ```ts
* const { result } = await updateProductsWorkflow(container)
* .run({
@@ -301,11 +373,11 @@ export const updateProductsWorkflowId = "update-products"
* }
* })
* ```
*
*
* @summary
*
*
* Update one or more products with options and variants.
*
*
* @property hooks.productsUpdated - This hook is executed after the products are updated. You can consume this hook to perform custom actions on the updated products.
*/
export const updateProductsWorkflow = createWorkflow(
@@ -345,11 +417,20 @@ export const updateProductsWorkflow = createWorkflow(
const toUpdateInput = transform({ input }, prepareUpdateProductInput)
const updatedProducts = updateProductsStep(toUpdateInput)
const updatedPorductIds = transform({ updatedProducts }, (data) => {
return data.updatedProducts.map((p) => p.id)
})
const salesChannelLinks = transform(
{ input, updatedProducts },
prepareSalesChannelLinks
)
const shippingProfileLinks = transform(
{ input, updatedProducts },
prepareShippingProfileLinks
)
const variantPrices = transform(
{ input, updatedProducts },
prepareVariantPrices
@@ -366,16 +447,33 @@ export const updateProductsWorkflow = createWorkflow(
variables: { filters: { product_id: productsWithSalesChannels } },
}).config({ name: "get-current-sales-channel-links-step" })
const currentShippingProfileLinks = useRemoteQueryStep({
entry_point: "product_shipping_profile",
fields: ["product_id", "shipping_profile_id"],
variables: { filters: { product_id: updatedPorductIds } },
}).config({ name: "get-current-shipping-profile-links-step" })
const toDeleteSalesChannelLinks = transform(
{ currentSalesChannelLinks },
prepareToDeleteSalesChannelLinks
)
const toDeleteShippingProfileLinks = transform(
{ currentShippingProfileLinks },
prepareToDeleteShippingProfileLinks
)
upsertVariantPricesWorkflow.runAsStep({
input: { variantPrices, previousVariantIds },
})
dismissRemoteLinkStep(toDeleteSalesChannelLinks)
dismissRemoteLinkStep(toDeleteSalesChannelLinks).config({
name: "delete-sales-channel-links-step",
})
dismissRemoteLinkStep(toDeleteShippingProfileLinks).config({
name: "delete-shipping-profile-links-step",
})
const productIdEvents = transform(
{ updatedProducts },
@@ -387,7 +485,12 @@ export const updateProductsWorkflow = createWorkflow(
)
parallelize(
createRemoteLinkStep(salesChannelLinks),
createRemoteLinkStep(salesChannelLinks).config({
name: "create-sales-channel-links-step",
}),
createRemoteLinkStep(shippingProfileLinks).config({
name: "create-shipping-profile-links-step",
}),
emitEventStep({
eventName: ProductWorkflowEvents.UPDATED,
data: productIdEvents,
@@ -1,8 +1,31 @@
import { createWorkflow, WorkflowData } from "@medusajs/framework/workflows-sdk"
import {
createStep,
createWorkflow,
WorkflowData,
} from "@medusajs/framework/workflows-sdk"
import { MedusaError, Modules } from "@medusajs/framework/utils"
import { deleteShippingProfilesStep } from "../steps"
import { removeRemoteLinkStep } from "../../common"
import { Modules } from "@medusajs/framework/utils"
import { removeRemoteLinkStep, useQueryGraphStep } from "../../common"
/**
* This step validates that the shipping profiles to delete are not linked to any products.
*/
const validateStepShippingProfileDelete = createStep(
"validate-step-shipping-profile-delete",
(data: { links: { product_id: string; shipping_profile_id: string }[] }) => {
const { links } = data
if (links.length > 0) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Cannot delete following shipping profiles because they are linked to products: ${links
.map((l) => l.product_id)
.join(", ")}`
)
}
}
)
/**
* The data to delete shipping profiles.
@@ -19,10 +42,11 @@ export const deleteShippingProfileWorkflowId =
/**
* This workflow deletes one or more shipping profiles. It's used by the
* [Delete Shipping Profile Admin API Route](https://docs.medusajs.com/api/admin#shipping-profiles_deleteshippingprofilesid).
*
* Shipping profiles that are linked to products cannot be deleted.
*
* You can use this workflow within your customizations or your own custom workflows, allowing you to
* delete shipping profiles within your custom flows.
*
*
* @example
* const { result } = await deleteShippingProfileWorkflow(container)
* .run({
@@ -30,14 +54,24 @@ export const deleteShippingProfileWorkflowId =
* ids: ["sp_123"]
* }
* })
*
*
* @summary
*
*
* Delete shipping profiles.
*/
export const deleteShippingProfileWorkflow = createWorkflow(
deleteShippingProfileWorkflowId,
(input: WorkflowData<DeleteShippingProfilesWorkflowInput>) => {
const currentShippingProfileLinks = useQueryGraphStep({
entity: "product_shipping_profile",
fields: ["product_id", "shipping_profile_id"],
filters: { shipping_profile_id: input.ids },
})
validateStepShippingProfileDelete({
links: currentShippingProfileLinks.data,
})
deleteShippingProfilesStep(input.ids)
removeRemoteLinkStep({
@@ -32,6 +32,11 @@ export interface AdminCreateOrderFulfillment {
* to fulfill the items from.
*/
location_id?: string
/**
* The ID of the shipping option to use for the fulfillment.
* Overrides the shipping option selected by the customer.
*/
shipping_option_id?: string
/**
* Whether to notify the customer about this change.
*/
@@ -5,7 +5,7 @@ export interface AdminExportProductRequest {}
export interface AdminImportProductRequest {
/**
* The CSV file to import the products from.
*
*
* It's an uploaded file of type [File](https://developer.mozilla.org/en-US/docs/Web/API/File).
*/
file: File
@@ -35,7 +35,7 @@ export interface AdminBatchProductVariantInventoryItemRequest
export interface AdminCreateProductVariantPrice {
/**
* The price's currency code.
*
*
* @example
* usd
*/
@@ -54,11 +54,11 @@ export interface AdminCreateProductVariantPrice {
max_quantity?: number | null
/**
* The price's rules.
*
*
* @privateRemarks
* Note: Although the BE is generic, we only use region_id for price rules for now, so it's better to keep the typings stricter.
*/
rules?: {
rules?: {
/**
* The ID of the region that the price applies in.
*/
@@ -180,11 +180,11 @@ export interface AdminCreateProduct {
/**
* The product's images.
*/
images?: {
images?: {
/**
* The image's URL.
*/
url: string
url: string
}[]
/**
* The product's thumbnail URL.
@@ -210,10 +210,14 @@ export interface AdminCreateProduct {
* The ID of the product's collection.
*/
collection_id?: string
/**
* The ID of the product's shipping profile.
*/
shipping_profile_id: string
/**
* The product's categories.
*/
categories?: {
categories?: {
/**
* The ID of a product category that the product belongs to.
*/
@@ -222,7 +226,7 @@ export interface AdminCreateProduct {
/**
* The product's tags.
*/
tags?: {
tags?: {
/**
* The ID of the associated product tag.
*/
@@ -239,7 +243,7 @@ export interface AdminCreateProduct {
/**
* The sales channels that the product is available in.
*/
sales_channels?: {
sales_channels?: {
/**
* The ID of a sales channel that the product is available in.
*/
@@ -363,7 +367,8 @@ export interface AdminUpdateProductVariant {
options?: Record<string, string>
}
export interface AdminBatchUpdateProductVariant extends AdminUpdateProductVariant {
export interface AdminBatchUpdateProductVariant
extends AdminUpdateProductVariant {
/**
* The ID of the variant to update.
*/
@@ -394,7 +399,7 @@ export interface AdminUpdateProduct {
/**
* The product's images.
*/
images?: {
images?: {
/**
* The image's URL.
*/
@@ -427,20 +432,20 @@ export interface AdminUpdateProduct {
/**
* The product's categories.
*/
categories?: {
categories?: {
/**
* The ID of the category that the product belongs to.
*/
id: string
id: string
}[]
/**
* The product's tags.
*/
tags?: {
tags?: {
/**
* The ID of a tag that the product is associated with.
*/
id: string
id: string
}[]
/**
* The product's options.
@@ -453,12 +458,16 @@ export interface AdminUpdateProduct {
/**
* The sales channels that the product is available in.
*/
sales_channels?: {
sales_channels?: {
/**
* The ID of a sales channel that the product is available in.
*/
id: string
id: string
}[]
/**
* The ID of the product's shipping profile.
*/
shipping_profile_id?: string
/**
* The product's weight.
*/
@@ -62,6 +62,11 @@ export interface CreateOrderFulfillmentWorkflowInput {
*/
metadata?: Record<string, any> | null
/**
* Shipping option to be used for the fulfillment.
*/
shipping_option_id?: string
/**
* Whether the fulfillment should be shipped.
*/
@@ -33,6 +33,10 @@ export type CreateProductWorkflowInputDTO = Omit<
* The sales channels that the product is available in.
*/
sales_channels?: { id: string }[]
/**
* The product's shipping profile.
*/
shipping_profile_id: string
/**
* The product's variants.
*/
@@ -41,4 +45,5 @@ export type CreateProductWorkflowInputDTO = Omit<
export type UpdateProductWorkflowInputDTO = ProductTypes.UpsertProductDTO & {
sales_channels?: { id: string }[]
shipping_profile_id?: string
}
+6
View File
@@ -116,4 +116,10 @@ export const LINKS = {
Modules.FULFILLMENT,
"fulfillment_id"
),
ProductShippingProfile: composeLinkName(
Modules.PRODUCT,
"product_id",
Modules.FULFILLMENT,
"shipping_profile_id"
),
}
@@ -75,6 +75,7 @@ export type AdminOrderCreateFulfillmentType = z.infer<
export const OrderCreateFulfillment = z.object({
items: z.array(Item),
location_id: z.string().nullish(),
shipping_option_id: z.string().optional(),
no_notification: z.boolean().optional(),
metadata: z.record(z.unknown()).nullish(),
})
@@ -232,6 +232,7 @@ export const CreateProduct = z
options: z.array(CreateProductOption).optional(),
variants: z.array(CreateProductVariant).optional(),
sales_channels: z.array(z.object({ id: z.string() })).optional(),
shipping_profile_id: z.string(),
weight: z.number().nullish(),
length: z.number().nullish(),
height: z.number().nullish(),
@@ -266,6 +267,7 @@ export const UpdateProduct = z
categories: z.array(IdAssociation).optional(),
tags: z.array(IdAssociation).optional(),
sales_channels: z.array(z.object({ id: z.string() })).optional(),
shipping_profile_id: z.string().optional(),
weight: z.number().nullish(),
length: z.number().nullish(),
height: z.number().nullish(),
@@ -0,0 +1,88 @@
import {
createRemoteLinkStep,
createShippingProfilesStep,
useQueryGraphStep,
} from "@medusajs/core-flows"
import { ExecArgs } from "@medusajs/framework/types"
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
import {
transform,
when,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { createWorkflow } from "@medusajs/framework/workflows-sdk"
const assignProductsToShippingProfileWorkflow = createWorkflow(
"assign-products-to-shipping-profile",
() => {
const { data: shippingProfiles } = useQueryGraphStep({
entity: "shipping_profile",
fields: ["id", "name"],
}).config({ name: "get-shipping-profiles" })
const { data: products } = useQueryGraphStep({
entity: "product",
fields: ["id"],
}).config({ name: "get-products" })
const shippingProfileId = transform(
{ shippingProfiles },
({ shippingProfiles }) =>
shippingProfiles.find((sp) =>
sp.name.toLocaleLowerCase().includes("default")
)?.id ?? shippingProfiles[0]?.id
)
const createdShippingProfileId = when(
"create-shipping-profile",
{
shippingProfileId,
},
({ shippingProfileId }) => !shippingProfileId
).then(() => {
const createdShippingProfiles = createShippingProfilesStep([
{
name: "Default Shipping Profile",
type: "default",
},
])
return createdShippingProfiles[0].id
})
const links = transform(
{ products, shippingProfileId, createdShippingProfileId },
({ products, shippingProfileId, createdShippingProfileId }) => {
return products.map((product) => ({
[Modules.PRODUCT]: {
product_id: product.id,
},
[Modules.FULFILLMENT]: {
shipping_profile_id: shippingProfileId ?? createdShippingProfileId,
},
}))
}
)
createRemoteLinkStep(links)
return new WorkflowResponse(void 0)
}
)
export default async function assignProductsToShippingProfile({
container,
}: ExecArgs) {
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
logger.info("Assigning products to shipping profile")
await assignProductsToShippingProfileWorkflow(container)
.run()
.then(() => {
logger.info("Products assigned to shipping profile")
})
.catch((e) => {
logger.error(e)
})
}
@@ -15,3 +15,4 @@ export * from "./readonly"
export * from "./region-payment-provider"
export * from "./sales-channel-location"
export * from "./shipping-option-price-set"
export * from "./product-shipping-profile"
@@ -0,0 +1,72 @@
import { ModuleJoinerConfig } from "@medusajs/framework/types"
import { LINKS, Modules } from "@medusajs/framework/utils"
export const ProductShippingProfile: ModuleJoinerConfig = {
serviceName: LINKS.ProductShippingProfile,
isLink: true,
databaseConfig: {
tableName: "product_shipping_profile",
idPrefix: "prodsp",
},
alias: [
{
name: "product_shipping_profile",
},
{
name: "product_shipping_profiles",
},
],
primaryKeys: ["id", "product_id", "shipping_profile_id"],
relationships: [
{
serviceName: Modules.PRODUCT,
entity: "Product",
primaryKey: "id",
foreignKey: "product_id",
alias: "product",
args: {
methodSuffix: "Products",
},
},
{
serviceName: Modules.FULFILLMENT,
entity: "ShippingProfile",
primaryKey: "id",
foreignKey: "shipping_profile_id",
alias: "shipping_profile",
args: {
methodSuffix: "ShippingProfiles",
},
},
],
extends: [
{
serviceName: Modules.PRODUCT,
entity: "Product",
fieldAlias: {
shipping_profile: {
path: "shipping_profiles_link.shipping_profile",
isList: false,
},
},
relationship: {
serviceName: LINKS.ProductShippingProfile,
primaryKey: "product_id",
foreignKey: "id",
alias: "shipping_profiles_link",
isList: false,
},
},
{
serviceName: Modules.FULFILLMENT,
entity: "ShippingProfile",
relationship: {
serviceName: LINKS.ProductShippingProfile,
primaryKey: "shipping_profile_id",
foreignKey: "id",
alias: "products_link",
isList: true,
},
},
],
}