fix(dashboard): improve inventory level location management (#13589)

**What**
- add InfiniteList on location selection for inventory level -> fixes issue with location pagination
- fix removal of location level for an inventory item
- refresh the levels table when locations are updated
- add search input for filtering locations

---

CLOSES CORE-1208
This commit is contained in:
Frane Polić
2025-09-24 18:00:45 +00:00
committed by GitHub
parent edf29b3bd2
commit 7af9e3224c
9 changed files with 193 additions and 152 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/dashboard": patch
---
fix(dashboard): improve inventory level location management
@@ -241,6 +241,9 @@ export const useBatchInventoryItemLocationLevels = (
queryClient.invalidateQueries({
queryKey: inventoryItemLevelsQueryKeys.detail(inventoryItemId),
})
queryClient.invalidateQueries({
queryKey: inventoryItemLevelsQueryKeys.list({ inventoryItemId }),
})
options?.onSuccess?.(data, variables, context)
},
...options,
@@ -3111,6 +3111,9 @@
"quantityAcrossLocations": {
"type": "string"
},
"levelDeleted": {
"type": "string"
},
"create": {
"type": "object",
"properties": {
@@ -3327,6 +3330,7 @@
"deleteWarning",
"editItemDetails",
"quantityAcrossLocations",
"levelDeleted",
"create",
"reservation",
"adjustInventory",
@@ -832,6 +832,7 @@
"deleteWarning": "You are about to delete an inventory item. This action cannot be undone.",
"editItemDetails": "Edit item details",
"quantityAcrossLocations": "{{quantity}} across {{locations}} locations",
"levelDeleted": "Inventory level deleted successfully.",
"create": {
"title": "Create Inventory Item",
"details": "Details",
@@ -49,9 +49,16 @@ export const useLocationListTableColumns = () => {
level.location_id
)
toast.success(t("inventory.levelDeleted"))
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: inventoryItemLevelsQueryKeys.list({
inventoryItemId: level.inventory_item_id,
}),
})
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.detail(level.inventory_item_id),
})
@@ -1,11 +1,11 @@
import { Checkbox, Text, clx } from "@medusajs/ui"
import { StockLocationDTO } from "@medusajs/types"
import { HttpTypes } from "@medusajs/types"
type LocationItemProps = {
selected: boolean
onSelect: (selected: boolean) => void
location: StockLocationDTO
location: HttpTypes.AdminStockLocation
}
export const LocationItem = ({
@@ -0,0 +1,34 @@
import { Input } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useState, useEffect } from "react"
type LocationSearchInputProps = {
onSearchChange: (search: string) => void
placeholder?: string
}
export const LocationSearchInput = ({
onSearchChange,
placeholder,
}: LocationSearchInputProps) => {
const { t } = useTranslation()
const [searchValue, setSearchValue] = useState("")
useEffect(() => {
const timer = setTimeout(() => {
onSearchChange(searchValue)
}, 300)
return () => clearTimeout(timer)
}, [searchValue, onSearchChange])
return (
<Input
type="text"
placeholder={placeholder || t("general.search")}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="w-full"
/>
)
}
@@ -1,111 +1,75 @@
import * as zod from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { AdminInventoryItem, AdminStockLocation } from "@medusajs/types"
import {
AdminInventoryItem,
AdminStockLocation,
HttpTypes,
} from "@medusajs/types"
import { Button, Text, toast } from "@medusajs/ui"
import { useFieldArray, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { RouteDrawer, useRouteModal } from "../../../../../../components/modals"
import { useBatchInventoryItemLocationLevels } from "../../../../../../hooks/api/inventory"
import { sdk } from "../../../../../../lib/client"
import { useEffect, useMemo } from "react"
import { KeyboundForm } from "../../../../../../components/utilities/keybound-form"
import { useMemo, useState } from "react"
import { LocationItem } from "./location-item"
import { LocationSearchInput } from "./location-search-input"
import { InfiniteList } from "../../../../../../components/common/infinite-list/infinite-list"
import { useStockLocations } from "../../../../../../hooks/api/stock-locations"
type EditInventoryItemAttributeFormProps = {
item: AdminInventoryItem
locations: AdminStockLocation[]
}
const EditInventoryItemAttributesSchema = z.object({
locations: z.array(
z.object({
id: z.string(),
location_id: z.string(),
selected: z.boolean(),
})
),
})
const getDefaultValues = (
allLocations: AdminStockLocation[],
existingLevels: Set<string>
) => {
return {
locations: allLocations.map((location) => ({
...location,
location_id: location.id,
selected: existingLevels.has(location.id),
})),
}
}
export const ManageLocationsForm = ({
item,
locations,
}: EditInventoryItemAttributeFormProps) => {
const existingLocationLevels = useMemo(
() => new Set(item.location_levels?.map((l) => l.location_id) ?? []),
item.location_levels
[item.location_levels]
)
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const [searchQuery, setSearchQuery] = useState("")
const [selectedLocationIds, setSelectedLocationIds] = useState<Set<string>>(
existingLocationLevels
)
const form = useForm<zod.infer<typeof EditInventoryItemAttributesSchema>>({
defaultValues: getDefaultValues(locations, existingLocationLevels),
resolver: zodResolver(EditInventoryItemAttributesSchema),
})
const { count } = useStockLocations({ limit: 1, fields: "id" })
const { fields: locationFields, update: updateField } = useFieldArray({
control: form.control,
name: "locations",
})
useEffect(() => {
form.setValue(
"locations",
getDefaultValues(locations, existingLocationLevels).locations
)
}, [existingLocationLevels, locations])
const handleLocationSelect = (locationId: string, selected: boolean) => {
setSelectedLocationIds((prev) => {
const newSet = new Set(prev)
if (selected) {
newSet.add(locationId)
} else {
newSet.delete(locationId)
}
return newSet
})
}
const { mutateAsync } = useBatchInventoryItemLocationLevels(item.id)
const handleSubmit = form.handleSubmit(async ({ locations }) => {
// Changes in selected locations
const [selectedLocations, unselectedLocations] = locations.reduce(
(acc, location) => {
// If the location is not changed do nothing
if (
(!location.selected &&
!existingLocationLevels.has(location.location_id)) ||
(location.selected &&
existingLocationLevels.has(location.location_id))
) {
return acc
}
if (location.selected) {
acc[0].push(location.location_id)
} else {
acc[1].push(location.location_id)
}
return acc
},
[[], []] as [string[], string[]]
const handleSubmit = async () => {
const toCreate = Array.from(selectedLocationIds).filter(
(id) => !existingLocationLevels.has(id)
)
if (selectedLocations.length === 0 && unselectedLocations.length === 0) {
return handleSuccess()
}
const toDeleteLocations = Array.from(existingLocationLevels).filter(
(id) => !selectedLocationIds.has(id)
)
const toDelete = toDeleteLocations
.map((id) => item.location_levels?.find((l) => l.location_id === id)?.id)
.filter(Boolean) as unknown as string[]
await mutateAsync(
{
create: selectedLocations.map((location_id) => ({
create: toCreate.map((location_id) => ({
location_id,
})),
delete: unselectedLocations,
delete: toDelete,
},
{
onSuccess: () => {
@@ -117,80 +81,103 @@ export const ManageLocationsForm = ({
},
}
)
})
}
return (
<RouteDrawer.Form form={form}>
<KeyboundForm
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-4 overflow-auto">
<div className="text-ui-fg-subtle shadow-elevation-card-rest grid grid-rows-2 divide-y rounded-lg border">
<div className="grid grid-cols-2 divide-x">
<Text className="px-2 py-1.5" size="small" leading="compact">
{t("fields.title")}
</Text>
<Text className="px-2 py-1.5" size="small" leading="compact">
{item.title ?? "-"}
</Text>
</div>
<div className="grid grid-cols-2 divide-x">
<Text className="px-2 py-1.5" size="small" leading="compact">
{t("fields.sku")}
</Text>
<Text className="px-2 py-1.5" size="small" leading="compact">
{item.sku}
</Text>
</div>
</div>
<div className="flex flex-col">
<Text size="small" weight="plus" leading="compact">
{t("locations.domain")}
<div className="flex flex-1 flex-col overflow-hidden">
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-4 overflow-auto">
<div className="text-ui-fg-subtle shadow-elevation-card-rest grid grid-rows-2 divide-y rounded-lg border">
<div className="grid grid-cols-2 divide-x">
<Text className="px-2 py-1.5" size="small" leading="compact">
{t("fields.title")}
</Text>
<Text className="px-2 py-1.5" size="small" leading="compact">
{item.title ?? "-"}
</Text>
<div className="text-ui-fg-subtle flex w-full justify-between">
<Text size="small" leading="compact">
{t("locations.selectLocations")}
</Text>
<Text size="small" leading="compact">
{"("}
{t("general.countOfTotalSelected", {
count: locationFields.filter((l) => l.selected).length,
total: locations.length,
})}
{")"}
</Text>
</div>
</div>
{locationFields.map((location, idx) => {
return (
<div className="grid grid-cols-2 divide-x">
<Text className="px-2 py-1.5" size="small" leading="compact">
{t("fields.sku")}
</Text>
<Text className="px-2 py-1.5" size="small" leading="compact">
{item.sku}
</Text>
</div>
</div>
<div className="flex flex-col">
<Text size="small" weight="plus" leading="compact">
{t("locations.domain")}
</Text>
<div className="text-ui-fg-subtle flex w-full justify-between">
<Text size="small" leading="compact">
{t("locations.selectLocations")}
</Text>
<Text size="small" leading="compact">
{"("}
{t("general.countOfTotalSelected", {
count: selectedLocationIds.size,
total: count,
})}
{")"}
</Text>
</div>
</div>
<LocationSearchInput
onSearchChange={setSearchQuery}
placeholder={t("general.search")}
/>
<div className="min-h-0 flex-1">
<InfiniteList<
HttpTypes.AdminStockLocationListResponse,
HttpTypes.AdminStockLocation,
HttpTypes.AdminStockLocationListParams
>
queryKey={["stock-locations", searchQuery]}
queryFn={async (params) => {
const response = await sdk.admin.stockLocation.list({
limit: params.limit,
offset: params.offset,
...(searchQuery && { q: searchQuery }),
})
return response
}}
responseKey="stock_locations"
renderItem={(location) => (
<LocationItem
selected={location.selected}
location={location as any}
onSelect={() =>
updateField(idx, {
...location,
selected: !location.selected,
})
selected={selectedLocationIds.has(location.id)}
location={location}
onSelect={(selected) =>
handleLocationSelect(location.id, selected)
}
key={location.id}
/>
)
})}
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button type="submit" size="small" isLoading={false}>
{t("actions.save")}
)}
renderEmpty={() => (
<div className="flex items-center justify-center py-8">
<Text size="small" className="text-ui-fg-subtle">
{searchQuery
? t("locations.noLocationsFound")
: t("locations.noLocationsFound")}
</Text>
</div>
)}
pageSize={20}
/>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</div>
</RouteDrawer.Footer>
</KeyboundForm>
</RouteDrawer.Form>
</RouteDrawer.Close>
<Button onClick={handleSubmit} size="small" isLoading={false}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</div>
)
}
@@ -33,14 +33,14 @@ export type ValidateInventoryLevelsDeleteStepInput = {
* inventory levels have reserved or incoming items, or the force
* flag is not set and the inventory levels have stocked items, the
* step will throw an error.
*
*
* :::note
*
*
* You can retrieve an inventory level'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 = validateInventoryLevelsDelete({
* inventoryLevels: [
@@ -108,10 +108,10 @@ export const deleteInventoryLevelsWorkflowId =
/**
* This workflow deletes one or more inventory levels. It's used by the
* [Delete Inventory Levels Admin API Route](https://docs.medusajs.com/api/admin#inventory-items_deleteinventoryitemsidlocationlevelslocation_id).
*
*
* You can use this workflow within your own customizations or custom workflows, allowing you
* to delete inventory levels in your custom flows.
*
*
* @example
* const { result } = await deleteInventoryLevelsWorkflow(container)
* .run({
@@ -119,9 +119,9 @@ export const deleteInventoryLevelsWorkflowId =
* id: ["iilev_123", "iilev_321"],
* }
* })
*
*
* @summary
*
*
* Delete one or more inventory levels.
*/
export const deleteInventoryLevelsWorkflow = createWorkflow(