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>
This commit is contained in:
Frane Polić
2023-08-14 21:37:12 +02:00
committed by GitHub
parent 0887e5007f
commit 4a448b68fd
4 changed files with 432 additions and 59 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/admin-ui": patch
"@medusajs/admin": patch
---
feat(admin-ui): bulk advanced selections + copy/paste

View File

@@ -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 (
<td
onMouseOver={() => 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" : "",
}}
>
<div className="flex">
<span className="text-gray-400">{currencyMeta?.symbol_native}</span>
@@ -206,7 +229,7 @@ function CurrencyCell(props: CurrencyCellProps) {
decimalSeparator="."
placeholder="-"
/>
{isRangeEnd && !isEditable && (
{isRangeEnd && props.isRangeEndCol && !isEditable && (
<div
style={{ bottom: -4, right: -4, zIndex: 9999 }}
onMouseDown={onFillIndicatorMouseDown}

View File

@@ -2,11 +2,24 @@ 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 {
getCurrencyPricesOnly,
getRegionPricesOnly,
mod,
isText,
} from "./utils"
import IconBuildingTax from "../../../fundamentals/icons/building-tax-icon"
import { currencies as CURRENCY_MAP } from "../../../../utils/currencies"
import Tooltip from "../../../atoms/tooltip"
import CurrencyCell from "./currency-cell"
import useNotification from "../../../../hooks/use-notification"
enum ArrowMove {
UP,
DOWN,
LEFT,
RIGHT,
}
type EditPricesTableProps = {
product: Product
@@ -21,9 +34,9 @@ type EditPricesTableProps = {
let anchorVariant: string | undefined
/**
* During drag move keep info which column is active one.
* Currency or region column that is origin of the drag move.
*/
let activeCurrencyOrRegion: string | undefined = undefined
let anchorCurrencyOrRegion: string | undefined
/**
* Pointer for displaying highlight rectangle range.
@@ -32,6 +45,10 @@ let startIndex: number | undefined
let endIndex: number | undefined
let anchorIndex: number | undefined
let startIndexCol: number | undefined
let endIndexCol: number | undefined
let anchorIndexCol: number | undefined
/**
* Temp. variable for persisting previous "editedPrices" state before editing,
* so we can undo changes.
@@ -61,8 +78,12 @@ function EditPricesTable(props: EditPricesTableProps) {
limit: 1000,
})
const notification = useNotification()
const initialPricesSet = useRef(false)
const columns = [...props.currencies, ...props.regions]
const [isDragFill, setIsDragFill] = useState(false)
const [isDrag, setIsDrag] = useState(false)
const [editedPrices, setEditedPrices] = useState<
@@ -78,7 +99,7 @@ function EditPricesTable(props: EditPricesTableProps) {
region?: string,
override?: boolean
) => {
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}`}
</td>
{props.currencies.map((c) => {
{props.currencies.map((c, indexCol) => {
return (
<CurrencyCell
key={variant.id + c}
@@ -454,48 +782,53 @@ function EditPricesTable(props: EditPricesTableProps) {
onInputChange={onInputChange}
onMouseCellClick={onMouseCellClick}
onDragFillStart={onDragFillStart}
isAnchor={anchorIndex === index}
isRangeStart={
activeCurrencyOrRegion === c && startIndex === index
onColumnOver={onColumnOver}
isAnchor={
anchorIndex === index && anchorIndexCol === indexCol
}
isRangeEnd={
activeCurrencyOrRegion === c && index === endIndex
}
isInRange={
activeCurrencyOrRegion === c &&
index >= 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) => (
<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
}
/>
))}
{props.regions.map((r, indexCol) => {
indexCol = props.currencies.length + indexCol
return (
<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}
onColumnOver={onColumnOver}
isAnchor={
anchorIndex === index && anchorIndexCol === indexCol
}
isRangeStart={startIndex === index}
isRangeEnd={index === endIndex}
isInRange={index >= startIndex && index <= endIndex}
isRangeStartCol={startIndexCol === indexCol}
isRangeEndCol={indexCol === endIndexCol}
isInRangeCol={
indexCol >= startIndexCol && indexCol <= endIndexCol
}
/>
)
})}
</tr>
)
})}

View File

@@ -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))
}