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ć
2025-01-27 12:00:20 +00:00
committed by GitHub
parent 3e81962503
commit 864d772e34
78 changed files with 3529 additions and 794 deletions
@@ -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 dont 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>
)
}