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