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:
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()
|
||||
|
||||
|
||||
-1
@@ -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(),
|
||||
|
||||
+184
-72
@@ -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>
|
||||
|
||||
+101
-80
@@ -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>
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+18
-5
@@ -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}>
|
||||
|
||||
+38
@@ -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,
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./product-shipping-profile-section"
|
||||
+53
@@ -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>
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./product-shipping-profile-form"
|
||||
+122
@@ -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"
|
||||
+34
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
Reference in new issue
Block a user