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 clsx from "clsx"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
type SpinnerProps = {
|
export type SpinnerProps = {
|
||||||
size?: "large" | "medium" | "small"
|
size?: "large" | "medium" | "small"
|
||||||
variant?: "primary" | "secondary"
|
variant?: "primary" | "secondary"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ type Props = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-usable nested form used to submit pricing information for products and their variants.
|
* 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.
|
* currency and region specific prices.
|
||||||
* @example
|
* @example
|
||||||
* <Pricing form={nestedForm(form, "prices")} />
|
* <Pricing form={nestedForm(form, "prices")} />
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React, { Children } from "react"
|
import React, { Children } from "react"
|
||||||
|
|
||||||
import Spinner from "../../atoms/spinner"
|
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
|
|
||||||
|
import Spinner, { SpinnerProps } from "../../atoms/spinner"
|
||||||
|
|
||||||
export type ButtonProps = {
|
export type ButtonProps = {
|
||||||
variant: "primary" | "secondary" | "ghost" | "danger" | "nuclear"
|
variant: "primary" | "secondary" | "ghost" | "danger" | "nuclear"
|
||||||
size?: "small" | "medium" | "large"
|
size?: "small" | "medium" | "large"
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
spanClassName?: string
|
spanClassName?: string
|
||||||
|
spinnerConfig?: SpinnerProps
|
||||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
@@ -17,6 +18,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
size = "large",
|
size = "large",
|
||||||
loading = false,
|
loading = false,
|
||||||
spanClassName,
|
spanClassName,
|
||||||
|
spinnerConfig,
|
||||||
children,
|
children,
|
||||||
...attributes
|
...attributes
|
||||||
},
|
},
|
||||||
@@ -56,7 +58,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Spinner size={size} variant={"secondary"} />
|
<Spinner size={size} variant={"secondary"} {...spinnerConfig} />
|
||||||
) : (
|
) : (
|
||||||
Children.map(children, (child, i) => {
|
Children.map(children, (child, i) => {
|
||||||
return (
|
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 { useQueryClient } from "@tanstack/react-query"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import useToggleState from "../../../hooks/use-toggle-state"
|
import useToggleState from "../../../hooks/use-toggle-state"
|
||||||
|
import DollarSignIcon from "../../fundamentals/icons/dollar-sign-icon"
|
||||||
|
import Index from "./edit-prices-modal"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
product: Product
|
product: Product
|
||||||
@@ -59,12 +61,23 @@ const ProductVariantsSection = ({ product }: Props) => {
|
|||||||
toggle: toggleEditVariants,
|
toggle: toggleEditVariants,
|
||||||
} = useToggleState()
|
} = useToggleState()
|
||||||
|
|
||||||
|
const {
|
||||||
|
state: showEditPrices,
|
||||||
|
close: hideEditPrices,
|
||||||
|
toggle: toggleEditPrices,
|
||||||
|
} = useToggleState()
|
||||||
|
|
||||||
const actions: ActionType[] = [
|
const actions: ActionType[] = [
|
||||||
{
|
{
|
||||||
label: "Add Variant",
|
label: "Add Variant",
|
||||||
onClick: toggleAddVariant,
|
onClick: toggleAddVariant,
|
||||||
icon: <PlusIcon size="20" />,
|
icon: <PlusIcon size="20" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Edit Prices",
|
||||||
|
onClick: toggleEditPrices,
|
||||||
|
icon: <DollarSignIcon size="20" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Edit Variants",
|
label: "Edit Variants",
|
||||||
onClick: toggleEditVariants,
|
onClick: toggleEditVariants,
|
||||||
@@ -148,6 +161,7 @@ const ProductVariantsSection = ({ product }: Props) => {
|
|||||||
onClose={closeEditVariants}
|
onClose={closeEditVariants}
|
||||||
product={product}
|
product={product}
|
||||||
/>
|
/>
|
||||||
|
{showEditPrices && <Index close={hideEditPrices} product={product} />}
|
||||||
{variantToEdit && (
|
{variantToEdit && (
|
||||||
<EditVariantModal
|
<EditVariantModal
|
||||||
variant={variantToEdit.base}
|
variant={variantToEdit.base}
|
||||||
|
|||||||
Reference in New Issue
Block a user