feat(admin-ui): Bulk Editor (#4516)

* wip: initial commit

* wip: modal layout

* wip: regions

* refactor: restructure, drag to fill logic wip

* fix: currency input, optimise table rendering on input change

* fix: pass edited amount to local cell state

* wip: cell based algo

* wip: row based algo

* feat: convert prices to human format initially

* feat: column fill

* fix: decimal formating

* feat: currency symbol, tax incl. indicator, formatting dropdown, drag indicator positioning

* fix: load all currencies

* wip: highlighting range selection

* feat: more optimal highlighting algo

* fix: coordinate click handlers, selection reset

* fix: resetting pointers on close

* feat: prefill regional prices

* feat: keypress features, persisting price change between edits

* fix: undo feature, add saving waring

* feat: update prices

* feat: notifications and prompts

* feat: use only store currencies

* feat: tax. incl tooltip

* fix: decimal formatting

* fix: correct decimal formatting when multiedit

* feat: save prompt with hidden columns check

* chore: changesets

* fix: push icon

* fix: feedback changes v1

* fix: remove span placeholder

* fix: simplify and optimise selection algo

* fix: scroll z index

* fix: truncate region headers

* feat: calculate first col width

* fix: don't show delete notification

* fix: utils check conditions

* fix: typo

* feat: new selection behaviour

* refactor: currency cell

* refactor: save prompt

* chore: changesets

* chore: cleanup

* chore: Update changeset

* fix: don't calculate first col with but rather cut product title to the longest variant if needed

* feat: add loader on save

* fix: very last cell setting undefined on first render

* fix: show confirmation exit prompt on "x" click

* Update packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/utils.ts

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>

---------

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Frane Polić
2023-07-19 15:38:04 +02:00
committed by GitHub
co-authored by Oliver Windall Juhl
parent f325881227
commit bfc0ea5695
12 changed files with 1478 additions and 5 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@medusajs/admin-ui": patch
"@medusajs/admin": patch
---
feat(admin-ui): variant prices bulk editor
@@ -1,7 +1,7 @@
import clsx from "clsx"
import React from "react"
type SpinnerProps = {
export type SpinnerProps = {
size?: "large" | "medium" | "small"
variant?: "primary" | "secondary"
}
@@ -36,7 +36,7 @@ type Props = {
/**
* Re-usable nested form used to submit pricing information for products and their variants.
* Fetches store currencies and regions from the backend, and allows the user to specifcy both
* Fetches store currencies and regions from the backend, and allows the user to specify both
* currency and region specific prices.
* @example
* <Pricing form={nestedForm(form, "prices")} />
@@ -1,13 +1,14 @@
import React, { Children } from "react"
import Spinner from "../../atoms/spinner"
import clsx from "clsx"
import Spinner, { SpinnerProps } from "../../atoms/spinner"
export type ButtonProps = {
variant: "primary" | "secondary" | "ghost" | "danger" | "nuclear"
size?: "small" | "medium" | "large"
loading?: boolean
spanClassName?: string
spinnerConfig?: SpinnerProps
} & React.ButtonHTMLAttributes<HTMLButtonElement>
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
@@ -17,6 +18,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
size = "large",
loading = false,
spanClassName,
spinnerConfig,
children,
...attributes
},
@@ -56,7 +58,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
onClick={handleClick}
>
{loading ? (
<Spinner size={size} variant={"secondary"} />
<Spinner size={size} variant={"secondary"} {...spinnerConfig} />
) : (
Children.map(children, (child, i) => {
return (
@@ -0,0 +1,67 @@
import React from "react"
import IconProps from "./types/icon-type"
const IconBuildingTax: React.FC<IconProps> = ({
size = "20px",
color = "currentColor",
...attributes
}) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...attributes}
>
<path
d="M17.5 7.5H2.5V5.83333L10 2.5L17.5 5.83333V7.5Z"
stroke={color}
strokeWidth="1"
strokeLinejoin="round"
{...attributes}
/>
<path
d="M8.33333 16.6666H2.5L3.22917 14.1666H8.33333"
stroke={color}
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
{...attributes}
/>
<path
d="M4.16667 7.5V14.1667M7.50001 14.1667V7.5"
stroke={color}
strokeWidth="1"
{...attributes}
/>
<path
d="M17.1354 11.1979L12.0312 16.302"
stroke={color}
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
{...attributes}
/>
<path
d="M12.2917 12.0834C12.6368 12.0834 12.9167 11.8036 12.9167 11.4584C12.9167 11.1132 12.6368 10.8334 12.2917 10.8334C11.9465 10.8334 11.6667 11.1132 11.6667 11.4584C11.6667 11.8036 11.9465 12.0834 12.2917 12.0834Z"
stroke={color}
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
{...attributes}
/>
<path
d="M16.875 16.6666C17.2202 16.6666 17.5 16.3868 17.5 16.0416C17.5 15.6964 17.2202 15.4166 16.875 15.4166C16.5298 15.4166 16.25 15.6964 16.25 16.0416C16.25 16.3868 16.5298 16.6666 16.875 16.6666Z"
stroke={color}
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
{...attributes}
/>
</svg>
)
}
export default IconBuildingTax
@@ -0,0 +1,221 @@
import React, { forwardRef, useEffect, useRef, useState } from "react"
import { ProductVariant } from "@medusajs/client-types"
import AmountField from "react-currency-input-field"
import clsx from "clsx"
import { currencies as CURRENCY_MAP } from "../../../../utils/currencies"
import { useAdminRegions } from "medusa-react"
/**
* Return currency metadata or metadata of region's currency
*/
function useCurrencyMeta(
currencyCode: string | undefined,
regionId: string | undefined
) {
const { regions } = useAdminRegions()
if (currencyCode) {
return CURRENCY_MAP[currencyCode?.toUpperCase()]
}
if (regions) {
const region = regions.find((r) => r.id === regionId)
return CURRENCY_MAP[region!.currency_code.toUpperCase()]
}
}
type CurrencyCellProps = {
currencyCode?: string
region?: string
variant: ProductVariant
editedAmount?: number
isSelected?: boolean
isAnchor?: boolean
isRangeStart: boolean
isRangeEnd: boolean
isInRange: boolean
onDragFillStart: (
variantId: string,
currencyCode?: string,
regionId?: string
) => void
onMouseCellClick: (
event: React.MouseEvent,
variantId: string,
currencyCode?: string,
regionId?: string
) => void
onInputChange: (
value: number | undefined,
variantId: string,
currencyCode?: string,
regionId?: string
) => void
}
const currencyInput = forwardRef((props, ref) => <input ref={ref} {...props} />)
const currencySpan = forwardRef((props, ref) => (
<span ref={ref} {...props}>
{props.value || props.placeholder}
</span>
))
/**
* Amount cell container.
*/
function CurrencyCell(props: CurrencyCellProps) {
const {
variant,
currencyCode,
region,
editedAmount,
isSelected,
isAnchor,
isInRange,
isRangeStart,
isRangeEnd,
} = props
const ref = useRef()
const [isEditable, setIsEditable] = useState(false)
const currencyMeta = useCurrencyMeta(currencyCode, region)
const [localValue, setLocalValue] = useState({
value: editedAmount,
float: editedAmount,
})
useEffect(() => {
setLocalValue({
// when amount changes with dragging, format received value
value: editedAmount?.toFixed(currencyMeta?.decimal_digits) || "",
float: editedAmount,
})
}, [editedAmount])
useEffect(() => {
if (!isSelected) {
setIsEditable(false)
}
/**
* Register key listener on selected anchor cell
*/
if (isSelected && isAnchor) {
const onkeydown = (e) => {
if (document.activeElement?.tagName === "INPUT") {
return
}
if (!isNaN(Number(e.key))) {
setLocalValue({
float: Number(e.key),
value: String(e.key),
})
setIsEditable(true)
}
}
document.addEventListener("keydown", onkeydown)
return () => document.removeEventListener("keydown", onkeydown)
}
}, [isSelected, isAnchor, ref.current])
useEffect(() => {
// when cell becomes editable underlying `span` element is replaced with an `input` which needs to be focused
if (isEditable) {
/**
* HACK - for some reason focusing input will cause `react-currency-input-field` to double the digit that is set as value
* If we use set timout it will work as expected.
*/
setTimeout(() => ref.current.focus())
} else {
// Format value back after edit
setLocalValue({
value: localValue.float?.toFixed(currencyMeta?.decimal_digits) || "",
float: localValue.float,
})
// notify parent container about the change
props.onInputChange(localValue.float, variant.id, currencyCode, region)
}
}, [isEditable])
/* ==================== HANDLERS ==================== */
const onCellMouseDown: React.MouseEventHandler = (event) => {
if (!isEditable) {
event.stopPropagation()
event.preventDefault()
}
props.onMouseCellClick(event, variant.id, currencyCode, region)
if (event.detail === 2) {
// Unformat value for edit
setLocalValue({
float: localValue.float,
value: String(localValue.float || ""),
})
setIsEditable(true)
}
}
const onFillIndicatorMouseDown: React.MouseEventHandler = (event) => {
document.body.style.userSelect = "none"
event.stopPropagation()
props.onDragFillStart(variant.id, currencyCode, region)
}
const onInputBlurCapture = () => {
setIsEditable(false)
}
return (
<td
onMouseDown={onCellMouseDown}
className={clsx("relative cursor-pointer pr-2 pl-4", {
border: !isInRange,
"bg-blue-100": isSelected && !isAnchor,
"border-x border-double border-blue-400": isInRange,
"border-t border-blue-400": isRangeStart,
"border-b border-blue-400": isRangeEnd,
})}
>
<div className="flex">
<span className="text-gray-400">{currencyMeta?.symbol_native}</span>
<AmountField
ref={ref}
onBlurCapture={onInputBlurCapture}
style={{ width: "100%", textAlign: "right", paddingRight: 8 }}
className={clsx("decoration-transparent focus:outline-0", {
"bg-blue-100": isSelected && !isAnchor,
})}
onValueChange={(_a, _b, v) => setLocalValue(v)}
allowDecimals={currencyMeta?.decimal_digits > 0}
decimalScale={isEditable ? undefined : currencyMeta?.decimal_digits}
customInput={isEditable ? currencyInput : currencySpan}
allowNegativeValue={false}
value={localValue.value}
decimalSeparator="."
placeholder="-"
/>
{isRangeEnd && !isEditable && (
<div
style={{ bottom: -4, right: -4, zIndex: 9999 }}
onMouseDown={onFillIndicatorMouseDown}
className="absolute h-2 w-2 cursor-ns-resize rounded-full bg-blue-400"
/>
)}
</div>
</td>
)
}
export default CurrencyCell
@@ -0,0 +1,106 @@
import React, { useMemo } from "react"
import { useAdminRegions, useAdminStore } from "medusa-react"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import Button from "../../../fundamentals/button"
import AdjustmentsIcon from "../../../fundamentals/icons/adjustments-icon"
import CheckIcon from "../../../fundamentals/icons/check-icon"
type EditPricesActionsProps = {
selectedCurrencies: string[]
selectedRegions: string[]
toggleCurrency: (currencyCode: string) => void
toggleRegion: (regionId: string) => void
}
/**
* Edit prices table header actions.
*/
function EditPricesActions(props: EditPricesActionsProps) {
const { selectedCurrencies, selectedRegions, toggleCurrency, toggleRegion } =
props
const { store } = useAdminStore()
const _currencies = store?.currencies
const { regions: _regions } = useAdminRegions({
limit: 1000,
})
const currencies = useMemo(() => {
return (_currencies || []).sort((c1, c2) => c1.code.localeCompare(c2.code))
}, [_currencies])
const regions = useMemo(() => {
return (_regions || []).sort((r1, r2) => r1.name.localeCompare(r2.name))
}, [_regions])
return (
<div
style={{ fontSize: 13 }}
className="flex items-center gap-2 border-t py-[12px] px-4"
>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button variant="secondary" size="small" className="text-gray-700">
View
<AdjustmentsIcon size={20} />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
align="start"
sideOffset={10}
className="bg-grey-0 border-grey-20 rounded-rounded shadow-dropdown z-30 max-h-[500px] min-w-[272px] overflow-y-scroll border"
>
<DropdownMenu.Label className="text-small px-[12px] py-2 font-medium text-gray-400">
Currencies
</DropdownMenu.Label>
{currencies?.map((c) => (
<DropdownMenu.Item
key={c.code}
onClick={(event) => {
event.preventDefault()
toggleCurrency(c.code)
}}
className="mb-1 cursor-pointer last:mb-0 hover:bg-gray-100"
>
<div className="flex justify-between gap-4 px-[12px] py-2 text-gray-800">
{c.code.toUpperCase()}
<label className="flex items-center justify-between gap-2 text-gray-400">
<span className="max-w-[180px] truncate">{c.name}</span>
{selectedCurrencies.includes(c.code) && (
<CheckIcon className="text-gray-900" size={16} />
)}
</label>
</div>
</DropdownMenu.Item>
))}
<DropdownMenu.Label className="text-small border-t border-gray-200 px-[12px] py-2 font-medium text-gray-400">
Regions
</DropdownMenu.Label>
{regions?.map((r) => (
<DropdownMenu.Item
onClick={(event) => {
event.preventDefault()
toggleRegion(r.id)
}}
className="mb-1 cursor-pointer last:mb-0 hover:bg-gray-100"
key={r.id}
>
<div className="flex justify-between gap-4 px-[12px] py-2 text-gray-800">
{r.name}
{selectedRegions.includes(r.id) && (
<CheckIcon className="text-gray-900" size={16} />
)}
</div>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
)
}
export default EditPricesActions
@@ -0,0 +1,508 @@
import React, { useEffect, useMemo, useRef, useState } from "react"
import { useAdminRegions, useAdminStore } from "medusa-react"
import { Product } from "@medusajs/client-types"
import { getCurrencyPricesOnly, getRegionPricesOnly } from "./utils"
import CurrencyCell from "./currency-cell"
import IconBuildingTax from "../../../fundamentals/icons/building-tax-icon"
import { currencies as CURRENCY_MAP } from "../../../../utils/currencies"
import Tooltip from "../../../atoms/tooltip"
type EditPricesTableProps = {
product: Product
currencies: string[]
regions: string[]
onPriceUpdate: (prices: Record<string, number | undefined>) => void
}
/**
* Variant cell that is origin of the current drag move.
*/
let anchorVariant: string | undefined
/**
* During drag move keep info which column is active one.
*/
let activeCurrencyOrRegion: string | undefined = undefined
/**
* Pointer for displaying highlight rectangle range.
*/
let startIndex: number | undefined
let endIndex: number | undefined
let anchorIndex: number | undefined
/**
* Temp. variable for persisting previous "editedPrices" state before editing,
* so we can undo changes.
*/
let prevPriceState: Record<string, number> | undefined = undefined
/**
* Ordered list of variant id that are currently rendered.
*/
let variantIds: string[] = []
/**
* Construct cell key.
* Cell row is defined by variant id and column is ether currency or region.
*/
function getKey(variantId: string, currencyCode?: string, regionId?: string) {
return `${variantId}-${currencyCode || regionId}`
}
/**
* Edit prices table component.
*/
function EditPricesTable(props: EditPricesTableProps) {
const { store } = useAdminStore()
const storeCurrencies = store?.currencies
const { regions: storeRegions } = useAdminRegions({
limit: 1000,
})
const initialPricesSet = useRef(false)
const [isDragFill, setIsDragFill] = useState(false)
const [isDrag, setIsDrag] = useState(false)
const [editedPrices, setEditedPrices] = useState<
Record<string, number | undefined>
>({})
const [selectedCells, setSelectedCells] = useState<Record<string, boolean>>(
{}
)
const selectCell = (
variantId: string,
currencyCode?: string,
region?: string,
override?: boolean
) => {
if ((currencyCode || region) !== activeCurrencyOrRegion) {
return
}
const key = getKey(variantId, currencyCode, region)
if (override) {
setSelectedCells({ [key]: true })
return
}
const next = { ...selectedCells }
next[key] = true
setSelectedCells(next)
}
const setPriceForCell = (
amount: number | undefined,
variantId: string,
currencyCode?: string,
region?: string
) => {
const next = { ...editedPrices }
next[getKey(variantId, currencyCode, region)] = amount
setEditedPrices(next)
}
const resetSelection = () => {
anchorIndex = undefined
startIndex = undefined
endIndex = undefined
anchorVariant = undefined
activeCurrencyOrRegion = undefined
// warning state updates in event handlers will be batched together so if there is another
// `setSelectedCells` (or `resetSelection`) call in the same event handler, only last state will apply
setSelectedCells({})
}
/**
* ==================== HANDLERS ====================
*/
const onMouseRowEnter = (variantId: string) => {
if (!(isDragFill || isDrag) || !anchorVariant) {
return
}
const currentIndex = variantIds.findIndex((v) => v === variantId)
if (currentIndex > anchorIndex) {
startIndex = anchorIndex
endIndex = currentIndex
} else {
startIndex = currentIndex
endIndex = anchorIndex
}
const selectedVariants = variantIds.slice(startIndex, endIndex + 1)
const keys = selectedVariants.map((vId) =>
getKey(vId, activeCurrencyOrRegion)
)
const nextSelection = { ...selectedCells }
const nextPrices = { ...editedPrices }
Object.keys(nextSelection).forEach((k) => {
// deselect case
if (k.split("-")[1] === activeCurrencyOrRegion && !keys.includes(k)) {
delete nextSelection[k] // remove selection
nextPrices[k] = prevPriceState[k] // ...and reset price of that cell to the previous state
}
})
// select cells in range and set price
keys.forEach((k) => {
nextSelection[k] = true
if (isDragFill) {
nextPrices[k] =
editedPrices[getKey(anchorVariant, activeCurrencyOrRegion)]
}
})
setSelectedCells(nextSelection)
if (isDragFill) {
setEditedPrices(nextPrices)
}
}
const onMouseCellClick = (
event: React.MouseEvent,
variantId: string,
currencyCode?: string,
regionId?: string
) => {
event.stopPropagation()
prevPriceState = editedPrices
// set variant row anchors
anchorVariant = variantId
anchorIndex = variantIds.findIndex((v) => v === anchorVariant)
activeCurrencyOrRegion = currencyCode || regionId
setSelectedCells({ [getKey(variantId, currencyCode || regionId)]: true })
setIsDrag(true)
startIndex = props.product.variants!.findIndex((v) => v.id === variantId)
endIndex = startIndex
anchorIndex = startIndex
}
const onInputChange = (
value: number | undefined,
variantId: string,
currencyCode?: string,
regionId?: string
) => {
setPriceForCell(value, variantId, currencyCode, regionId)
}
const onDragFillStart = (
variantId: string,
currencyCode?: string,
regionId?: string
) => {
selectCell(variantId, currencyCode, regionId)
setIsDragFill(true)
}
/**
* ==================== EFFECTS ====================
*/
useEffect(() => {
resetSelection()
/**
* Called initially to populate `editedPrices` but called on column toggle as well
*/
if (!props.currencies || !props.regions || !props.product.variants) {
return
}
const nextState: Record<string, number | undefined> = {}
props.product.variants!.forEach((variant) => {
props.currencies.forEach((c) => {
const currencyMetadata = CURRENCY_MAP[c.toUpperCase()]
const ma = getCurrencyPricesOnly(variant.prices!).find(
(p) => p.currency_code === c
)
if (ma) {
nextState[getKey(variant.id, c)] =
ma.amount / Math.pow(10, currencyMetadata.decimal_digits)
}
})
props.regions.forEach((r) => {
const ma = getRegionPricesOnly(variant.prices!).find(
(p) => p.region_id === r
)
if (ma) {
const currencyMetadata = CURRENCY_MAP[ma.currency_code.toUpperCase()]
nextState[getKey(variant.id, undefined, r)] =
ma.amount / Math.pow(10, currencyMetadata.decimal_digits)
}
})
})
variantIds = props.product.variants!.map((v) => v.id)
initialPricesSet.current = true
setEditedPrices((s) => ({ ...nextState, ...s })) // called on column toggle -> don't override previous edits
}, [props.currencies, props.regions, props.product.variants])
useEffect(() => {
const down = () => {
document.body.style.userSelect = "none"
resetSelection()
}
const up = () => {
document.body.style.userSelect = "auto"
setIsDragFill(false)
setIsDrag(false)
}
/**
* Delete selected prices
*/
const onKeyDown = (e: KeyboardEvent) => {
// if backspace is pressed but we aren't focused on any input
if (e.key === "Backspace" && document.activeElement.tagName !== "INPUT") {
const next = { ...editedPrices }
Object.keys(selectedCells).forEach((k) => {
const [v, c] = k.split("-")
next[getKey(v, c)] = undefined
})
setEditedPrices(next)
}
/**
* Undo last selection change (or delete) on CMD/CTR + Z
*/
if ((e.ctrlKey || e.metaKey) && e.keyCode === 90) {
e.preventDefault()
if (Object.keys(selectedCells).length) {
e.stopPropagation()
setEditedPrices(prevPriceState || {})
resetSelection()
}
}
}
document.addEventListener("mousedown", down)
document.addEventListener("mouseup", up)
document.addEventListener("keydown", onKeyDown)
return () => {
document.removeEventListener("mousedown", down)
document.removeEventListener("mouseup", up)
document.addEventListener("keydown", onKeyDown)
}
}, [selectedCells])
useEffect(() => {
// when drag is released, notify parent container that prices have changed
if (!isDragFill) {
props.onPriceUpdate(editedPrices)
}
}, [isDragFill, editedPrices])
const productTitle = useMemo(() => {
let max = 0
props.product.variants!.forEach(
(v) => (max = Math.max(max, v.title.length + (v.sku ? v.sku.length : 0)))
)
return props.product.title.length > max
? props.product.title.substring(0, max) + "..."
: props.product.title
}, [props.product])
if (!initialPricesSet.current) {
/**
* Don't render the table until initial prices are populated in the state.
* This prevents cells from populating `editedPrices` with `undefined` values
* when `onInputChange` is called in an effect on mount.
*/
return
}
return (
<div className="h-full overflow-x-auto">
<table
onMouseMove={
/** prevent default browser highlighting while dragging **/
(e) => e.preventDefault()
}
style={{ fontSize: 13, borderCollapse: "collapse" }}
className="w-full table-auto"
>
<thead>
<tr
style={{ height: 42 }}
className="tw-text-medusa-text-subtle h-2 text-left font-normal"
>
<th className="h-2 border pl-4 font-medium text-gray-400">
Product
</th>
{props.currencies.map((c) => {
const currency = storeCurrencies?.find((sc) => sc.code === c)
return (
<th
key={c}
className="min-w-[220px] border px-4 font-medium text-gray-400"
>
<div className="flex items-center justify-between">
<span>Price {c.toUpperCase()}</span>
{currency?.includes_tax && (
<Tooltip content="Tax inclusive pricing" side="bottom">
<IconBuildingTax strokeWidth={1.3} size={20} />
</Tooltip>
)}
</div>
</th>
)
})}
{props.regions.map((r) => {
const region = storeRegions?.find((sr) => sr.id === r)
if (!region) {
return null
}
return (
<th
key={r}
className="min-w-[220px] max-w-[220px] border px-4 font-medium text-gray-400"
>
<div className="flex items-center justify-between gap-2">
<span className="flex overflow-hidden">
<span title={region?.name} className="truncate pr-1">
Price {region?.name}
</span>
({region?.currency_code.toUpperCase()})
</span>
{region.includes_tax && (
<Tooltip content="Tax inclusive pricing" side="bottom">
<IconBuildingTax strokeWidth={1.3} size={20} />
</Tooltip>
)}
</div>
</th>
)
})}
</tr>
</thead>
<tbody>
<tr style={{ lineHeight: 3, background: "#f9fafb" }}>
<td className="border pl-4 pr-4">
<div className="text-black-800 flex items-center gap-2 overflow-hidden">
{props.product.thumbnail && (
<img
src={props.product.thumbnail}
alt="Thumbnail"
className="h-[22px] w-[16px] rounded"
/>
)}
<span title={props.product.title} className="truncate">
{productTitle}
</span>
</div>
</td>
{props.currencies.map((c) => (
<td className="border pr-4 text-right" key={c}>
-
</td>
))}
{props.regions.map((r) => (
<td className="border pr-4 text-right" key={r}>
-
</td>
))}
</tr>
{props.product.variants!.map((variant, index) => {
return (
<tr
key={variant.id}
onMouseEnter={() => onMouseRowEnter(variant.id)}
style={{ lineHeight: 3 }}
>
<td className="whitespace-nowrap border pl-10 pr-4 text-gray-600">
{variant.title} {variant.sku && `${variant.sku}`}
</td>
{props.currencies.map((c) => {
return (
<CurrencyCell
key={variant.id + c}
currencyCode={c}
variant={variant}
isSelected={selectedCells[getKey(variant.id, c)]}
editedAmount={editedPrices[getKey(variant.id, c)]}
onInputChange={onInputChange}
onMouseCellClick={onMouseCellClick}
onDragFillStart={onDragFillStart}
isAnchor={anchorIndex === index}
isRangeStart={
activeCurrencyOrRegion === c && startIndex === index
}
isRangeEnd={
activeCurrencyOrRegion === c && index === endIndex
}
isInRange={
activeCurrencyOrRegion === c &&
index >= startIndex &&
index <= endIndex
}
/>
)
})}
{props.regions.map((r) => (
<CurrencyCell
key={variant.id + r}
region={r!}
variant={variant}
isSelected={selectedCells[getKey(variant.id, undefined, r)]}
editedAmount={
editedPrices[getKey(variant.id, undefined, r)]
}
onInputChange={onInputChange}
onMouseCellClick={onMouseCellClick}
onDragFillStart={onDragFillStart}
isAnchor={anchorIndex === index}
isRangeStart={
activeCurrencyOrRegion === r && startIndex === index
}
isRangeEnd={
activeCurrencyOrRegion === r && index === endIndex
}
isInRange={
activeCurrencyOrRegion === r &&
index >= startIndex &&
index <= endIndex
}
/>
))}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
export default EditPricesTable
@@ -0,0 +1,365 @@
import React, { useEffect, useMemo, useRef, useState } from "react"
import { useAdminRegions, useAdminUpdateVariant } from "medusa-react"
import { MoneyAmount, Product } from "@medusajs/client-types"
import pick from "lodash/pick"
import pickBy from "lodash/pickBy"
import mapKeys from "lodash/mapKeys"
import { currencies as CURRENCY_MAP } from "../../../../utils/currencies"
import Modal from "../../../molecules/modal"
import Fade from "../../../atoms/fade-wrapper"
import Button from "../../../fundamentals/button"
import {
getAllProductPricesCurrencies,
getAllProductPricesRegions,
getCurrencyPricesOnly,
getRegionPricesOnly,
} from "./utils"
import CrossIcon from "../../../fundamentals/icons/cross-icon"
import EditPricesTable from "./edit-prices-table"
import EditPricesActions from "./edit-prices-actions"
import useNotification from "../../../../hooks/use-notification"
import DeletePrompt from "../../delete-prompt"
import SavePrompt from "./save-prompt"
type EditPricesModalProps = {
close: () => void
product: Product
}
/**
* Return map of regionIds <> currency_codes
*/
function useRegionsCurrencyMap() {
const map = {}
const { regions: storeRegions } = useAdminRegions({
limit: 1000,
})
storeRegions?.forEach((r) => {
map[r.id] = r.currency_code
})
return useMemo(() => map, [storeRegions])
}
/**
* Edit prices modal container.
*/
function EditPricesModal(props: EditPricesModalProps) {
const editedPrices = useRef({})
const { regions: storeRegions } = useAdminRegions({
limit: 1000,
})
const regionCurrenciesMap = useRegionsCurrencyMap()
const regions = getAllProductPricesRegions(props.product).sort()
const currencies = getAllProductPricesCurrencies(props.product).sort()
const notification = useNotification()
const updateVariant = useAdminUpdateVariant(props.product.id)
const [showCloseConfirmationPrompt, setShowCloseConfirmationPrompt] =
useState(false)
const [showSaveConfirmationPrompt, setShowSaveConfirmationPrompt] =
useState(false)
const [selectedCurrencies, setSelectedCurrencies] = useState(currencies)
const [selectedRegions, setSelectedRegions] = useState<string[]>(regions)
const toggleCurrency = (currencyCode: string) => {
const set = new Set(selectedCurrencies)
if (set.has(currencyCode)) {
set.delete(currencyCode)
} else {
set.add(currencyCode)
}
setSelectedCurrencies(Array.from(set))
}
const toggleRegion = (regionId: string) => {
const set = new Set(selectedRegions)
if (set.has(regionId)) {
set.delete(regionId)
} else {
set.add(regionId)
}
setSelectedRegions(Array.from(set))
}
const onPriceUpdate = (prices: Record<string, number | undefined>) => {
editedPrices.current = prices
}
const onSave = () => {
detectHiddenEditedColumns()
setShowSaveConfirmationPrompt(true)
}
const detectHiddenEditedColumns = () => {
const initialState = {}
// figure out which price cells were initially populated
props.product.variants!.forEach((variant) => {
currencies.forEach((c) => {
const currencyMetadata = CURRENCY_MAP[c.toUpperCase()]
const ma = getCurrencyPricesOnly(variant.prices!).find(
(p) => p.currency_code === c
)
if (ma) {
initialState[`${variant.id}-${c}`] =
ma.amount / Math.pow(10, currencyMetadata.decimal_digits)
}
})
regions.forEach((r) => {
const ma = getRegionPricesOnly(variant.prices!).find(
(p) => p.region_id === r
)
if (ma) {
const currencyMetadata = CURRENCY_MAP[ma.currency_code.toUpperCase()]
initialState[`${variant.id}-${r}`] =
ma.amount / Math.pow(10, currencyMetadata.decimal_digits)
}
})
})
const diff = { ...editedPrices.current }
// all prices that differ from the initial populated value are changed prices
Object.keys(initialState).forEach((k) => {
if (initialState[k] === diff[k]) {
delete diff[k]
}
})
const dirtyColumns = [
...new Set(Object.keys(diff).map((k) => k.split("-")[1])),
]
const hiddenDirtyColumns = dirtyColumns.filter(
(c) => !selectedCurrencies.includes(c) && !selectedRegions.includes(c)
)
return hiddenDirtyColumns.map((c) => {
if (c.length === 3) {
return c.toUpperCase()
} else {
return storeRegions?.find((r) => r.id === c)?.name || c
}
})
}
const save = (saveOnlyVisible?: boolean) => {
const pricesEditMap: Record<string, number | undefined> =
editedPrices.current
const variants = props.product.variants!
const promises = variants.map((variant) => {
const variantPrices = variant.prices!.filter((p) => !p.price_list_id)
// pick price edits that are related to the current variant
const variantPricesEditMap = mapKeys(
pickBy(pricesEditMap, (_, k) => k.includes(variant.id)),
(_, k) => k.split("-")[1]
)
const currencyPriceEdits = pickBy(
variantPricesEditMap,
(o, k) =>
!k.startsWith("reg") &&
(saveOnlyVisible ? selectedCurrencies.includes(k) : true)
)
const regionPriceEdits = pickBy(
variantPricesEditMap,
(o, k) =>
k.startsWith("reg") &&
(saveOnlyVisible ? selectedRegions.includes(k) : true)
)
const pricesPayload: Partial<MoneyAmount>[] = []
variantPrices.forEach((price) => {
if (price.region_id) {
// region price
if (price.region_id in regionPriceEdits) {
// this MA is edited - UPDATE CASE
if (typeof regionPriceEdits[price.region_id] === "number") {
const p = { ...price }
p.amount =
regionPriceEdits[price.region_id]! *
Math.pow(
10,
CURRENCY_MAP[price.currency_code.toUpperCase()].decimal_digits
)
pricesPayload.push(p)
} else {
// amount is unset -> DELETED case just skip
}
} else {
pricesPayload.push(price) // not edited just send it so it's not deleted
}
delete regionPriceEdits[price.region_id]
} else {
// currency price
if (price.currency_code in currencyPriceEdits) {
// this MA is edited - UPDATE CASE
if (typeof currencyPriceEdits[price.currency_code] === "number") {
const p = { ...price }
p.amount =
currencyPriceEdits[price.currency_code] *
Math.pow(
10,
CURRENCY_MAP[price.currency_code.toUpperCase()].decimal_digits
)
pricesPayload.push(p)
} else {
// amount is unset -> DELETED case just skip
}
} else {
pricesPayload.push(price) // not edited just send it so it's not deleted
}
delete currencyPriceEdits[price.currency_code] // not deleted entries are new prices
}
})
Object.entries(currencyPriceEdits).forEach(([currency, amount]) => {
if (typeof amount === "number") {
amount *= Math.pow(
10,
CURRENCY_MAP[currency.toUpperCase()].decimal_digits
)
pricesPayload.push({ currency_code: currency, amount })
}
})
Object.entries(regionPriceEdits).forEach(([region, amount]) => {
if (typeof amount === "number") {
const currency = regionCurrenciesMap[region]
amount *= Math.pow(
10,
CURRENCY_MAP[currency.toUpperCase()].decimal_digits
)
pricesPayload.push({ region_id: region, amount })
}
})
// @ts-ignore
return updateVariant.mutateAsync({
variant_id: variant.id,
prices: pricesPayload.map((p) =>
pick(p, ["id", "amount", "region_id", "currency_code"])
),
})
})
Promise.all(promises)
.then(() => {
notification(
"Success",
"Successfully updated variant prices",
"success"
)
props.close()
})
.catch((e) => {
notification("Error", "Failed to update variant prices", "error")
})
}
useEffect(() => {
const onEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setShowCloseConfirmationPrompt(true)
}
}
document.addEventListener("keydown", onEsc)
return () => document.removeEventListener("keydown", onEsc)
}, [])
return (
<Fade isFullScreen isVisible>
<Modal.Body className="border bg-gray-200 p-2">
<div className="h-full overflow-hidden rounded-lg border border-gray-300 bg-white">
<div className="flex h-[64px] items-center justify-between px-4">
<div className="flex h-[20px] items-center gap-2">
<Button
variant="ghost"
size="small"
onClick={() => setShowCloseConfirmationPrompt(true)}
className="text-grey-50 cursor-pointer"
>
<CrossIcon size={20} />
</Button>
<span className="text-small rounded-lg border border-2 border-gray-300 bg-gray-100 px-2 font-medium text-gray-500">
esc
</span>
</div>
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="small"
onClick={props.close}
className="text-black-800 cursor-pointer border p-1.5 font-medium"
>
Discard
</Button>
<Button
variant="ghost"
size="small"
onClick={onSave}
className="cursor-pointer border bg-black p-1.5 font-medium text-white hover:bg-black"
>
Save and close
</Button>
</div>
</div>
<EditPricesActions
selectedCurrencies={selectedCurrencies.sort()}
selectedRegions={selectedRegions.sort()}
toggleCurrency={toggleCurrency}
toggleRegion={toggleRegion}
/>
<EditPricesTable
product={props.product}
currencies={selectedCurrencies.sort()}
regions={selectedRegions.sort()}
onPriceUpdate={onPriceUpdate}
/>
</div>
</Modal.Body>
{showCloseConfirmationPrompt && (
<DeletePrompt
handleClose={() => setShowCloseConfirmationPrompt(false)}
onDelete={async () => props.close()}
successText={""}
confirmText="Yes, close"
heading="Close"
text="Are you sure you want to close this editor without saving?"
/>
)}
{showSaveConfirmationPrompt && (
<SavePrompt
onSaveAll={async () => save()}
onSaveOnlyVisible={async () => save(true)}
hiddenEditedColumns={detectHiddenEditedColumns()}
handleClose={() => setShowSaveConfirmationPrompt(false)}
/>
)}
</Fade>
)
}
export default EditPricesModal
@@ -0,0 +1,111 @@
import React, { useState } from "react"
import clsx from "clsx"
import Button from "../../../fundamentals/button"
import Modal from "../../../molecules/modal"
import RadioGroup from "../../radio-group"
type ConfirmationPromptProps = {
handleClose: () => void
onSaveOnlyVisible: () => Promise<void>
onSaveAll: () => Promise<void>
hiddenEditedColumns: string[]
}
enum SaveMode {
SAVE_VISIBLE_ONLY = "SAVE_VISIBLE_ONLY",
SAVE_ALL = "SAVE_ALL",
}
const SavePrompt: React.FC<ConfirmationPromptProps> = ({
handleClose,
onSaveOnlyVisible,
onSaveAll,
hiddenEditedColumns,
}) => {
const [isLoading, setIsLoading] = useState(false)
const hasHiddenColumns = !!hiddenEditedColumns.length
const [saveSelection, setSaveSelection] = useState<SaveMode>(
SaveMode.SAVE_VISIBLE_ONLY
)
return (
<Modal isLargeModal={false} handleClose={handleClose}>
<Modal.Body>
<Modal.Content>
<div className="flex flex-col">
<span className="inter-large-semibold">Saving changes</span>
<span className="inter-base-regular text-grey-50 mt-1 mb-4 w-[420px]">
{hasHiddenColumns
? `You have edited prices in hidden columns: (${hiddenEditedColumns.join(
", "
)}). Do you wish to save these too?`
: "Save edited variant prices"}
</span>
</div>
{hasHiddenColumns && (
<RadioGroup.Root
className="gap-base mt-2 flex-col"
value={saveSelection}
onValueChange={setSaveSelection}
>
<RadioGroup.Item
className="flex-1"
label={"Save all"}
description={"Save all price changes"}
value={SaveMode.SAVE_ALL}
/>
<RadioGroup.Item
className="flex-1"
label={"Save only visible"}
description={"Save only visible price changes"}
value={SaveMode.SAVE_VISIBLE_ONLY}
/>
</RadioGroup.Root>
)}
</Modal.Content>
<Modal.Footer className="flex items-center border border-t">
<div className="mt-4 flex h-8 w-full justify-end gap-2">
<Button
variant="ghost"
className="text-small mr-2 justify-center"
size="small"
onClick={handleClose}
>
Cancel
</Button>
<Button
size="small"
color="black"
className={clsx(
"text-small justify-center bg-black text-white active:bg-black active:text-white",
{
"hover:bg-black": !isLoading,
}
)}
loading={isLoading}
onClick={() => {
setIsLoading(true)
const callback = !hasHiddenColumns
? onSaveOnlyVisible
: saveSelection === SaveMode.SAVE_ALL
? onSaveAll
: onSaveOnlyVisible
callback()
}}
>
Save changes
</Button>
</div>
</Modal.Footer>
</Modal.Body>
</Modal>
)
}
export default SavePrompt
@@ -0,0 +1,73 @@
import { MoneyAmount, Product } from "@medusajs/client-types"
/**
* Extract currencies that the product variants have pricing in.
*/
export function getAllProductPricesCurrencies(product: Product) {
const currencyMap: Record<string, true> = {}
product.variants!.forEach((variant) => {
variant.prices!.forEach((price) => {
if (
price.price_list_id ||
price.region_id ||
price.min_quantity ||
price.max_quantity
) {
return
}
currencyMap[price.currency_code] = true
})
})
return Object.keys(currencyMap)
}
/**
* Extract regions that the product variants have pricing in.
*/
export function getAllProductPricesRegions(product: Product) {
const regionMap: Record<string, true> = {}
product.variants!.forEach((variant) => {
variant.prices!.forEach((price) => {
if (
price.price_list_id ||
price.min_quantity ||
price.max_quantity ||
!price.region_id
) {
return
}
regionMap[price.region_id] = true
})
})
return Object.keys(regionMap)
}
/**
* Return only currency prices.
*/
export function getCurrencyPricesOnly(prices: MoneyAmount[]) {
return prices.filter((price) => {
if (
price.price_list_id ||
price.region_id ||
price.min_quantity ||
price.max_quantity
) {
return false
}
return true
})
}
/**
* Return only region prices.
*/
export function getRegionPricesOnly(prices: MoneyAmount[]) {
return prices.filter((price) => {
return !(price.price_list_id || price.min_quantity || price.max_quantity)
})
}
@@ -18,6 +18,8 @@ import { adminInventoryItemsKeys, useMedusa } from "medusa-react"
import { useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import useToggleState from "../../../hooks/use-toggle-state"
import DollarSignIcon from "../../fundamentals/icons/dollar-sign-icon"
import Index from "./edit-prices-modal"
type Props = {
product: Product
@@ -59,12 +61,23 @@ const ProductVariantsSection = ({ product }: Props) => {
toggle: toggleEditVariants,
} = useToggleState()
const {
state: showEditPrices,
close: hideEditPrices,
toggle: toggleEditPrices,
} = useToggleState()
const actions: ActionType[] = [
{
label: "Add Variant",
onClick: toggleAddVariant,
icon: <PlusIcon size="20" />,
},
{
label: "Edit Prices",
onClick: toggleEditPrices,
icon: <DollarSignIcon size="20" />,
},
{
label: "Edit Variants",
onClick: toggleEditVariants,
@@ -148,6 +161,7 @@ const ProductVariantsSection = ({ product }: Props) => {
onClose={closeEditVariants}
product={product}
/>
{showEditPrices && <Index close={hideEditPrices} product={product} />}
{variantToEdit && (
<EditVariantModal
variant={variantToEdit.base}