feat(admin-ui, medusa-js, medusa-react, medusa): Multiwarehousing UI (#3403)

* add "get-variant" endpoint

* import from a different place

* fix unit test

* add changeset

* inventory management for orders

* add changeset

* initial create-fulfillment

* add changeset

* type oas and admin

* Move inv. creation and listing from admin repo

* Fix location editing bug (CORE-1216)

* Fix default warehouse on inventory table view

* remove actions from each table line

* Use feature flag hook instead of context directly

* remove manage inventory action if inventory management is not enabled

* Address review comments

* fix queries made when inventorymodules are disabled

* variant form changes for feature enabled

* move exclamation icon into warning icon

* ensure queries are not run unless feature is enabled for create-fulfillment

---------

Co-authored-by: Philip Korsholm <philip.korsholm@hotmail.com>
Co-authored-by: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com>
This commit is contained in:
Rares Stefan
2023-03-08 16:08:56 +01:00
committed by GitHub
co-authored by Philip Korsholm Philip Korsholm
parent 53eda215e0
commit 57d7728dd9
49 changed files with 3922 additions and 722 deletions
@@ -1,4 +1,4 @@
import React, { useMemo } from "react"
import { useMemo } from "react"
import { Controller } from "react-hook-form"
import { Column, useTable } from "react-table"
import { FormImage } from "../../../types/shared"
@@ -38,7 +38,7 @@ const ImageTable = ({ data, form, onDelete }: ImageTableProps) => {
return (
<div className="py-base ml-large">
<img
className="h-[80px] w-[80px] object-cover rounded"
className="h-[80px] w-[80px] rounded object-cover"
src={value}
/>
</div>
@@ -71,7 +71,7 @@ const ImageTable = ({ data, form, onDelete }: ImageTableProps) => {
},
{
Header: () => (
<div className="flex gap-x-[6px] items-center justify-center">
<div className="flex items-center justify-center gap-x-[6px]">
<span>Thumbnail</span>
<IconTooltip content="Select which image you want to use as the thumbnail for this product" />
</div>
@@ -97,7 +97,7 @@ const ImageTable = ({ data, form, onDelete }: ImageTableProps) => {
onClick={() => onDelete(row.index)}
variant="ghost"
size="small"
className="p-1 text-grey-40 cursor-pointer mx-6"
className="p-1 mx-6 cursor-pointer text-grey-40"
type="button"
>
<TrashIcon size={20} />
@@ -108,19 +108,14 @@ const ImageTable = ({ data, form, onDelete }: ImageTableProps) => {
]
}, [])
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
} = useTable({
columns,
data,
defaultColumn: {
width: "auto",
},
})
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
useTable({
columns,
data,
defaultColumn: {
width: "auto",
},
})
return (
<Table {...getTableProps()}>
@@ -0,0 +1,471 @@
import { Cell, Row, TableRowProps, usePagination, useTable } from "react-table"
import {
InventoryItemDTO,
InventoryLevelDTO,
ProductVariant,
} from "@medusajs/medusa"
import React, { useEffect, useMemo, useState } from "react"
import {
useAdminInventoryItems,
useAdminStockLocations,
useAdminStore,
useAdminUpdateLocationLevel,
useAdminVariant,
} from "medusa-react"
import Button from "../../fundamentals/button"
import ImagePlaceholder from "../../fundamentals/image-placeholder"
import InputField from "../../molecules/input"
import InputHeader from "../../fundamentals/input-header"
import InventoryFilter from "../../../domain/inventory/filter-dropdown"
import Modal from "../../molecules/modal"
import { NextSelect } from "../../molecules/select/next-select"
import Spinner from "../../atoms/spinner"
import Table from "../../molecules/table"
import TableContainer from "../../../components/organisms/table-container"
import { getErrorMessage } from "../../../utils/error-messages"
import { isEmpty } from "lodash"
import qs from "qs"
import { useInventoryFilters } from "./use-inventory-filters"
import useInventoryTableColumn from "./use-inventory-column"
import { useLocation } from "react-router-dom"
import useNotification from "../../../hooks/use-notification"
import useToggleState from "../../../hooks/use-toggle-state"
const DEFAULT_PAGE_SIZE = 15
type InventoryTableProps = {}
const defaultQueryProps = {}
const LocationDropdown = ({
selectedLocation,
onChange,
}: {
selectedLocation: string
onChange: (id: string) => void
}) => {
const { stock_locations: locations, isLoading } = useAdminStockLocations()
useEffect(() => {
if (!selectedLocation && !isLoading && locations) {
onChange(locations[0].id)
}
}, [isLoading, locations, onChange, selectedLocation])
const selectedLocObj = useMemo(() => {
if (!isLoading && locations) {
return locations.find((l) => l.id === selectedLocation)
}
return null
}, [selectedLocation, locations, isLoading])
if (isLoading || !locations || !selectedLocObj) {
return null
}
return (
<div className="h-[40px] w-[200px]">
<NextSelect
isMulti={false}
onChange={(loc) => {
onChange(loc!.value)
}}
options={locations.map((l) => ({
label: l.name,
value: l.id,
}))}
value={{ value: selectedLocObj.id, label: selectedLocObj.name }}
/>
</div>
)
}
const InventoryTable: React.FC<InventoryTableProps> = () => {
const { store } = useAdminStore()
const location = useLocation()
const defaultQuery = useMemo(() => {
if (store) {
return {
...defaultQueryProps,
location_id: store.default_location_id,
}
}
return defaultQueryProps
}, [store])
const {
removeTab,
setTab,
saveTab,
availableTabs: filterTabs,
activeFilterTab,
reset,
paginate,
setFilters,
setLocationFilter,
filters,
setQuery: setFreeText,
queryObject,
representationObject,
} = useInventoryFilters(location.search, defaultQuery)
const offs = parseInt(queryObject.offset) || 0
const limit = parseInt(queryObject.limit)
const [query, setQuery] = useState(queryObject.query)
const [numPages, setNumPages] = useState(0)
const clearFilters = () => {
reset()
setQuery("")
}
const { inventory_items, isLoading, count } = useAdminInventoryItems(
{
...queryObject,
},
{
enabled: !!store,
}
)
useEffect(() => {
const controlledPageCount = Math.ceil(count! / queryObject.limit)
setNumPages(controlledPageCount)
}, [inventory_items])
const updateUrlFromFilter = (obj = {}) => {
const stringified = qs.stringify(obj)
window.history.replaceState(`/a/inventory`, "", `${`?${stringified}`}`)
}
const refreshWithFilters = () => {
const filterObj = representationObject
if (isEmpty(filterObj)) {
updateUrlFromFilter({ offset: 0, limit: DEFAULT_PAGE_SIZE })
} else {
updateUrlFromFilter(filterObj)
}
}
useEffect(() => {
refreshWithFilters()
}, [representationObject])
const [columns] = useInventoryTableColumn()
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
gotoPage,
canPreviousPage,
canNextPage,
pageCount,
nextPage,
previousPage,
// Get the state from the instance
state: { pageIndex },
} = useTable(
{
columns,
data: inventory_items || [],
manualPagination: true,
initialState: {
pageIndex: Math.floor(offs / limit),
pageSize: limit,
},
pageCount: numPages,
autoResetPage: false,
},
usePagination
)
// Debounced search
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
if (query) {
setFreeText(query)
gotoPage(0)
} else {
if (typeof query !== "undefined") {
// if we delete query string, we reset the table view
reset()
}
}
}, 400)
return () => clearTimeout(delayDebounceFn)
}, [query])
const handleNext = () => {
if (canNextPage) {
paginate(1)
nextPage()
}
}
const handlePrev = () => {
if (canPreviousPage) {
paginate(-1)
previousPage()
}
}
return (
<TableContainer
hasPagination
pagingState={{
count: count || 0,
offset: offs,
pageSize: offs + rows.length,
title: "Inventory Items",
currentPage: pageIndex + 1,
pageCount: pageCount,
nextPage: handleNext,
prevPage: handlePrev,
hasNext: canNextPage,
hasPrev: canPreviousPage,
}}
numberOfRows={limit}
isLoading={isLoading}
>
<Table
filteringOptions={
<InventoryFilter
filters={filters}
submitFilters={setFilters}
clearFilters={clearFilters}
tabs={filterTabs}
onTabClick={setTab}
activeTab={activeFilterTab}
onRemoveTab={removeTab}
onSaveTab={saveTab}
/>
}
enableSearch
handleSearch={setQuery}
searchValue={query}
tableActions={
<LocationDropdown
selectedLocation={
queryObject.location_id || store?.default_location_id
}
onChange={(id) => {
setLocationFilter(id)
gotoPage(0)
}}
/>
}
{...getTableProps()}
>
<Table.Head>
{headerGroups?.map((headerGroup) => {
const { key, ...rest } = headerGroup.getHeaderGroupProps()
return (
<Table.HeadRow key={key} {...rest}>
{headerGroup.headers.map((col) => {
const { key, ...rest } = col.getHeaderProps()
return (
<Table.HeadCell
className="min-w-[100px]"
key={key}
{...rest}
>
{col.render("Header")}
</Table.HeadCell>
)
})}
</Table.HeadRow>
)
})}
</Table.Head>
<Table.Body {...getTableBodyProps()}>
{rows.map((row) => {
prepareRow(row)
const { key, ...rest } = row.getRowProps()
return <InventoryRow row={row} key={key} {...rest} />
})}
</Table.Body>
</Table>
</TableContainer>
)
}
const InventoryRow = ({
row,
...rest
}: {
row: Row<
Partial<InventoryItemDTO> & {
location_levels?: InventoryLevelDTO[] | undefined
variants?: ProductVariant[] | undefined
}
>
} & TableRowProps) => {
const inventory = row.original
const {
state: isShowingAdjustAvailabilityModal,
open: showAdjustAvailabilityModal,
close: closeAdjustAvailabilityModal,
} = useToggleState()
return (
<Table.Row
color={"inherit"}
onClick={showAdjustAvailabilityModal}
forceDropdown
{...rest}
>
{row.cells.map((cell: Cell, index: number) => {
const { key, ...rest } = cell.getCellProps()
return (
<Table.Cell {...rest} key={key}>
{cell.render("Cell", { index })}
</Table.Cell>
)
})}
{isShowingAdjustAvailabilityModal && (
<AdjustAvailabilityModal
inventory={inventory}
handleClose={closeAdjustAvailabilityModal}
/>
)}
</Table.Row>
)
}
const AdjustAvailabilityModal = ({
inventory,
handleClose,
}: {
inventory: Partial<InventoryItemDTO> & {
location_levels?: InventoryLevelDTO[] | undefined
variants?: ProductVariant[] | undefined
}
handleClose: () => void
}) => {
const inventoryVariantId = inventory.variants?.[0]?.id
const locationLevel = inventory.location_levels?.[0]
const { variant, isLoading } = useAdminVariant(inventoryVariantId || "")
const {
mutate: updateLocationLevelForInventoryItem,
isLoading: isSubmitting,
} = useAdminUpdateLocationLevel(inventory.id!)
const notification = useNotification()
const [stockedQuantity, setStockedQuantity] = useState(
locationLevel?.stocked_quantity || 0
)
const disableSubmit =
stockedQuantity === (locationLevel?.stocked_quantity || 0) ||
!variant ||
!locationLevel
const onSubmit = () => {
updateLocationLevelForInventoryItem(
{
stockLocationId: locationLevel!.location_id,
stocked_quantity: stockedQuantity,
},
{
onSuccess: () => {
notification(
"Success",
"Inventory item updated successfully",
"success"
)
handleClose()
},
onError: (error) => {
notification("Error", getErrorMessage(error), "error")
},
}
)
}
return (
<Modal handleClose={handleClose}>
<Modal.Body>
<Modal.Header handleClose={handleClose}>
<h1 className="inter-large-semibold">Adjust availability</h1>
</Modal.Header>
<Modal.Content>
{isLoading ? (
<Spinner />
) : (
<div className="grid grid-cols-2">
<InputHeader label="Item" />
<InputHeader label="Quantity" />
<div className="flex flex-col">
<span className="pr-base">
<div className="float-left my-1.5 mr-4 flex h-[40px] w-[30px] items-center">
{variant?.product?.thumbnail ? (
<img
src={variant?.product?.thumbnail}
className="object-cover h-full rounded-rounded"
/>
) : (
<ImagePlaceholder />
)}
</div>
<div className="flex flex-col">
<span className="truncate">
{variant?.product?.title}
<span className="truncate text-grey-50">
({inventory.sku})
</span>
</span>
<span className="text-grey-50">
{variant?.options?.map((o) => (
<span key={o.id}>{o.value}</span>
))}
</span>
</div>
</span>
</div>
<InputField
onChange={(e) => setStockedQuantity(e.target.valueAsNumber)}
autoFocus
type="number"
value={stockedQuantity}
/>
</div>
)}
</Modal.Content>
</Modal.Body>
<Modal.Footer>
<div className="flex justify-end w-full gap-x-xsmall">
<Button
size="small"
variant="ghost"
className="border"
onClick={handleClose}
>
Cancel
</Button>
<Button
size="small"
variant="primary"
disabled={disableSubmit}
loading={isSubmitting}
onClick={onSubmit}
>
Save and close
</Button>
</div>
</Modal.Footer>
</Modal>
)
}
export default InventoryTable
@@ -0,0 +1,71 @@
import { useMemo } from "react"
import ImagePlaceholder from "../../fundamentals/image-placeholder"
const useInventoryTableColumn = () => {
const columns = useMemo(
() => [
{
Header: "Item",
accessor: "title",
Cell: ({ row: { original } }) => {
return (
<div className="flex items-center">
<div className="my-1.5 mr-4 flex h-[40px] w-[30px] items-center">
{original.variants[0]?.product?.thumbnail ? (
<img
src={original.variants[0].product.thumbnail}
className="object-cover h-full rounded-soft"
/>
) : (
<ImagePlaceholder />
)}
</div>
{original.variants[0]?.product?.title || ""}
</div>
)
},
},
{
Header: "Variant",
Cell: ({ row: { original } }) => {
return <div>{original?.variants[0]?.title || "-"}</div>
},
},
{
Header: "SKU",
accessor: "sku",
Cell: ({ cell: { value } }) => value,
},
{
Header: "Incoming",
accessor: "incoming_quantity",
Cell: ({ row: { original } }) => (
<div>
{original.location_levels.reduce(
(acc, next) => acc + next.incoming_quantity,
0
)}
</div>
),
},
{
Header: "In stock",
accessor: "stocked_quantity",
Cell: ({ row: { original } }) => (
<div>
{original.location_levels.reduce(
(acc, next) => acc + next.stocked_quantity,
0
)}
</div>
),
},
],
[]
)
return [columns] as const
}
export default useInventoryTableColumn
@@ -0,0 +1,466 @@
import { omit } from "lodash"
import qs from "qs"
import { useMemo, useReducer, useState } from "react"
import { relativeDateFormatToTimestamp } from "../../../utils/time"
type InventoryDateFilter = null | {
gt?: string
lt?: string
}
type InventoryFilterAction =
| { type: "setQuery"; payload: string | null }
| { type: "setFilters"; payload: InventoryFilterState }
| { type: "reset"; payload: InventoryFilterState }
| { type: "setOffset"; payload: number }
| { type: "setDefaults"; payload: InventoryDefaultFilters | null }
| { type: "setLocation"; payload: string }
| { type: "setLimit"; payload: number }
interface InventoryFilterState {
query?: string | null
limit: number
offset: number
location: string
additionalFilters: InventoryDefaultFilters | null
}
const allowedFilters = ["location", "q", "offset", "limit"]
const DefaultTabs = {}
const formatDateFilter = (filter: InventoryDateFilter) => {
if (filter === null) {
return filter
}
const dateFormatted = Object.entries(filter).reduce((acc, [key, value]) => {
if (value.includes("|")) {
acc[key] = relativeDateFormatToTimestamp(value)
} else {
acc[key] = value
}
return acc
}, {})
return dateFormatted
}
const reducer = (
state: InventoryFilterState,
action: InventoryFilterAction
): InventoryFilterState => {
switch (action.type) {
case "setFilters": {
return {
...state,
query: action?.payload?.query,
}
}
case "setQuery": {
return {
...state,
query: action.payload,
}
}
case "setLimit": {
return {
...state,
limit: action.payload,
}
}
case "setOffset": {
return {
...state,
offset: action.payload,
}
}
case "setLocation": {
return {
...state,
location: action.payload,
}
}
case "reset": {
return action.payload
}
default: {
return state
}
}
}
type InventoryDefaultFilters = {
expand?: string
fields?: string
location_id?: string
}
const eqSet = (as: Set<string>, bs: Set<string>) => {
if (as.size !== bs.size) {
return false
}
for (const a of as) {
if (!bs.has(a)) {
return false
}
}
return true
}
export const useInventoryFilters = (
existing?: string,
defaultFilters: InventoryDefaultFilters | null = null
) => {
if (existing && existing[0] === "?") {
existing = existing.substring(1)
}
const initial = useMemo(
() => parseQueryString(existing, defaultFilters),
[existing, defaultFilters]
)
const initialTabs = useMemo(() => {
const storageString = localStorage.getItem("inventory::filters")
if (storageString) {
const savedTabs = JSON.parse(storageString)
if (savedTabs) {
return Object.entries(savedTabs).map(([key, value]) => {
return {
label: key,
value: key,
removable: true,
representationString: value,
}
})
}
}
return []
}, [])
const [state, dispatch] = useReducer(reducer, initial)
const [tabs, setTabs] = useState(initialTabs)
const setDefaultFilters = (filters: InventoryDefaultFilters | null) => {
dispatch({ type: "setDefaults", payload: filters })
}
const setLimit = (limit: number) => {
dispatch({ type: "setLimit", payload: limit })
}
const setLocationFilter = (loc: string) => {
dispatch({ type: "setLocation", payload: loc })
}
const paginate = (direction: 1 | -1) => {
if (direction > 0) {
const nextOffset = state.offset + state.limit
dispatch({ type: "setOffset", payload: nextOffset })
} else {
const nextOffset = Math.max(state.offset - state.limit, 0)
dispatch({ type: "setOffset", payload: nextOffset })
}
}
const reset = () => {
dispatch({
type: "setFilters",
payload: {
...state,
offset: 0,
query: null,
},
})
}
const setFilters = (filters: InventoryFilterState) => {
dispatch({ type: "setFilters", payload: filters })
}
const setQuery = (queryString: string | null) => {
dispatch({ type: "setQuery", payload: queryString })
}
const getQueryObject = () => {
const toQuery: any = { ...state.additionalFilters }
for (const [key, value] of Object.entries(state)) {
if (key === "query") {
if (value && typeof value === "string") {
toQuery["q"] = value
}
} else if (key === "offset" || key === "limit") {
toQuery[key] = value
} else if (value.open) {
if (key === "date") {
toQuery[stateFilterMap[key]] = formatDateFilter(
value.filter as InventoryDateFilter
)
} else {
toQuery[stateFilterMap[key]] = value.filter
}
} else if (key === "location") {
toQuery[stateFilterMap[key]] = value
}
}
return toQuery
}
const getQueryString = () => {
const obj = getQueryObject()
return qs.stringify(obj, { skipNulls: true })
}
const getRepresentationObject = (fromObject?: InventoryFilterState) => {
const objToUse = fromObject ?? state
const toQuery: any = {}
for (const [key, value] of Object.entries(objToUse)) {
if (key === "query") {
if (value && typeof value === "string") {
toQuery["q"] = value
}
} else if (key === "offset" || key === "limit") {
toQuery[key] = value
} else if (value.open) {
toQuery[stateFilterMap[key]] = value.filter
}
}
return toQuery
}
const getRepresentationString = () => {
const obj = getRepresentationObject()
return qs.stringify(obj, { skipNulls: true })
}
const queryObject = useMemo(() => getQueryObject(), [state])
const representationObject = useMemo(() => getRepresentationObject(), [state])
const representationString = useMemo(() => getRepresentationString(), [state])
const activeFilterTab = useMemo(() => {
const clean = omit(representationObject, ["limit", "offset"])
const stringified = qs.stringify(clean)
const existsInSaved = tabs.find(
(el) => el.representationString === stringified
)
if (existsInSaved) {
return existsInSaved.value
}
for (const [tab, conditions] of Object.entries(DefaultTabs)) {
let match = true
if (Object.keys(clean).length !== Object.keys(conditions).length) {
continue
}
for (const [filter, value] of Object.entries(conditions)) {
if (filter in clean) {
if (Array.isArray(value)) {
match =
Array.isArray(clean[filter]) &&
eqSet(new Set(clean[filter]), new Set(value))
} else {
match = clean[filter] === value
}
} else {
match = false
}
if (!match) {
break
}
}
if (match) {
return tab
}
}
return null
}, [representationObject, tabs])
const availableTabs = useMemo(() => {
return [...tabs]
}, [tabs])
const setTab = (tabName: string) => {
let tabToUse: object | null = null
if (tabName in DefaultTabs) {
tabToUse = DefaultTabs[tabName]
} else {
const tabFound = tabs.find((t) => t.value === tabName)
if (tabFound) {
tabToUse = qs.parse(tabFound.representationString)
}
}
if (tabToUse) {
const toSubmit = {
...state,
}
for (const [filter, val] of Object.entries(tabToUse)) {
toSubmit[filterStateMap[filter]] = {
open: true,
filter: val,
}
}
dispatch({ type: "setFilters", payload: toSubmit })
}
}
const saveTab = (tabName: string, filters: InventoryFilterState) => {
const repObj = getRepresentationObject({ ...filters })
const clean = omit(repObj, ["limit", "offset"])
const repString = qs.stringify(clean, { skipNulls: true })
const storedString = localStorage.getItem("inventory::filters")
let existing: null | object = null
if (storedString) {
existing = JSON.parse(storedString)
}
if (existing) {
existing[tabName] = repString
localStorage.setItem("inventory::filters", JSON.stringify(existing))
} else {
const newFilters = {}
newFilters[tabName] = repString
localStorage.setItem("inventory::filters", JSON.stringify(newFilters))
}
setTabs((prev) => {
const duplicate = prev.findIndex(
(prev) => prev.label?.toLowerCase() === tabName.toLowerCase()
)
if (duplicate !== -1) {
prev.splice(duplicate, 1)
}
return [
...prev,
{
label: tabName,
value: tabName,
representationString: repString,
removable: true,
},
]
})
dispatch({ type: "setFilters", payload: filters })
}
const removeTab = (tabValue: string) => {
const storedString = localStorage.getItem("products::filters")
let existing: null | object = null
if (storedString) {
existing = JSON.parse(storedString)
}
if (existing) {
delete existing[tabValue]
localStorage.setItem("products::filters", JSON.stringify(existing))
}
setTabs((prev) => {
const newTabs = prev.filter((p) => p.value !== tabValue)
return newTabs
})
}
return {
...state,
filters: {
...state,
},
removeTab,
saveTab,
setTab,
availableTabs,
activeFilterTab,
representationObject,
representationString,
queryObject,
paginate,
getQueryObject,
getQueryString,
setQuery,
setFilters,
setDefaultFilters,
setLocationFilter,
setLimit,
reset,
}
}
const filterStateMap = {
location_id: "location",
}
const stateFilterMap = {
location: "location_id",
}
const parseQueryString = (
queryString?: string,
additionals: InventoryDefaultFilters | null = null
): InventoryFilterState => {
const defaultVal: InventoryFilterState = {
location: additionals?.location_id ?? "",
offset: 0,
limit: 15,
additionalFilters: additionals,
}
if (queryString) {
const filters = qs.parse(queryString)
for (const [key, value] of Object.entries(filters)) {
if (allowedFilters.includes(key)) {
switch (key) {
case "offset": {
if (typeof value === "string") {
defaultVal.offset = parseInt(value)
}
break
}
case "limit": {
if (typeof value === "string") {
defaultVal.limit = parseInt(value)
}
break
}
case "location_id": {
if (typeof value === "string") {
defaultVal.location = value
}
break
}
case "q": {
if (typeof value === "string") {
defaultVal.query = value
}
break
}
default: {
break
}
}
}
}
}
return defaultVal
}
@@ -46,7 +46,7 @@ const useProductTableColumn = ({ setTileView, setListView, showList }) => {
{original.thumbnail ? (
<img
src={original.thumbnail}
className="rounded-soft h-full object-cover"
className="object-cover h-full rounded-soft"
/>
) : (
<ImagePlaceholder />