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:
co-authored by
Oliver Windall Juhl
parent
f325881227
commit
bfc0ea5695
@@ -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
|
||||
+221
@@ -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
|
||||
+106
@@ -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
|
||||
+508
@@ -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
|
||||
+365
@@ -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
|
||||
+111
@@ -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
|
||||
+73
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user