feat(medusa, admin-ui, medusa-react, medusa-js): Allow toggling of manage inventory (#3435)

**What**
- Toggle manage inventory in the inventory management modal

**How**
- Create/update/remove inventory item based on if `manage_inventory` is set and if an inventory item already exists
- Move all stock location updates to when the modal is submitted
- Add create-inventory-item endpoint in the core

Fixes CORE-1196

Co-authored-by: Sebastian Rindom <7554214+srindom@users.noreply.github.com>
This commit is contained in:
Philip Korsholm
2023-03-14 16:14:31 +00:00
committed by GitHub
co-authored by Sebastian Rindom
parent 30a3203640
commit fe9eea4c18
18 changed files with 844 additions and 219 deletions
@@ -1,14 +1,15 @@
import { Discount } from "@medusajs/medusa"
import { parse } from "iso8601-duration"
import { useAdminUpdateDiscount } from "medusa-react"
import moment from "moment"
import { ReactNode } from "react"
import ClockIcon from "../../../../components/fundamentals/icons/clock-icon"
import TrashIcon from "../../../../components/fundamentals/icons/trash-icon"
import { ActionType } from "../../../../components/molecules/actionables"
import useNotification from "../../../../hooks/use-notification"
import ClockIcon from "../../../../components/fundamentals/icons/clock-icon"
import { Discount } from "@medusajs/medusa"
import TrashIcon from "../../../../components/fundamentals/icons/trash-icon"
import { getErrorMessage } from "../../../../utils/error-messages"
import moment from "moment"
import { parse } from "iso8601-duration"
import { removeNullish } from "../../../../utils/remove-nullish"
import { useAdminUpdateDiscount } from "medusa-react"
import useNotification from "../../../../hooks/use-notification"
type displaySetting = {
title: string
@@ -4,21 +4,24 @@ import {
Order,
StockLocationDTO,
} from "@medusajs/medusa"
import { useAdminStockLocations } from "medusa-react"
import { useAdminRequestReturn, useAdminShippingOptions } from "medusa-react"
import {
useAdminRequestReturn,
useAdminShippingOptions,
useAdminStockLocations,
} from "medusa-react"
import React, { useContext, useEffect, useState } from "react"
import Spinner from "../../../../components/atoms/spinner"
import LayeredModal, {
LayeredModalContext,
} from "../../../../components/molecules/modal/layered-modal"
import Button from "../../../../components/fundamentals/button"
import CheckIcon from "../../../../components/fundamentals/icons/check-icon"
import EditIcon from "../../../../components/fundamentals/icons/edit-icon"
import IconTooltip from "../../../../components/molecules/icon-tooltip"
import Modal from "../../../../components/molecules/modal"
import LayeredModal, {
LayeredModalContext,
} from "../../../../components/molecules/modal/layered-modal"
import RMAShippingPrice from "../../../../components/molecules/rma-select-shipping"
import Select from "../../../../components/molecules/select/next-select/select"
// import Select from "../../../../components/molecules/select"
import CurrencyInput from "../../../../components/organisms/currency-input"
import RMASelectProductTable from "../../../../components/organisms/rma-select-product-table"
import useNotification from "../../../../hooks/use-notification"
@@ -10,9 +10,7 @@ export type EditFlowVariantFormType = {
type Props = {
form: UseFormReturn<EditFlowVariantFormType, any>
locationLevels: InventoryLevelDTO[]
refetchInventory: () => void
isLoading: boolean
itemId: string
}
/**
@@ -34,13 +32,7 @@ type Props = {
* )
* }
*/
const EditFlowVariantForm = ({
form,
isLoading,
locationLevels,
refetchInventory,
itemId,
}: Props) => {
const EditFlowVariantForm = ({ form, isLoading, locationLevels }: Props) => {
if (isLoading) {
return null
}
@@ -49,8 +41,6 @@ const EditFlowVariantForm = ({
<>
<VariantStockForm
locationLevels={locationLevels}
refetchInventory={refetchInventory}
itemId={itemId}
form={nestedForm(form, "stock")}
/>
</>
@@ -1,13 +1,9 @@
import React, { useMemo, useState, useContext } from "react"
import Modal from "../../../../../components/molecules/modal"
import { LayeredModalContext } from "../../../../../components/molecules/modal/layered-modal"
import {
useAdminCreateLocationLevel,
useAdminDeleteLocationLevel,
useAdminStockLocations,
} from "medusa-react"
import { useAdminStockLocations } from "medusa-react"
import { InventoryLevelDTO, StockLocationDTO } from "@medusajs/medusa"
import { Controller } from "react-hook-form"
import { Controller, useFieldArray } from "react-hook-form"
import Button from "../../../../../components/fundamentals/button"
import Switch from "../../../../../components/atoms/switch"
import InputField from "../../../../../components/molecules/input"
@@ -16,55 +12,60 @@ import IconBadge from "../../../../../components/fundamentals/icon-badge"
import BuildingsIcon from "../../../../../components/fundamentals/icons/buildings-icon"
export type VariantStockFormType = {
manage_inventory: boolean
manage_inventory?: boolean
allow_backorder: boolean
inventory_quantity: number | null
sku: string | null
ean: string | null
upc: string | null
barcode: string | null
location_levels: InventoryLevelDTO[] | null
location_levels?: Partial<InventoryLevelDTO>[] | null
}
type Props = {
itemId: string
locationLevels: InventoryLevelDTO[]
refetchInventory: () => void
form: NestedForm<VariantStockFormType>
}
const VariantStockForm = ({
form,
locationLevels,
refetchInventory,
itemId,
}: Props) => {
const VariantStockForm = ({ form, locationLevels }: Props) => {
const locationLevelMap = useMemo(
() => new Map(locationLevels.map((l) => [l.location_id, l])),
[locationLevels]
)
const layeredModalContext = useContext(LayeredModalContext)
const { stock_locations: locations, isLoading } = useAdminStockLocations()
const deleteLevel = useAdminDeleteLocationLevel(itemId)
const createLevel = useAdminCreateLocationLevel(itemId)
const { path, control, register, watch } = form
const { path, control, register } = form
const manageInventory = watch(path("manage_inventory"))
const handleUpdateLocations = async (value) => {
await Promise.all(
value.removed.map(async (id) => {
await deleteLevel.mutateAsync(id)
})
const {
fields: selectedLocations,
append,
remove,
} = useFieldArray({
control,
name: path("location_levels"),
})
const handleUpdateLocations = async (data: {
added: string[]
removed: string[]
}) => {
const removed = data.removed.map((r) =>
selectedLocations.findIndex((sl) => sl.location_id === r)
)
await Promise.all(
value.added.map(async (id) => {
await createLevel.mutateAsync({
stocked_quantity: 0,
location_id: id,
})
})
)
removed.forEach((r) => remove(r))
refetchInventory()
data.added.forEach((added) => {
append({
location_id: added,
stocked_quantity: locationLevelMap.get(added)?.stocked_quantity ?? 0,
})
})
}
return (
@@ -111,88 +112,95 @@ const VariantStockForm = ({
returns are made.
</p>
</div>
<div className="gap-y-2xsmall flex flex-col">
<div className="flex items-center justify-between">
<h3 className="inter-base-semibold mb-2xsmall">Allow backorders</h3>
<Controller
control={control}
name={path("allow_backorder")}
render={({ field: { value, onChange } }) => {
return <Switch checked={value} onCheckedChange={onChange} />
}}
/>
</div>
<p className="inter-base-regular text-grey-50">
When checked the product will be available for purchase despite the
product being sold out
</p>
</div>
<div className="flex w-full flex-col text-base">
<h3 className="inter-base-semibold mb-2xsmall">Quantity</h3>
{!isLoading && locations && (
<div className="flex w-full flex-col">
<div className="inter-base-regular text-grey-50 flex justify-between py-3">
<div className="">Location</div>
<div className="">In Stock</div>
{manageInventory && (
<>
<div className="gap-y-2xsmall flex flex-col">
<div className="flex items-center justify-between">
<h3 className="inter-base-semibold mb-2xsmall">
Allow backorders
</h3>
<Controller
control={control}
name={path("allow_backorder")}
render={({ field: { value, onChange } }) => {
return <Switch checked={value} onCheckedChange={onChange} />
}}
/>
</div>
{locationLevels.map((level, i) => {
const locationDetails = locations.find(
(l) => l.id === level.location_id
)
return (
<div key={level.id} className="flex items-center py-3">
<div className="inter-base-regular flex items-center">
<IconBadge className="mr-base">
<BuildingsIcon />
</IconBadge>
{locationDetails?.name}
</div>
<div className="ml-auto flex">
<div className="mr-base text-small text-grey-50 flex flex-col">
<span className="whitespace-nowrap text-right">
{`${level.reserved_quantity} reserved`}
</span>
<span className="whitespace-nowrap text-right">{`${
level.stocked_quantity - level.reserved_quantity
} available`}</span>
</div>
<InputField
placeholder={"0"}
type="number"
{...register(
path(`location_levels.${i}.stocked_quantity`),
{ valueAsNumber: true }
)}
/>
</div>
</div>
)
})}
<p className="inter-base-regular text-grey-50">
When checked the product will be available for purchase despite
the product being sold out
</p>
</div>
)}
</div>
<div className="flex">
<Button
variant="secondary"
size="small"
type="button"
className="w-full"
onClick={() => {
layeredModalContext.push(
// @ts-ignore
ManageLocationsScreen(
layeredModalContext.pop,
locationLevels,
locations,
handleUpdateLocations
)
)
}}
>
Manage locations
</Button>
</div>
<div className="flex w-full flex-col text-base">
<h3 className="inter-base-semibold mb-2xsmall">Quantity</h3>
{!isLoading && locations && (
<div className="flex w-full flex-col">
<div className="inter-base-regular text-grey-50 flex justify-between py-3">
<div className="">Location</div>
<div className="">In Stock</div>
</div>
{selectedLocations.map((level, i) => {
console.log(level)
const locationDetails = locations.find(
(l: StockLocationDTO) => l.id === level.location_id
)
return (
<div key={level.id} className="flex items-center py-3">
<div className="inter-base-regular flex items-center">
<IconBadge className="mr-base">
<BuildingsIcon />
</IconBadge>
{locationDetails?.name}
</div>
<div className="ml-auto flex">
<div className="mr-base text-small text-grey-50 flex flex-col">
<span className="whitespace-nowrap text-right">
{`${level.reserved_quantity} reserved`}
</span>
<span className="whitespace-nowrap text-right">{`${
level.stocked_quantity - level.reserved_quantity
} available`}</span>
</div>
<InputField
placeholder={"0"}
type="number"
{...register(
path(`location_levels.${i}.stocked_quantity`),
{ valueAsNumber: true }
)}
/>
</div>
</div>
)
})}
</div>
)}
</div>
<div className="flex">
<Button
variant="secondary"
size="small"
type="button"
className="w-full"
onClick={() => {
layeredModalContext.push(
// @ts-ignore
ManageLocationsScreen(
layeredModalContext.pop,
selectedLocations,
locations,
handleUpdateLocations
)
)
}}
>
Manage locations
</Button>
</div>
</>
)}
</div>
</div>
)
@@ -200,7 +208,7 @@ const VariantStockForm = ({
export const ManageLocationsScreen = (
pop: () => void,
levels: InventoryLevelDTO[],
levels: Partial<InventoryLevelDTO>[],
locations: StockLocationDTO[],
onSubmit: (value: any) => Promise<void>
) => {
@@ -218,7 +226,7 @@ export const ManageLocationsScreen = (
}
type ManageLocationFormProps = {
existingLevels: InventoryLevelDTO[]
existingLevels: Partial<InventoryLevelDTO>[]
locationOptions: StockLocationDTO[]
onSubmit: (value: any) => Promise<void>
}
@@ -232,7 +240,7 @@ const ManageLocationsForm = ({
const { pop } = layeredModalContext
const existingLocations = useMemo(() => {
return existingLevels.map((level) => level.location_id)
return existingLevels.map((level) => level.location_id!)
}, [existingLevels])
const [selectedLocations, setSelectedLocations] =
@@ -266,7 +274,7 @@ const ManageLocationsForm = ({
(locationId: string) => !existingLocations.includes(locationId)
)
const removedLevels = existingLocations.filter(
(locationId) => !selectedLocations.includes(locationId)
(locationId) => !!locationId && !selectedLocations.includes(locationId)
)
await onSubmit({
@@ -11,11 +11,12 @@ import {
useAdminUpdateProduct,
useAdminUpdateVariant,
} from "medusa-react"
import { useNavigate } from "react-router-dom"
import useImperativeDialog from "../../../../hooks/use-imperative-dialog"
import useNotification from "../../../../hooks/use-notification"
import { getErrorMessage } from "../../../../utils/error-messages"
import { removeNullish } from "../../../../utils/remove-nullish"
import useImperativeDialog from "../../../../hooks/use-imperative-dialog"
import { useNavigate } from "react-router-dom"
import useNotification from "../../../../hooks/use-notification"
const useEditProductActions = (productId: string) => {
const dialog = useImperativeDialog()
@@ -70,8 +71,11 @@ const useEditProductActions = (productId: string) => {
successMessage = "Variant was updated successfully"
) => {
updateVariant.mutate(
// @ts-ignore - TODO fix type on request
{ variant_id: id, ...removeNullish(payload) },
{
variant_id: id,
...removeNullish(payload),
manage_inventory: payload.manage_inventory,
},
{
onSuccess: () => {
notification("Success", successMessage, "success")
@@ -1,20 +1,24 @@
import { Product, ProductVariant } from "@medusajs/medusa"
import {
useAdminUpdateLocationLevel,
useAdminVariantsInventory,
} from "medusa-react"
import React, { useContext } from "react"
InventoryLevelDTO,
Product,
ProductVariant,
VariantInventory,
} from "@medusajs/medusa"
import { useMedusa } from "medusa-react"
import { useAdminVariantsInventory } from "medusa-react"
import { useContext } from "react"
import { useForm } from "react-hook-form"
import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal"
import LayeredModal, {
LayeredModalContext,
} from "../../../../../components/molecules/modal/layered-modal"
import EditFlowVariantForm, {
EditFlowVariantFormType,
} from "../../../components/variant-inventory-form/edit-flow-variant-form"
import useEditProductActions from "../../hooks/use-edit-product-actions"
import LayeredModal, {
LayeredModalContext,
} from "../../../../../components/molecules/modal/layered-modal"
import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal"
import { createUpdatePayload } from "./edit-variants-modal/edit-variant-screen"
import useEditProductActions from "../../hooks/use-edit-product-actions"
import { removeNullish } from "../../../../../utils/remove-nullish"
type Props = {
onClose: () => void
@@ -24,6 +28,7 @@ type Props = {
}
const EditVariantInventoryModal = ({ onClose, product, variant }: Props) => {
const { client } = useMedusa()
const layeredModalContext = useContext(LayeredModalContext)
const {
// @ts-ignore
@@ -32,31 +37,94 @@ const EditVariantInventoryModal = ({ onClose, product, variant }: Props) => {
refetch,
} = useAdminVariantsInventory(variant.id)
const itemId = variantInventory?.inventory[0]?.id
const variantInventoryItem = variantInventory?.inventory[0]
const itemId = variantInventoryItem?.id
const { mutate: updateLocationLevel } = useAdminUpdateLocationLevel(
itemId || ""
)
const handleClose = () => {
onClose()
}
const { onUpdateVariant, updatingVariant } = useEditProductActions(product.id)
const onSubmit = async (data) => {
const { location_levels } = data.stock
await Promise.all(
location_levels.map(async (level) => {
await updateLocationLevel({
stockLocationId: level.location_id,
stocked_quantity: level.stocked_quantity,
})
})
)
// / TODO: Call update location level with new values
const onSubmit = async (data: EditFlowVariantFormType) => {
const locationLevels = data.stock.location_levels || []
const manageInventory = data.stock.manage_inventory
delete data.stock.manage_inventory
delete data.stock.location_levels
let inventoryItemId: string | undefined = itemId
const upsertPayload = removeNullish(data.stock)
if (variantInventoryItem) {
// variant inventory exists and we can remove location levels
// (it's important to do this before potentially deleting the inventory item)
const deleteLocations = manageInventory
? variantInventoryItem?.location_levels?.filter(
(itemLevel: InventoryLevelDTO) => {
return !locationLevels.find(
(level) => level.location_id === itemLevel.location_id
)
}
) ?? []
: []
if (inventoryItemId) {
await Promise.all(
deleteLocations.map(async (location: InventoryLevelDTO) => {
await client.admin.inventoryItems.deleteLocationLevel(
inventoryItemId!,
location.id
)
})
)
}
if (!manageInventory) {
// has an inventory item but no longer wants to manage inventory
await client.admin.inventoryItems.delete(itemId!)
inventoryItemId = undefined
} else {
// has an inventory item and wants to update inventory
await client.admin.inventoryItems.update(itemId!, upsertPayload)
}
} else if (manageInventory) {
// does not have an inventory item but wants to manage inventory
const { inventory_item } = await client.admin.inventoryItems.create({
variant_id: variant.id,
...upsertPayload,
})
inventoryItemId = inventory_item.id
}
// If some inventory Item exists update location levels
if (inventoryItemId) {
await Promise.all(
locationLevels.map(async (level) => {
if (!level.location_id) {
return
}
if (level.id) {
await client.admin.inventoryItems.updateLocationLevel(
inventoryItemId!,
level.location_id,
{
stocked_quantity: level.stocked_quantity,
}
)
} else {
await client.admin.inventoryItems.createLocationLevel(
inventoryItemId!,
{
location_id: level.location_id,
stocked_quantity: level.stocked_quantity!,
}
)
}
})
)
}
// @ts-ignore
onUpdateVariant(variant.id, createUpdatePayload(data), () => {
refetch()
@@ -71,8 +139,7 @@ const EditVariantInventoryModal = ({ onClose, product, variant }: Props) => {
</Modal.Header>
{!isLoadingInventory && (
<StockForm
variantInventory={variantInventory}
refetchInventory={refetch}
variantInventory={variantInventory!}
onSubmit={onSubmit}
isLoadingInventory={isLoadingInventory}
handleClose={handleClose}
@@ -86,10 +153,15 @@ const EditVariantInventoryModal = ({ onClose, product, variant }: Props) => {
const StockForm = ({
variantInventory,
onSubmit,
refetchInventory,
isLoadingInventory,
handleClose,
updatingVariant,
}: {
variantInventory: VariantInventory
onSubmit: (data: EditFlowVariantFormType) => void
isLoadingInventory: boolean
handleClose: () => void
updatingVariant: boolean
}) => {
const form = useForm<EditFlowVariantFormType>({
defaultValues: getEditVariantDefaultValues(variantInventory),
@@ -99,32 +171,20 @@ const StockForm = ({
formState: { isDirty },
handleSubmit,
reset,
watch,
} = form
const locationLevels = watch("stock.location_levels")
const { location_levels } = variantInventory.inventory[0]
React.useEffect(() => {
form.setValue("stock.location_levels", location_levels)
}, [form, location_levels])
const locationLevels = variantInventory.inventory[0]?.location_levels || []
const handleOnSubmit = handleSubmit((data) => {
// @ts-ignore
onSubmit(data)
})
const itemId = variantInventory.inventory[0].id
return (
<form onSubmit={handleOnSubmit} noValidate>
<Modal.Content>
<EditFlowVariantForm
form={form}
refetchInventory={refetchInventory}
locationLevels={locationLevels || []}
itemId={itemId}
locationLevels={locationLevels}
isLoading={isLoadingInventory}
/>
</Modal.Content>
@@ -106,9 +106,9 @@ const VariantsTable = ({ variants, actions }: Props) => {
const getTableRowActionables = (variant: ProductVariant) => {
const inventoryManagementActions = []
if (hasInventoryService && variant.manage_inventory) {
if (hasInventoryService) {
inventoryManagementActions.push({
label: "Manage inventory", // TODO: Only add this item if variant.manageInventory is true
label: "Manage inventory",
icon: <BuildingsIcon size="20" />,
onClick: () => updateVariantInventory(variant),
})