From 4a448b68fde90beb956375018ac833f6478726e6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Frane=20Poli=C4=87?=
<16856471+fPolic@users.noreply.github.com>
Date: Mon, 14 Aug 2023 21:37:12 +0200
Subject: [PATCH] feat(admin-ui): bulk advanced selections + copy/paste (#4568)
* wip: bulk editor copy/paste
* feat: exit edit mode with "enter" press
* wip: arrow navigation + onEnter
* wip: 2D select + arrow navigation
* feat: arrow navigation and multiselect, tabs navigation and multiselect
* fix: region cols offset
* feat: 2d copy
* feat: 2d paste
* fix: trailing tab
* fix: borders
* feat: ensure consistent copy order
* fix: off by one col, pass `cmd` keypress
* feat: `cmd` select
* refactor: cleanup 1
* refactor: cleanup 2, utils
* fix: copy paste
* fix: copy paste indicator
* fix: reduce dashed border size
* fix: issue with leading empty cell
* feat: cp support 2 formats of content, notification on copy, remove dashed box
* fix: last empty cell case
* feat: buffer content edge cases
* refactor: remove log
* feat: past fill selected area
* feat: simplify copy-paste
* fix: throw error if textual cell is in the buffer
* Create eighty-zebras-grow.md
---------
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
---
.changeset/eighty-zebras-grow.md | 6 +
.../edit-prices-modal/currency-cell.tsx | 35 +-
.../edit-prices-modal/edit-prices-table.tsx | 439 +++++++++++++++---
.../edit-prices-modal/utils.ts | 11 +
4 files changed, 432 insertions(+), 59 deletions(-)
create mode 100644 .changeset/eighty-zebras-grow.md
diff --git a/.changeset/eighty-zebras-grow.md b/.changeset/eighty-zebras-grow.md
new file mode 100644
index 0000000000..d8989fe234
--- /dev/null
+++ b/.changeset/eighty-zebras-grow.md
@@ -0,0 +1,6 @@
+---
+"@medusajs/admin-ui": patch
+"@medusajs/admin": patch
+---
+
+feat(admin-ui): bulk advanced selections + copy/paste
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
index 3d044c8855..0cfbdc962f 100644
--- 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
@@ -37,6 +37,12 @@ type CurrencyCellProps = {
isRangeEnd: boolean
isInRange: boolean
+ isRangeStartCol: boolean
+ isRangeEndCol: boolean
+ isInRangeCol: boolean
+
+ onColumnOver: (currencyOrRegion: string) => void
+
onDragFillStart: (
variantId: string,
currencyCode?: string,
@@ -79,6 +85,7 @@ function CurrencyCell(props: CurrencyCellProps) {
isInRange,
isRangeStart,
isRangeEnd,
+ onColumnOver,
} = props
const ref = useRef()
@@ -135,6 +142,15 @@ function CurrencyCell(props: CurrencyCellProps) {
* If we use set timout it will work as expected.
*/
setTimeout(() => ref.current.focus())
+
+ const onEnter = (e: KeyboardEvent) => {
+ if (e.key === "Enter") {
+ ref.current.blur()
+ }
+ }
+
+ document.addEventListener("keypress", onEnter)
+ return () => document.removeEventListener("keypress", onEnter)
} else {
// Format value back after edit
setLocalValue({
@@ -179,14 +195,21 @@ function CurrencyCell(props: CurrencyCellProps) {
return (
onColumnOver(currencyCode || region)}
onMouseDown={onCellMouseDown}
- className={clsx("relative cursor-pointer pr-2 pl-4", {
- border: !isInRange,
+ className={clsx("relative cursor-pointer border pr-2 pl-4", {
"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,
})}
+ style={{
+ borderTop:
+ isRangeStart && props.isInRangeCol ? "1px double #3B82F6" : "",
+ borderBottom:
+ isRangeEnd && props.isInRangeCol ? "1px double #3B82F6" : "",
+ borderLeft:
+ props.isRangeStartCol && isInRange ? "1px double #3B82F6" : "",
+ borderRight:
+ props.isRangeEndCol && isInRange ? "1px double #3B82F6" : "",
+ }}
>
{currencyMeta?.symbol_native}
@@ -206,7 +229,7 @@ function CurrencyCell(props: CurrencyCellProps) {
decimalSeparator="."
placeholder="-"
/>
- {isRangeEnd && !isEditable && (
+ {isRangeEnd && props.isRangeEndCol && !isEditable && (
{
- if ((currencyCode || region) !== activeCurrencyOrRegion) {
+ if ((currencyCode || region) !== anchorCurrencyOrRegion) {
return
}
@@ -111,14 +132,169 @@ function EditPricesTable(props: EditPricesTableProps) {
startIndex = undefined
endIndex = undefined
+ anchorIndexCol = undefined
+ startIndexCol = undefined
+ endIndexCol = undefined
+
anchorVariant = undefined
- activeCurrencyOrRegion = undefined
+ anchorCurrencyOrRegion = 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({})
}
+ const moveAnchor = (
+ direction: ArrowMove,
+ isShift: boolean,
+ isCmd: boolean
+ ) => {
+ if (!anchorIndex) {
+ setSelectedCells({ [getKey(variantIds[0], columns[0])]: true })
+ }
+
+ if (direction === ArrowMove.DOWN) {
+ if (!isShift) {
+ let ind = variantIds.findIndex((v) => v === anchorVariant)
+ ind = mod(ind + 1, variantIds.length)
+ const nextVariant = variantIds[ind]
+
+ anchorVariant = nextVariant
+ anchorIndex = ind
+ startIndex = ind
+ endIndex = ind
+
+ startIndexCol = anchorIndexCol
+ endIndexCol = anchorIndexCol
+
+ setSelectedCells({
+ [getKey(nextVariant, anchorCurrencyOrRegion)]: true,
+ })
+ } else {
+ if (isCmd) {
+ startIndex = anchorIndex
+ endIndex = variantIds.length - 1
+ } else {
+ if (anchorIndex === startIndex) {
+ endIndex = Math.min(endIndex + 1, variantIds.length - 1)
+ } else {
+ startIndex = Math.min(startIndex + 1, variantIds.length - 1)
+ }
+ }
+
+ onSelectionRangeChange()
+ }
+ }
+
+ if (direction === ArrowMove.UP) {
+ if (!isShift) {
+ let ind = variantIds.findIndex((v) => v === anchorVariant)
+ ind = mod(ind - 1, variantIds.length)
+ const nextVariant = variantIds[ind]
+ anchorVariant = nextVariant
+
+ anchorIndex = ind
+ startIndex = ind
+ endIndex = ind
+
+ startIndexCol = anchorIndexCol
+ endIndexCol = anchorIndexCol
+
+ setSelectedCells({
+ [getKey(nextVariant, anchorCurrencyOrRegion)]: true,
+ })
+ } else {
+ if (isCmd) {
+ endIndex = anchorIndex
+ startIndex = 0
+ } else {
+ if (anchorIndex === startIndex) {
+ if (startIndex === endIndex) {
+ startIndex = Math.max(startIndex - 1, 0)
+ } else {
+ endIndex = Math.max(endIndex - 1, 0)
+ }
+ } else {
+ startIndex = Math.max(startIndex - 1, 0)
+ }
+ }
+
+ onSelectionRangeChange()
+ }
+ }
+
+ if (direction === ArrowMove.LEFT) {
+ if (!isShift) {
+ let ind = columns.findIndex((v) => v === anchorCurrencyOrRegion)
+ ind = mod(ind - 1, columns.length)
+ const nextCol = columns[ind]
+
+ anchorCurrencyOrRegion = nextCol
+
+ anchorIndexCol = ind
+ startIndexCol = ind
+ endIndexCol = ind
+
+ startIndex = anchorIndex
+ endIndex = anchorIndex
+
+ setSelectedCells({
+ [getKey(anchorVariant, nextCol)]: true,
+ })
+ } else {
+ if (isCmd) {
+ endIndexCol = anchorIndexCol
+ startIndexCol = 0
+ } else {
+ if (anchorIndexCol === startIndexCol) {
+ if (startIndexCol === endIndexCol) {
+ startIndexCol = Math.max(startIndexCol - 1, 0)
+ } else {
+ endIndexCol = Math.max(endIndexCol - 1, 0)
+ }
+ } else {
+ startIndexCol = Math.max(startIndexCol - 1, 0)
+ }
+ }
+
+ onSelectionRangeChange()
+ }
+ }
+
+ if (direction === ArrowMove.RIGHT) {
+ if (!isShift) {
+ let ind = columns.findIndex((v) => v === anchorCurrencyOrRegion)
+ ind = mod(ind + 1, columns.length)
+ const nextCol = columns[ind]
+
+ anchorCurrencyOrRegion = nextCol
+
+ anchorIndexCol = ind
+ startIndexCol = ind
+ endIndexCol = ind
+
+ startIndex = anchorIndex
+ endIndex = anchorIndex
+
+ setSelectedCells({
+ [getKey(anchorVariant, nextCol)]: true,
+ })
+ } else {
+ if (isCmd) {
+ startIndexCol = anchorIndexCol
+ endIndexCol = columns.length - 1
+ } else {
+ if (anchorIndexCol === startIndexCol) {
+ endIndexCol = Math.min(endIndexCol + 1, columns.length - 1)
+ } else {
+ startIndexCol = Math.min(startIndexCol + 1, columns.length - 1)
+ }
+ }
+ onSelectionRangeChange()
+ }
+ }
+ }
+
/**
* ==================== HANDLERS ====================
*/
@@ -138,10 +314,37 @@ function EditPricesTable(props: EditPricesTableProps) {
endIndex = anchorIndex
}
+ onSelectionRangeChange()
+ }
+
+ const onColumnOver = (currencyOrRegion: string) => {
+ if (!(isDragFill || isDrag)) {
+ return
+ }
+
+ const currentIndexCol = columns.findIndex((v) => v === currencyOrRegion)
+
+ if (currentIndexCol > anchorIndexCol) {
+ endIndexCol = currentIndexCol
+ startIndexCol = anchorIndexCol
+ } else {
+ startIndexCol = currentIndexCol
+ endIndexCol = anchorIndexCol
+ }
+
+ onSelectionRangeChange()
+ }
+
+ const onSelectionRangeChange = () => {
+ const selectedColumns = columns.slice(startIndexCol, endIndexCol + 1)
const selectedVariants = variantIds.slice(startIndex, endIndex + 1)
- const keys = selectedVariants.map((vId) =>
- getKey(vId, activeCurrencyOrRegion)
+ const keys = []
+
+ selectedVariants.forEach((vId) =>
+ selectedColumns.forEach((c) => {
+ keys.push(getKey(vId, c))
+ })
)
const nextSelection = { ...selectedCells }
@@ -149,7 +352,7 @@ function EditPricesTable(props: EditPricesTableProps) {
Object.keys(nextSelection).forEach((k) => {
// deselect case
- if (k.split("-")[1] === activeCurrencyOrRegion && !keys.includes(k)) {
+ if (!keys.includes(k)) {
delete nextSelection[k] // remove selection
nextPrices[k] = prevPriceState[k] // ...and reset price of that cell to the previous state
}
@@ -161,7 +364,7 @@ function EditPricesTable(props: EditPricesTableProps) {
if (isDragFill) {
nextPrices[k] =
- editedPrices[getKey(anchorVariant, activeCurrencyOrRegion)]
+ editedPrices[getKey(anchorVariant, anchorCurrencyOrRegion)]
}
})
@@ -184,14 +387,16 @@ function EditPricesTable(props: EditPricesTableProps) {
// set variant row anchors
anchorVariant = variantId
anchorIndex = variantIds.findIndex((v) => v === anchorVariant)
+ startIndex = anchorIndex
+ endIndex = startIndex
+
+ anchorCurrencyOrRegion = currencyCode || regionId
+ anchorIndexCol = columns.findIndex((v) => v === anchorCurrencyOrRegion)
+ startIndexCol = anchorIndexCol
+ endIndexCol = anchorIndexCol
- 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 = (
@@ -288,6 +493,69 @@ function EditPricesTable(props: EditPricesTableProps) {
setEditedPrices(next)
}
+ if (e.key === "Tab") {
+ e.stopPropagation()
+ e.preventDefault()
+
+ if (e.shiftKey) {
+ if (anchorIndexCol === 0) {
+ moveAnchor(ArrowMove.UP, false)
+ }
+ moveAnchor(ArrowMove.LEFT, false)
+ } else {
+ if (anchorIndexCol === columns.length - 1) {
+ moveAnchor(ArrowMove.DOWN, false)
+ }
+ moveAnchor(ArrowMove.RIGHT, false)
+ }
+ }
+
+ if (e.keyCode === 38) {
+ moveAnchor(ArrowMove.UP, e.shiftKey, e.metaKey || e.ctrlKey)
+ }
+
+ if (e.keyCode === 40) {
+ moveAnchor(ArrowMove.DOWN, e.shiftKey, e.metaKey || e.ctrlKey)
+ }
+
+ if (e.keyCode === 37) {
+ moveAnchor(ArrowMove.LEFT, e.shiftKey, e.metaKey || e.ctrlKey)
+ }
+
+ if (e.keyCode === 39) {
+ moveAnchor(ArrowMove.RIGHT, e.shiftKey, e.metaKey || e.ctrlKey)
+ }
+
+ if ((e.ctrlKey || e.metaKey) && e.keyCode === 67) {
+ let ret = ""
+ const variants = {}
+ const columns = {}
+
+ Object.keys(selectedCells).forEach((k) => {
+ const [r, c] = k.split("-")
+ variants[r] = true
+ columns[c] = true
+ })
+
+ Object.keys(variants)
+ .sort((v1, v2) => variantIds.indexOf(v1) - variantIds.indexOf(v2))
+ .forEach((k) => {
+ Object.keys(columns).forEach((c) => {
+ const price = editedPrices[getKey(k, c)]
+
+ ret += (!isNaN(price) ? price : "") + "\t"
+ })
+
+ ret = ret.slice(0, -1)
+ ret += `\n`
+ })
+
+ ret = ret.slice(0, -1)
+
+ navigator.clipboard.writeText(ret)
+ notification("Success", "Copied to clipboard", "success")
+ }
+
/**
* Undo last selection change (or delete) on CMD/CTR + Z
*/
@@ -301,14 +569,74 @@ function EditPricesTable(props: EditPricesTableProps) {
}
}
+ const onPaste = (event: ClipboardEvent) => {
+ const paste = (event.clipboardData || window.clipboardData).getData(
+ "text"
+ )
+
+ const rows = paste.split("\n").map((r) => r.split("\t"))
+
+ // single cell click -> determine from the content
+ if (
+ typeof anchorIndex === "number" &&
+ typeof anchorIndexCol === "number"
+ ) {
+ const _edited = { ...editedPrices }
+
+ const isRange = startIndex !== endIndex || startIndexCol !== endIndexCol
+
+ // if only anchor is clicked past selected, if range is selected fill and repeat until selected area is filled
+ const iBoundary = !isRange ? rows.length - 1 : endIndex - startIndex
+
+ for (let i = 0; i <= iBoundary; i++) {
+ if (i >= variantIds.length) {
+ break
+ }
+
+ const parts = rows[i % rows.length]
+
+ // if only anchor is clicked past selected, if range is selected fill and repeat until selected area is filled
+ const jBoundary = !isRange
+ ? parts.length - 1
+ : endIndexCol - startIndexCol
+
+ for (let j = 0; j <= jBoundary; j++) {
+ if (j >= columns.length) {
+ break
+ }
+
+ if (isText(parts[j % parts.length])) {
+ notification(
+ "Error",
+ "Invalid data - copied cells contain text",
+ "error"
+ )
+ return
+ }
+
+ const amount = parseFloat(parts[j % parts.length])
+
+ _edited[
+ getKey(variantIds[startIndex + i], columns[startIndexCol + j])
+ ] = !isNaN(amount) ? amount : undefined
+ }
+ }
+
+ setEditedPrices(_edited)
+ }
+ }
+
document.addEventListener("mousedown", down)
document.addEventListener("mouseup", up)
document.addEventListener("keydown", onKeyDown)
+ document.addEventListener("paste", onPaste)
+
return () => {
document.removeEventListener("mousedown", down)
document.removeEventListener("mouseup", up)
- document.addEventListener("keydown", onKeyDown)
+ document.removeEventListener("keydown", onKeyDown)
+ document.removeEventListener("paste", onPaste)
}
}, [selectedCells])
@@ -443,7 +771,7 @@ function EditPricesTable(props: EditPricesTableProps) {
{variant.title} {variant.sku && `∙ ${variant.sku}`}
|
- {props.currencies.map((c) => {
+ {props.currencies.map((c, indexCol) => {
return (
= startIndex &&
- index <= endIndex
+ isRangeStart={startIndex === index}
+ isRangeEnd={index === endIndex}
+ isInRange={index >= startIndex && index <= endIndex}
+ isRangeStartCol={startIndexCol === indexCol}
+ isRangeEndCol={indexCol === endIndexCol}
+ isInRangeCol={
+ indexCol >= startIndexCol && indexCol <= endIndexCol
}
/>
)
})}
- {props.regions.map((r) => (
- = startIndex &&
- index <= endIndex
- }
- />
- ))}
+ {props.regions.map((r, indexCol) => {
+ indexCol = props.currencies.length + indexCol
+ return (
+ = startIndex && index <= endIndex}
+ isRangeStartCol={startIndexCol === indexCol}
+ isRangeEndCol={indexCol === endIndexCol}
+ isInRangeCol={
+ indexCol >= startIndexCol && indexCol <= endIndexCol
+ }
+ />
+ )
+ })}
)
})}
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
index 6d6f5a2c54..f9650bb145 100644
--- 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
@@ -71,3 +71,14 @@ export function getRegionPricesOnly(prices: MoneyAmount[]) {
return !(price.price_list_id || price.min_quantity || price.max_quantity)
})
}
+
+/**
+ * Modulo operation
+ */
+export function mod(n: number, m: number) {
+ return ((n % m) + m) % m
+}
+
+export const isText = (v: string) => {
+ return v !== "" && isNaN(Number(v))
+}