Skip to content

Commit

Permalink
feat(inventory,dashboard,types,core-flows,js-sdk,medusa): Improve inv…
Browse files Browse the repository at this point in the history
…entory UX (medusajs#10630)

* feat(dashboard): Add UI for bulk editing inventory stock (medusajs#10556)

* progress

* cleanup types

* add changeset

* fix 0 values

* format schema

* add delete event and allow copy/pasting enabled for some fields

* add response types

* add tests

* work on fixing setValue behaviour

* cleanup toggle logic

* add loading state

* format schema

* add support for bidirectional actions in DataGrid and update Checkbox and RadioGroup

* update lock

* lint

* fix 404

* address feedback

* update cursor on bidirectional select
  • Loading branch information
kasperkristensen authored Jan 13, 2025
1 parent c591545 commit bc22b81
Show file tree
Hide file tree
Showing 82 changed files with 2,719 additions and 288 deletions.
10 changes: 10 additions & 0 deletions .changeset/violet-weeks-pretend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@medusajs/inventory": patch
"@medusajs/dashboard": patch
"@medusajs/core-flows": patch
"@medusajs/js-sdk": patch
"@medusajs/types": patch
"@medusajs/medusa": patch
---

feat(inventory,dashboard,core-flows,js-sdk,types,medusa): Improve inventory management UX
162 changes: 156 additions & 6 deletions integration-tests/http/__tests__/inventory/admin/inventory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ medusaIntegrationTestRunner({
let inventoryItem2
let stockLocation1
let stockLocation2

let stockLocation3
beforeEach(async () => {
await createAdminUser(dbConnection, adminHeaders, getContainer())

Expand All @@ -24,6 +24,10 @@ medusaIntegrationTestRunner({
await api.post(`/admin/stock-locations`, { name: "loc2" }, adminHeaders)
).data.stock_location

stockLocation3 = (
await api.post(`/admin/stock-locations`, { name: "loc3" }, adminHeaders)
).data.stock_location

inventoryItem1 = (
await api.post(
`/admin/inventory-items`,
Expand Down Expand Up @@ -122,24 +126,170 @@ medusaIntegrationTestRunner({
})
})

describe("POST /admin/inventory-items/location-levels/batch", () => {
let locationLevel1
let locationLevel2

beforeEach(async () => {
const seed = await api.post(
`/admin/inventory-items/${inventoryItem1.id}/location-levels/batch`,
{
create: [
{
location_id: stockLocation1.id,
stocked_quantity: 0,
},
{
location_id: stockLocation2.id,
stocked_quantity: 10,
},
],
},
adminHeaders
)

locationLevel1 = seed.data.created[0]
locationLevel2 = seed.data.created[1]
})

it("should batch update the inventory levels", async () => {
const result = await api.post(
`/admin/inventory-items/location-levels/batch`,
{
update: [
{
location_id: stockLocation1.id,
inventory_item_id: inventoryItem1.id,
stocked_quantity: 10,
},
{
location_id: stockLocation2.id,
inventory_item_id: inventoryItem1.id,
stocked_quantity: 20,
},
],
},
adminHeaders
)

expect(result.status).toEqual(200)
expect(result.data).toEqual(
expect.objectContaining({
updated: expect.arrayContaining([
expect.objectContaining({
location_id: stockLocation1.id,
inventory_item_id: inventoryItem1.id,
stocked_quantity: 10,
}),
expect.objectContaining({
location_id: stockLocation2.id,
inventory_item_id: inventoryItem1.id,
stocked_quantity: 20,
}),
]),
})
)
})

it("should batch create the inventory levels", async () => {
const result = await api.post(
`/admin/inventory-items/location-levels/batch`,
{
create: [
{
location_id: stockLocation3.id,
inventory_item_id: inventoryItem1.id,
stocked_quantity: 10,
},
],
},
adminHeaders
)

expect(result.status).toEqual(200)
expect(result.data).toEqual(
expect.objectContaining({
created: expect.arrayContaining([
expect.objectContaining({
location_id: stockLocation3.id,
inventory_item_id: inventoryItem1.id,
stocked_quantity: 10,
}),
]),
})
)
})

it("should batch delete the inventory levels when stocked quantity is 0 and force is false", async () => {
const result = await api.post(
`/admin/inventory-items/location-levels/batch`,
{ delete: [locationLevel1.id] },
adminHeaders
)

expect(result.status).toEqual(200)
expect(result.data).toEqual(
expect.objectContaining({
deleted: [locationLevel1.id],
})
)
})

it("should not delete the inventory levels when stocked quantity is greater than 0 and force is false", async () => {
const error = await api
.post(
`/admin/inventory-items/location-levels/batch`,
{ delete: [locationLevel2.id] },
adminHeaders
)
.catch((e) => e)

expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
type: "not_allowed",
message: `Cannot remove Inventory Levels for ${stockLocation2.id} because there are stocked items at the locations. Use force flag to delete anyway.`,
})
})

it("should delete the inventory levels when stocked quantity is greater than 0 and force is true", async () => {
const result = await api.post(
`/admin/inventory-items/location-levels/batch`,
{ delete: [locationLevel2.id], force: true },
adminHeaders
)

expect(result.status).toEqual(200)
expect(result.data).toEqual(
expect.objectContaining({
deleted: [locationLevel2.id],
})
)
})
})

describe("POST /admin/inventory-items/:id/location-levels/batch", () => {
let locationLevel1

beforeEach(async () => {
await api.post(
const seed = await api.post(
`/admin/inventory-items/${inventoryItem1.id}/location-levels`,
{
location_id: stockLocation1.id,
stocked_quantity: 0,
},
adminHeaders
)

locationLevel1 = seed.data.inventory_item.location_levels[0]
})

it("should delete an inventory location level and create a new one", async () => {
const result = await api.post(
`/admin/inventory-items/${inventoryItem1.id}/location-levels/batch`,
{
create: [{ location_id: "location_2" }],
delete: [stockLocation1.id],
delete: [locationLevel1.id],
force: true,
},
adminHeaders
)
Expand All @@ -154,7 +304,7 @@ medusaIntegrationTestRunner({
expect(levelsListResult.data.inventory_levels).toHaveLength(1)
})

it("should not delete an inventory location level when there is stocked items", async () => {
it("should not delete an inventory location level when there is stocked items without force", async () => {
await api.post(
`/admin/inventory-items/${inventoryItem1.id}/location-levels/${stockLocation1.id}`,
{ stocked_quantity: 10 },
Expand All @@ -164,15 +314,15 @@ medusaIntegrationTestRunner({
const { response } = await api
.post(
`/admin/inventory-items/${inventoryItem1.id}/location-levels/batch`,
{ delete: [stockLocation1.id] },
{ delete: [locationLevel1.id] },
adminHeaders
)
.catch((e) => e)

expect(response.status).toEqual(400)
expect(response.data).toEqual({
type: "not_allowed",
message: `Cannot remove Inventory Levels for ${stockLocation1.id} because there are stocked or reserved items at the locations`,
message: `Cannot remove Inventory Levels for ${stockLocation1.id} because there are stocked items at the locations. Use force flag to delete anyway.`,
})
})

Expand Down
2 changes: 1 addition & 1 deletion packages/admin/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,12 @@
"@uiw/react-json-view": "^2.0.0-alpha.17",
"cmdk": "^0.2.0",
"date-fns": "^3.6.0",
"framer-motion": "^11.0.3",
"i18next": "23.7.11",
"i18next-browser-languagedetector": "7.2.0",
"i18next-http-backend": "2.4.2",
"lodash": "^4.17.21",
"match-sorter": "^6.3.4",
"motion": "^11.15.0",
"qs": "^6.12.0",
"react": "^18.2.0",
"react-country-flag": "^3.1.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { motion } from "framer-motion"
import { motion } from "motion/react"

import { IconAvatar } from "../icon-avatar"

export default function AvatarBox({ checked }: { checked?: boolean }) {
return (
<IconAvatar
size="xlarge"
className="bg-ui-button-neutral shadow-buttons-neutral after:button-neutral-gradient relative mb-4 flex items-center justify-center rounded-xl after:inset-0 after:content-[''] w-[52px] h-[52px]"
className="bg-ui-button-neutral shadow-buttons-neutral after:button-neutral-gradient relative mb-4 flex h-[52px] w-[52px] items-center justify-center rounded-xl after:inset-0 after:content-['']"
>
{checked && (
<motion.div
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { clx } from "@medusajs/ui"
import { Transition, motion } from "framer-motion"
import { Transition, motion } from "motion/react"

type LogoBoxProps = {
className?: string
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./progress-bar"
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { motion } from "motion/react"

interface ProgressBarProps {
/**
* The duration of the animation in seconds.
*
* @default 2
*/
duration?: number
}

export const ProgressBar = ({ duration = 2 }: ProgressBarProps) => {
return (
<motion.div
className="bg-ui-fg-subtle size-full"
initial={{
width: "0%",
}}
transition={{
delay: 0.2,
duration,
ease: "linear",
}}
animate={{
width: "90%",
}}
exit={{
width: "100%",
transition: { duration: 0.2, ease: "linear" },
}}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const Thumbnail = ({ src, alt, size = "base" }: ThumbnailProps) => {
return (
<div
className={clx(
"bg-ui-bg-component flex items-center justify-center overflow-hidden rounded-[4px]",
"bg-ui-bg-component border-ui-border-base flex items-center justify-center overflow-hidden rounded border",
{
"h-8 w-6": size === "base",
"h-5 w-4": size === "small",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export const DataGridCellContainer = ({
<div
{...overlayProps}
data-cell-overlay="true"
className="absolute inset-0"
className="absolute inset-0 z-[2]"
/>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ReactNode } from "react"
import { useDataGridDuplicateCell } from "../hooks"

interface DataGridDuplicateCellProps<TValue> {
duplicateOf: string
children?: ReactNode | ((props: { value: TValue }) => ReactNode)
}
export const DataGridDuplicateCell = <TValue,>({
duplicateOf,
children,
}: DataGridDuplicateCellProps<TValue>) => {
const { watchedValue } = useDataGridDuplicateCell({ duplicateOf })

return (
<div className="bg-ui-bg-base txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center justify-between overflow-hidden px-4 py-2.5 outline-none">
{typeof children === "function"
? children({ value: watchedValue })
: children}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,32 @@
import { PropsWithChildren } from "react"

import { clx } from "@medusajs/ui"
import { useDataGridCellError } from "../hooks"
import { DataGridCellProps } from "../types"
import { DataGridRowErrorIndicator } from "./data-grid-row-error-indicator"

type DataGridReadonlyCellProps<TData, TValue = any> = DataGridCellProps<
TData,
TValue
> &
PropsWithChildren
type DataGridReadonlyCellProps<TData, TValue = any> = PropsWithChildren<
DataGridCellProps<TData, TValue>
> & {
color?: "muted" | "normal"
}

export const DataGridReadonlyCell = <TData, TValue = any>({
context,
color = "muted",
children,
}: DataGridReadonlyCellProps<TData, TValue>) => {
const { rowErrors } = useDataGridCellError({ context })

return (
<div className="bg-ui-bg-subtle txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center justify-between overflow-hidden px-4 py-2.5 outline-none">
<span className="truncate">{children}</span>
<div
className={clx(
"txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center justify-between overflow-hidden px-4 py-2.5 outline-none",
color === "muted" && "bg-ui-bg-subtle",
color === "normal" && "bg-ui-bg-base"
)}
>
<div className="flex-1 truncate">{children}</div>
<DataGridRowErrorIndicator rowErrors={rowErrors} />
</div>
)
Expand Down
Loading

0 comments on commit bc22b81

Please sign in to comment.