From bfc0ea5695b8999a63df9ba58409e3e139473afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Wed, 19 Jul 2023 15:38:04 +0200 Subject: [PATCH] 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> --- .changeset/purple-scissors-tap.md | 6 + .../ui/src/components/atoms/spinner.tsx | 2 +- .../forms/general/prices-form/index.tsx | 2 +- .../components/fundamentals/button/index.tsx | 8 +- .../fundamentals/icons/building-tax-icon.tsx | 67 +++ .../edit-prices-modal/currency-cell.tsx | 221 ++++++++ .../edit-prices-modal/edit-prices-actions.tsx | 106 ++++ .../edit-prices-modal/edit-prices-table.tsx | 508 ++++++++++++++++++ .../edit-prices-modal/index.tsx | 365 +++++++++++++ .../edit-prices-modal/save-prompt.tsx | 111 ++++ .../edit-prices-modal/utils.ts | 73 +++ .../product-variants-section/index.tsx | 14 + 12 files changed, 1478 insertions(+), 5 deletions(-) create mode 100644 .changeset/purple-scissors-tap.md create mode 100644 packages/admin-ui/ui/src/components/fundamentals/icons/building-tax-icon.tsx create mode 100644 packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/currency-cell.tsx create mode 100644 packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/edit-prices-actions.tsx create mode 100644 packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/edit-prices-table.tsx create mode 100644 packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/index.tsx create mode 100644 packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/save-prompt.tsx create mode 100644 packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/utils.ts diff --git a/.changeset/purple-scissors-tap.md b/.changeset/purple-scissors-tap.md new file mode 100644 index 0000000000..8e913345f6 --- /dev/null +++ b/.changeset/purple-scissors-tap.md @@ -0,0 +1,6 @@ +--- +"@medusajs/admin-ui": patch +"@medusajs/admin": patch +--- + +feat(admin-ui): variant prices bulk editor diff --git a/packages/admin-ui/ui/src/components/atoms/spinner.tsx b/packages/admin-ui/ui/src/components/atoms/spinner.tsx index 153223731f..567aa3ba35 100644 --- a/packages/admin-ui/ui/src/components/atoms/spinner.tsx +++ b/packages/admin-ui/ui/src/components/atoms/spinner.tsx @@ -1,7 +1,7 @@ import clsx from "clsx" import React from "react" -type SpinnerProps = { +export type SpinnerProps = { size?: "large" | "medium" | "small" variant?: "primary" | "secondary" } diff --git a/packages/admin-ui/ui/src/components/forms/general/prices-form/index.tsx b/packages/admin-ui/ui/src/components/forms/general/prices-form/index.tsx index d94c11aaae..c89b4bc55d 100644 --- a/packages/admin-ui/ui/src/components/forms/general/prices-form/index.tsx +++ b/packages/admin-ui/ui/src/components/forms/general/prices-form/index.tsx @@ -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 * diff --git a/packages/admin-ui/ui/src/components/fundamentals/button/index.tsx b/packages/admin-ui/ui/src/components/fundamentals/button/index.tsx index dc32a81f5a..962cbd7bf2 100644 --- a/packages/admin-ui/ui/src/components/fundamentals/button/index.tsx +++ b/packages/admin-ui/ui/src/components/fundamentals/button/index.tsx @@ -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 const Button = React.forwardRef( @@ -17,6 +18,7 @@ const Button = React.forwardRef( size = "large", loading = false, spanClassName, + spinnerConfig, children, ...attributes }, @@ -56,7 +58,7 @@ const Button = React.forwardRef( onClick={handleClick} > {loading ? ( - + ) : ( Children.map(children, (child, i) => { return ( diff --git a/packages/admin-ui/ui/src/components/fundamentals/icons/building-tax-icon.tsx b/packages/admin-ui/ui/src/components/fundamentals/icons/building-tax-icon.tsx new file mode 100644 index 0000000000..29fc41bdce --- /dev/null +++ b/packages/admin-ui/ui/src/components/fundamentals/icons/building-tax-icon.tsx @@ -0,0 +1,67 @@ +import React from "react" +import IconProps from "./types/icon-type" + +const IconBuildingTax: React.FC = ({ + size = "20px", + color = "currentColor", + ...attributes +}) => { + return ( + + + + + + + + + ) +} + +export default IconBuildingTax diff --git a/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/currency-cell.tsx b/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/currency-cell.tsx new file mode 100644 index 0000000000..3d044c8855 --- /dev/null +++ b/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/currency-cell.tsx @@ -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) => ) +const currencySpan = forwardRef((props, ref) => ( + + {props.value || props.placeholder} + +)) + +/** + * 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 ( + +
+ {currencyMeta?.symbol_native} + 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 && ( +
+ )} +
+ + ) +} + +export default CurrencyCell diff --git a/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/edit-prices-actions.tsx b/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/edit-prices-actions.tsx new file mode 100644 index 0000000000..b59c30d8ec --- /dev/null +++ b/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/edit-prices-actions.tsx @@ -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 ( +
+ + + + + + + + Currencies + + {currencies?.map((c) => ( + { + event.preventDefault() + toggleCurrency(c.code) + }} + className="mb-1 cursor-pointer last:mb-0 hover:bg-gray-100" + > +
+ {c.code.toUpperCase()} + +
+
+ ))} + + + Regions + + {regions?.map((r) => ( + { + event.preventDefault() + toggleRegion(r.id) + }} + className="mb-1 cursor-pointer last:mb-0 hover:bg-gray-100" + key={r.id} + > +
+ {r.name} + {selectedRegions.includes(r.id) && ( + + )} +
+
+ ))} +
+
+
+ ) +} + +export default EditPricesActions diff --git a/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/edit-prices-table.tsx b/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/edit-prices-table.tsx new file mode 100644 index 0000000000..009ef64107 --- /dev/null +++ b/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/edit-prices-table.tsx @@ -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) => 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 | 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 + >({}) + const [selectedCells, setSelectedCells] = useState>( + {} + ) + + 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 = {} + 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 ( +
+ e.preventDefault() + } + style={{ fontSize: 13, borderCollapse: "collapse" }} + className="w-full table-auto" + > + + + + {props.currencies.map((c) => { + const currency = storeCurrencies?.find((sc) => sc.code === c) + return ( + + ) + })} + {props.regions.map((r) => { + const region = storeRegions?.find((sr) => sr.id === r) + if (!region) { + return null + } + return ( + + ) + })} + + + + + + {props.currencies.map((c) => ( + + ))} + {props.regions.map((r) => ( + + ))} + + + {props.product.variants!.map((variant, index) => { + return ( + onMouseRowEnter(variant.id)} + style={{ lineHeight: 3 }} + > + + + {props.currencies.map((c) => { + return ( + = startIndex && + index <= endIndex + } + /> + ) + })} + + {props.regions.map((r) => ( + = startIndex && + index <= endIndex + } + /> + ))} + + ) + })} + +
+ Product + +
+ Price {c.toUpperCase()} + {currency?.includes_tax && ( + + + + )} +
+
+
+ + + Price {region?.name} + + ({region?.currency_code.toUpperCase()}) + + {region.includes_tax && ( + + + + )} +
+
+
+ {props.product.thumbnail && ( + Thumbnail + )} + + {productTitle} + +
+
+ - + + - +
+ {variant.title} {variant.sku && `∙ ${variant.sku}`} +
+
+ ) +} + +export default EditPricesTable diff --git a/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/index.tsx b/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/index.tsx new file mode 100644 index 0000000000..3ceb760e5f --- /dev/null +++ b/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/index.tsx @@ -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(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) => { + 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 = + 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[] = [] + + 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 ( + + +
+
+
+ + + esc + +
+
+ + +
+
+ + +
+
+ {showCloseConfirmationPrompt && ( + 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 && ( + save()} + onSaveOnlyVisible={async () => save(true)} + hiddenEditedColumns={detectHiddenEditedColumns()} + handleClose={() => setShowSaveConfirmationPrompt(false)} + /> + )} +
+ ) +} + +export default EditPricesModal diff --git a/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/save-prompt.tsx b/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/save-prompt.tsx new file mode 100644 index 0000000000..75a571dce4 --- /dev/null +++ b/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/save-prompt.tsx @@ -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 + onSaveAll: () => Promise + hiddenEditedColumns: string[] +} + +enum SaveMode { + SAVE_VISIBLE_ONLY = "SAVE_VISIBLE_ONLY", + SAVE_ALL = "SAVE_ALL", +} + +const SavePrompt: React.FC = ({ + handleClose, + onSaveOnlyVisible, + onSaveAll, + hiddenEditedColumns, +}) => { + const [isLoading, setIsLoading] = useState(false) + + const hasHiddenColumns = !!hiddenEditedColumns.length + + const [saveSelection, setSaveSelection] = useState( + SaveMode.SAVE_VISIBLE_ONLY + ) + + return ( + + + +
+ Saving changes + + {hasHiddenColumns + ? `You have edited prices in hidden columns: (${hiddenEditedColumns.join( + ", " + )}). Do you wish to save these too?` + : "Save edited variant prices"} + +
+ + {hasHiddenColumns && ( + + + + + )} +
+ +
+ + +
+
+
+
+ ) +} + +export default SavePrompt diff --git a/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/utils.ts b/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/utils.ts new file mode 100644 index 0000000000..6d6f5a2c54 --- /dev/null +++ b/packages/admin-ui/ui/src/components/organisms/product-variants-section/edit-prices-modal/utils.ts @@ -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 = {} + + 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 = {} + + 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) + }) +} diff --git a/packages/admin-ui/ui/src/components/organisms/product-variants-section/index.tsx b/packages/admin-ui/ui/src/components/organisms/product-variants-section/index.tsx index 5d1f9d318a..da5ba46b0e 100644 --- a/packages/admin-ui/ui/src/components/organisms/product-variants-section/index.tsx +++ b/packages/admin-ui/ui/src/components/organisms/product-variants-section/index.tsx @@ -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: , }, + { + label: "Edit Prices", + onClick: toggleEditPrices, + icon: , + }, { label: "Edit Variants", onClick: toggleEditVariants, @@ -148,6 +161,7 @@ const ProductVariantsSection = ({ product }: Props) => { onClose={closeEditVariants} product={product} /> + {showEditPrices && } {variantToEdit && (